Add per-user saved column visibility for tables
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user