Add per-user saved column visibility for tables

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-05 11:56:49 +03:30
co-authored by Cursor
parent 75ff2c36c4
commit 23c83ffbee
29 changed files with 624 additions and 23 deletions
+189
View File
@@ -0,0 +1,189 @@
import { reactive, watch, watchEffect, type Directive } from 'vue';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';
/**
* نمایش/مخفی‌سازی ستون‌های جدول به‌ازای هر کاربر.
* ستون‌ها با متن سرستون (th) شناسایی می‌شوند تا ستون جدید به‌طور پیش‌فرض دیده شود.
*/
const STORAGE_PREFIX = 'mento_table_cols:';
const SAVE_DEBOUNCE_MS = 400;
const hiddenByTable = reactive<Record<string, string[]>>({});
const labelsByTable = reactive<Record<string, string[]>>({});
let activeScope: string | null = null;
let initialized = false;
const saveTimers = new Map<string, ReturnType<typeof setTimeout>>();
function storageKey(scope: string) {
return `${STORAGE_PREFIX}${scope}`;
}
function readCache(scope: string): Record<string, string[]> {
try {
const raw = localStorage.getItem(storageKey(scope));
const parsed = raw ? (JSON.parse(raw) as unknown) : null;
if (parsed && typeof parsed === 'object') return parsed as Record<string, string[]>;
} catch {
/* ignore corrupted cache */
}
return {};
}
function writeCache() {
if (!activeScope) return;
try {
localStorage.setItem(storageKey(activeScope), JSON.stringify(hiddenByTable));
} catch {
/* storage full / disabled */
}
}
function replaceHidden(next: Record<string, string[]>) {
for (const key of Object.keys(hiddenByTable)) delete hiddenByTable[key];
for (const [key, list] of Object.entries(next)) {
if (Array.isArray(list)) hiddenByTable[key] = list.filter((v) => typeof v === 'string');
}
}
async function loadScope(scope: string) {
replaceHidden(readCache(scope));
try {
const { data } = await api.get<Record<string, string[]>>('/table-prefs');
if (activeScope !== scope) return;
replaceHidden(data ?? {});
writeCache();
} catch {
/* offline: keep cached */
}
}
function ensureInit() {
if (initialized) return;
initialized = true;
const auth = useAuthStore();
watch(
() => (auth.user ? `${auth.user.id}:${auth.user.organizationId}` : null),
(scope) => {
activeScope = scope;
if (scope) void loadScope(scope);
else replaceHidden({});
},
{ immediate: true },
);
installStyle();
}
function cssEscape(value: string) {
return typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(value) : value.replace(/"/g, '\\"');
}
function installStyle() {
const style = document.createElement('style');
style.id = 'mento-table-cols';
document.head.appendChild(style);
watchEffect(() => {
const rules: string[] = [];
for (const [table, labels] of Object.entries(labelsByTable)) {
const hidden = new Set(hiddenByTable[table] ?? []);
if (!hidden.size) continue;
labels.forEach((label, idx) => {
if (!label || !hidden.has(label)) return;
const sel = `table[data-tc="${cssEscape(table)}"]`;
const n = idx + 1;
rules.push(
`${sel} > thead > tr > th:nth-child(${n}),${sel} > tbody > tr > td:nth-child(${n}):not([colspan]),${sel} > tfoot > tr > td:nth-child(${n}):not([colspan]){display:none}`,
);
});
}
style.textContent = rules.join('\n');
});
}
function persist(table: string) {
writeCache();
const prev = saveTimers.get(table);
if (prev) clearTimeout(prev);
saveTimers.set(
table,
setTimeout(() => {
saveTimers.delete(table);
void api
.put(`/table-prefs/${encodeURIComponent(table)}`, {
hiddenColumns: hiddenByTable[table] ?? [],
})
.catch(() => undefined);
}, SAVE_DEBOUNCE_MS),
);
}
function readHeaderLabels(table: HTMLTableElement): string[] {
const rows = table.tHead?.rows;
const row = rows && rows.length ? rows[rows.length - 1] : null;
if (!row) return [];
return Array.from(row.cells).map((cell) =>
(cell.getAttribute('data-col') || cell.textContent || '').replace(/\s+/g, ' ').trim(),
);
}
function syncLabels(el: HTMLElement, key: string) {
if (!(el instanceof HTMLTableElement)) return;
el.dataset.tc = key;
const labels = readHeaderLabels(el);
const prev = labelsByTable[key];
if (!prev || prev.length !== labels.length || prev.some((v, i) => v !== labels[i])) {
labelsByTable[key] = labels;
}
}
/** روی `<table v-table-cols="'contacts.people'">` قرار می‌گیرد. */
export const vTableCols: Directive<HTMLElement, string> = {
mounted(el, binding) {
ensureInit();
syncLabels(el, binding.value);
},
updated(el, binding) {
syncLabels(el, binding.value);
},
};
declare module 'vue' {
interface ComponentCustomProperties {
vTableCols: typeof vTableCols;
}
}
export function useTableColumns(table: () => string) {
ensureInit();
const columns = () =>
(labelsByTable[table()] ?? []).filter((label, idx, all) => label && all.indexOf(label) === idx);
const isVisible = (label: string) => !(hiddenByTable[table()] ?? []).includes(label);
const hiddenCount = () => {
const visibleLabels = new Set(columns());
return (hiddenByTable[table()] ?? []).filter((l) => visibleLabels.has(l)).length;
};
function toggle(label: string) {
const key = table();
const hidden = new Set(hiddenByTable[key] ?? []);
if (hidden.has(label)) {
hidden.delete(label);
} else {
const remaining = columns().filter((c) => c !== label && !hidden.has(c));
if (!remaining.length) return;
hidden.add(label);
}
hiddenByTable[key] = [...hidden];
persist(key);
}
function showAll() {
const key = table();
hiddenByTable[key] = [];
persist(key);
}
return { columns, isVisible, hiddenCount, toggle, showAll };
}