"use client" /** * Generic, opt-in lifecycle persistence for `useTableState`. * * - Any hub can persist its `DataTable` lifecycle (sort / search / filters / * column order / pin / width / hidden / row height / gridlines / etc.) to * `localStorage` by calling `useTableStateLifecycle({ namespace, tabId, * tableState, columnKeys })`. Don't call it → no persistence (the table * still works fine in memory). * - Storage keys are namespaced (`exxat-ds::lifecycle:v1:`) * so each hub owns its own keyspace and can't clobber another hub. * - Hubs that need to persist EXTRA state alongside the table (e.g. the * placements table also persists `conditionalRules` + pagination) pass an * `extras` object and an `onLoadExtras` callback. * - Saves are debounced (~400ms) and SSR-safe (no-op on the server). * * **Where this lives:** `@exxatdesignux/ui/lib/table-state-lifecycle` * (lifted from `apps/web/lib/` in 0.5.18 so customer scaffolds get * persistence for free). The old apps/web path re-exports from here for * back-compat. * * **Storage primitive:** Uses `scheduleStorageWrite` / `getStorageItem` * from `./persisted-state/storage` so quota errors, SSR guards, debounce, * and the `exxat-ds:` namespace prefix all live in one place. */ import * as React from "react" import type { Dispatch, SetStateAction } from "react" import type { RowHeight } from "./row-height" import { mergeColOrderWithPinPolicy, reconcileColumnPins, type ColumnPinPolicy, } from "./column-pin-policy" import type { DataListDisplayOptions } from "./data-list-display-options" import type { ActiveFilter, ConditionalRule, SortRule } from "./table-properties-types" // Type-only import; no runtime cycle. import type { ViewTab } from "../components/templates/list-page" import type { DataListViewType } from "./data-list-view" import { getStorageItem, setStorageItem, scheduleStorageWrite, } from "./persisted-state" // ───────────────────────────────────────────────────────────────────────────── // Storage key + debounce config // ───────────────────────────────────────────────────────────────────────────── const LIFECYCLE_SAVE_DEBOUNCE_MS = 400 const PAGE_SAVE_DEBOUNCE_MS = 400 /** Public so hubs that want to clear or namespace-scan storage can. */ export function lifecycleStorageKey(namespace: string, tabId: string): string { return `exxat-ds:${namespace}:lifecycle:v1:${tabId}` } export { columnPinPolicyFromDefs, reconcileColumnPins, type ColumnPinPolicy, } from "./column-pin-policy" export function pageStorageKey(namespace: string): string { return `exxat-ds:${namespace}:page:v1` } // ───────────────────────────────────────────────────────────────────────────── // Persisted shapes // ───────────────────────────────────────────────────────────────────────────── /** * Versioned snapshot of a single table's lifecycle state. The fields under * `extras` are entity-specific (e.g. placements stuff `conditionalRules` and * pagination there) and opaque to this module. Older v1 records (pre-extras * rollout) may have those entity-specific fields at the top level — the * parser accepts both shapes for back-compat. */ export interface PersistedLifecycleV1 { v: 1 sortRules: SortRule[] search: string activeFilters: ActiveFilter[] filterConnectors: Record groupBy: string | null colOrder: string[] hiddenCols: string[] colWidths: Record colPins: Record colWrap: Record colMenuSearch: Record rowHeight: RowHeight showGridlines: boolean filterBarVisible: boolean searchOpen: boolean /** Generic hub-defined extras. Persisted as JSON. */ extras?: Record /** * @deprecated Legacy top-level fields (used by placements pre-extras * rollout). New code SHOULD live under `extras`. Kept here so existing * placements `localStorage` payloads still parse. */ conditionalRules?: ConditionalRule[] pagination?: boolean paginationPage?: number paginationPageSize?: number } export interface PersistedPageV1 { v: 1 displayOptions: DataListDisplayOptions showMetrics: boolean tabs: ViewTab[] activeTabId: string } /** * Narrow surface the lifecycle hook needs from `useTableState` — getters + * setters for every persisted slice. Defined as a structural type so * callers can pass `tableState` directly (it satisfies this shape). */ export interface TableStatePersistSlice { sortRules: SortRule[] search: string activeFilters: ActiveFilter[] filterConnectors: Record groupBy: string | null colOrder: string[] hiddenCols: Set colWidths: Record colPins: Record colWrap: Record colMenuSearch: Record rowHeight: RowHeight showGridlines: boolean filterBarVisible: boolean searchOpen: boolean setSortRules: Dispatch> setSearch: Dispatch> setActiveFilters: Dispatch> setFilterConnectors: Dispatch>> setGroupBy: Dispatch> setColOrder: Dispatch> setHiddenCols: Dispatch>> setColWidths: Dispatch>> setColPins: Dispatch>> setColWrap: Dispatch>> setColMenuSearch: Dispatch>> setRowHeight: Dispatch> setShowGridlines: Dispatch> setFilterBarVisible: Dispatch> setSearchOpen: Dispatch> } // ───────────────────────────────────────────────────────────────────────────── // Parsers + validators // ───────────────────────────────────────────────────────────────────────────── const VIEW_TYPES: DataListViewType[] = ["table", "list", "board", "dashboard"] function isViewType(v: unknown): v is DataListViewType { return typeof v === "string" && (VIEW_TYPES as string[]).includes(v) } function parseViewTab(raw: unknown): ViewTab | null { if (!raw || typeof raw !== "object") return null const o = raw as Record if (typeof o.id !== "string" || typeof o.label !== "string") return null if (!isViewType(o.viewType)) return null if (typeof o.icon !== "string" || typeof o.filterId !== "string") return null return { id: o.id, label: o.label, viewType: o.viewType, icon: o.icon, filterId: o.filterId } } export function parsePersistedPage(raw: string | null): PersistedPageV1 | null { if (!raw) return null try { const j = JSON.parse(raw) as unknown if (!j || typeof j !== "object") return null const o = j as Record if (o.v !== 1) return null if (!o.displayOptions || typeof o.displayOptions !== "object") return null if (typeof o.showMetrics !== "boolean") return null if (!Array.isArray(o.tabs) || typeof o.activeTabId !== "string") return null const tabs = o.tabs.map(parseViewTab).filter((t): t is ViewTab => t !== null) if (tabs.length === 0) return null return { v: 1, displayOptions: o.displayOptions as DataListDisplayOptions, showMetrics: o.showMetrics, tabs, activeTabId: o.activeTabId, } } catch { return null } } export function parsePersistedLifecycle(raw: string | null): PersistedLifecycleV1 | null { if (!raw) return null try { const j = JSON.parse(raw) as unknown if (!j || typeof j !== "object") return null const o = j as Record if (o.v !== 1) return null if (!Array.isArray(o.sortRules)) return null if (typeof o.search !== "string") return null if (!Array.isArray(o.activeFilters)) return null if (!o.filterConnectors || typeof o.filterConnectors !== "object") return null if (o.groupBy !== null && typeof o.groupBy !== "string") return null if (!Array.isArray(o.colOrder)) return null if (!Array.isArray(o.hiddenCols)) return null if (!o.colWidths || typeof o.colWidths !== "object") return null if (!o.colPins || typeof o.colPins !== "object") return null if (!o.colWrap || typeof o.colWrap !== "object") return null if (!o.colMenuSearch || typeof o.colMenuSearch !== "object") return null if (typeof o.rowHeight !== "string") return null if (typeof o.showGridlines !== "boolean") return null if (typeof o.filterBarVisible !== "boolean") return null if (typeof o.searchOpen !== "boolean") return null // `extras` is optional; legacy placements payloads kept these at the top // level instead and we accept those for back-compat. if (o.extras !== undefined && (typeof o.extras !== "object" || o.extras === null)) return null return o as unknown as PersistedLifecycleV1 } catch { return null } } // ───────────────────────────────────────────────────────────────────────────── // Apply / serialize // ───────────────────────────────────────────────────────────────────────────── function mergeColOrder(saved: string[], columnKeys: Set, pinPolicy?: ColumnPinPolicy): string[] { return mergeColOrderWithPinPolicy(saved, columnKeys, pinPolicy) } function filterRecordKeys>(obj: T, keys: Set): T { const out = { ...obj } for (const k of Object.keys(out)) { if (!keys.has(k)) delete out[k] } return out } function sanitizeActiveFilters( filters: ActiveFilter[], columnKeys: Set, ): ActiveFilter[] { return filters.filter(f => columnKeys.has(f.fieldKey)) } function sanitizeSortRules(rules: SortRule[], columnKeys: Set): SortRule[] { return rules.filter(r => columnKeys.has(r.fieldKey)) } /** Column layout only — keeps in-memory search / filters when the column set changes. */ export function applyLifecycleColumnLayout( ts: TableStatePersistSlice, p: PersistedLifecycleV1, columnKeys: Set, pinPolicy?: ColumnPinPolicy, ): void { const colOrder = mergeColOrder(p.colOrder, columnKeys, pinPolicy) const hidden = new Set(p.hiddenCols.filter(k => columnKeys.has(k))) const colWidths = filterRecordKeys(p.colWidths, columnKeys) as Record const savedPins = filterRecordKeys(p.colPins, columnKeys) as Record const colPins = pinPolicy ? reconcileColumnPins(savedPins, pinPolicy) : savedPins const colWrap = filterRecordKeys(p.colWrap, columnKeys) as Record const colMenuSearch = filterRecordKeys(p.colMenuSearch, columnKeys) as Record ts.setColOrder(colOrder) ts.setHiddenCols(hidden) ts.setColWidths(colWidths) ts.setColPins(colPins) ts.setColWrap(colWrap) ts.setColMenuSearch(colMenuSearch) ts.setRowHeight(p.rowHeight) ts.setShowGridlines(p.showGridlines) } export function applyLifecyclePersisted( ts: TableStatePersistSlice, p: PersistedLifecycleV1, columnKeys: Set, pinPolicy?: ColumnPinPolicy, ): void { applyLifecycleColumnLayout(ts, p, columnKeys, pinPolicy) ts.setSortRules(sanitizeSortRules(p.sortRules, columnKeys)) ts.setSearch(p.search) ts.setActiveFilters(sanitizeActiveFilters(p.activeFilters, columnKeys)) ts.setFilterConnectors(p.filterConnectors) ts.setGroupBy(p.groupBy != null && columnKeys.has(p.groupBy) ? p.groupBy : null) ts.setFilterBarVisible(p.filterBarVisible) ts.setSearchOpen(p.searchOpen) } export function serializeLifecycle( ts: TableStatePersistSlice, extras?: Record, ): PersistedLifecycleV1 { return { v: 1, sortRules: ts.sortRules, search: ts.search, activeFilters: ts.activeFilters, filterConnectors: ts.filterConnectors, groupBy: ts.groupBy, colOrder: ts.colOrder, hiddenCols: [...ts.hiddenCols], colWidths: { ...ts.colWidths }, colPins: { ...ts.colPins }, colWrap: { ...ts.colWrap }, colMenuSearch: { ...ts.colMenuSearch }, rowHeight: ts.rowHeight, showGridlines: ts.showGridlines, filterBarVisible: ts.filterBarVisible, searchOpen: ts.searchOpen, extras, } } /** * Read merged extras from a payload, falling back to the legacy top-level * placements fields when `extras` isn't set yet. Generic so hubs can cast to * their own extras shape. */ export function readLifecycleExtras>( p: PersistedLifecycleV1, ): TExtras | undefined { if (p.extras) return p.extras as TExtras // Legacy placements payload — synthesise extras from known top-level keys. if ( p.conditionalRules !== undefined || p.pagination !== undefined || p.paginationPage !== undefined || p.paginationPageSize !== undefined ) { const legacy: Record = {} if (p.conditionalRules !== undefined) legacy.conditionalRules = p.conditionalRules if (p.pagination !== undefined) legacy.pagination = p.pagination if (p.paginationPage !== undefined) legacy.paginationPage = p.paginationPage if (p.paginationPageSize !== undefined) legacy.paginationPageSize = p.paginationPageSize return legacy as TExtras } return undefined } // ───────────────────────────────────────────────────────────────────────────── // Direct storage IO (rarely needed — prefer the hook) // ───────────────────────────────────────────────────────────────────────────── export function loadLifecycleFromStorage( namespace: string, tabId: string, ): PersistedLifecycleV1 | null { return parsePersistedLifecycle(getStorageItem(lifecycleStorageKey(namespace, tabId))) } export function scheduleLifecycleSave( namespace: string, tabId: string, payload: PersistedLifecycleV1, ): void { scheduleStorageWrite( lifecycleStorageKey(namespace, tabId), JSON.stringify(payload), { debounceMs: LIFECYCLE_SAVE_DEBOUNCE_MS }, ) } /** * Pre-populate the localStorage lifecycle record for a new `(namespace, tabId)` * scope so the next mount of `useTableStateLifecycle` reads back the seeded * values on first render. * * Used by the centralized "Add view" creation flow in `HubTable` — when the * user commits a new tab from the creation drawer, the hub client emits a * `CreatedViewSpec` describing the filters / sort / columns / etc. that were * configured in the ephemeral creation state buffer. Calling this helper with * the new `tabId` and the spec's writable slices ensures those values stick * when the new tab activates and `useTableStateLifecycle` mounts against that * tabId for the first time. * * Any field omitted falls back to the same defaults `useTableStateLifecycle` * uses when reading an empty record (so partial seeding is safe). * * SSR-safe: no-op on the server (`typeof window === "undefined"`). * * @example * seedTableStateLifecycle("library", "table-abc123", { * activeFilters: spec.filters, * sortRules: spec.sortRules, * colOrder: spec.colOrder, * hiddenCols: spec.hiddenCols, * groupBy: spec.groupBy, * conditionalRules: spec.conditionalRules, * displayOptions: spec.displayOptions, * }) */ export interface SeedTableStateLifecyclePartial { activeFilters?: ActiveFilter[] sortRules?: SortRule[] colOrder?: string[] /** Pass as an array (the persisted shape). Internal `hiddenCols` is a `Set` but the JSON record stores an array. */ hiddenCols?: string[] groupBy?: string | null conditionalRules?: ConditionalRule[] /** * Seeded `displayOptions` go into `extras.displayOptions`. `HubTable` reads * `extras` via `onLoadExtras` and applies the seeded display options once * the new tab mounts. */ displayOptions?: DataListDisplayOptions } export function seedTableStateLifecycle( namespace: string, tabId: string, partial: SeedTableStateLifecyclePartial, ): void { if (typeof window === "undefined") return const extras: Record = {} if (partial.conditionalRules !== undefined) extras.conditionalRules = partial.conditionalRules if (partial.displayOptions !== undefined) extras.displayOptions = partial.displayOptions const payload: PersistedLifecycleV1 = { v: 1, sortRules: partial.sortRules ?? [], search: "", activeFilters: partial.activeFilters ?? [], filterConnectors: {}, groupBy: partial.groupBy ?? null, colOrder: partial.colOrder ?? [], hiddenCols: partial.hiddenCols ?? [], colWidths: {}, colPins: {}, colWrap: {}, colMenuSearch: {}, rowHeight: "default", showGridlines: true, filterBarVisible: true, searchOpen: false, ...(Object.keys(extras).length > 0 ? { extras } : {}), } setStorageItem(lifecycleStorageKey(namespace, tabId), JSON.stringify(payload)) } export function loadPageFromStorage(namespace: string): PersistedPageV1 | null { return parsePersistedPage(getStorageItem(pageStorageKey(namespace))) } export function schedulePageSave(namespace: string, payload: PersistedPageV1): void { scheduleStorageWrite( pageStorageKey(namespace), JSON.stringify(payload), { debounceMs: PAGE_SAVE_DEBOUNCE_MS }, ) } // ───────────────────────────────────────────────────────────────────────────── // React hook (the recommended entry point) // ───────────────────────────────────────────────────────────────────────────── export interface UseTableStateLifecycleOptions | void = void> { /** Storage namespace, e.g. `"placements"`, `"team"`, `"library"`. */ namespace: string /** * Sub-key per lifecycle tab. A hub with only one lifecycle should pass a * stable constant like `"main"`. A hub with multiple lifecycle scopes * (e.g. placements' "all / mine / shared" tabs) passes the active scope id. */ tabId: string /** `useTableState(...)` return value. Satisfies `TableStatePersistSlice`. */ tableState: TableStatePersistSlice /** * Valid column keys for the active table. Persisted column references that * are no longer present (e.g. column was renamed / removed) are dropped on * load. */ columnKeys: Set /** * Default + locked pin policy from the active `ColumnDef[]`. Merges with * persisted pins so `defaultPin` / `lockPin` survive rehydration. */ columnPinPolicy?: ColumnPinPolicy /** * Current value of extra state to persist alongside the table (optional). * Pass `undefined` / omit entirely if the hub only persists the table. */ extras?: TExtras /** * Called once when the persisted record is loaded, with whatever `extras` * it contained (or legacy top-level fields). Use this to rehydrate the * matching React state in the consumer. */ onLoadExtras?: (extras: TExtras | Record | undefined) => void } /** * Opt-in lifecycle persistence for a `DataTable`. Wires up: * * 1. **Load** (`useLayoutEffect`, once per `tabId` / `columnKeys` change) — * reads from `localStorage` and pushes the persisted state back into * `tableState` setters; then calls `onLoadExtras` so the consumer can * restore hub-specific state too. * 2. **Save** (`useEffect`, debounced ~400ms) — re-serializes whenever any * persisted slice changes and writes to `localStorage`. * * Behaviour: * * - SSR-safe (`localStorage` reads are guarded; the layout effect only runs * on the client). * - No render hits: setters are called inside the effect, not during render. * - Doesn't depend on the full `tableState` object — it depends on each * persisted slice individually so the table object identity (which is fresh * every render) doesn't force re-saves. */ export function useTableStateLifecycle | void = void>( opts: UseTableStateLifecycleOptions, ): void { const { namespace, tabId, tableState, columnKeys, columnPinPolicy, extras, onLoadExtras } = opts // Keep `onLoadExtras` in a ref so the load effect doesn't refire when the // consumer passes a new function reference each render. const onLoadExtrasRef = React.useRef(onLoadExtras) React.useEffect(() => { onLoadExtrasRef.current = onLoadExtras }) const columnKeysFingerprint = React.useMemo( () => [...columnKeys].sort().join("\0"), [columnKeys], ) const loadedScopeRef = React.useRef(null) const appliedColumnFingerprintRef = React.useRef(null) // ── Load ──────────────────────────────────────────────────────────────── // useLayoutEffect so the rehydrated state paints in the first frame after // mount instead of flashing the unhydrated defaults first. React.useLayoutEffect(() => { // Wait until column defs exist — applying persisted sort/filters against an // empty key set would sanitize everything away and look like Properties broke. if (columnKeys.size === 0) return const scope = `${namespace}:${tabId}` const raw = loadLifecycleFromStorage(namespace, tabId) if (loadedScopeRef.current !== scope) { loadedScopeRef.current = scope appliedColumnFingerprintRef.current = columnKeysFingerprint if (!raw) return applyLifecyclePersisted(tableState, raw, columnKeys, columnPinPolicy) const e = readLifecycleExtras>(raw) onLoadExtrasRef.current?.(e as TExtras | Record | undefined) return } if (appliedColumnFingerprintRef.current === columnKeysFingerprint) return appliedColumnFingerprintRef.current = columnKeysFingerprint if (!raw) return // Column defs changed (e.g. hub scope / dynamic filter options) — re-merge // layout only; do not wipe in-memory filters the user set in Properties. applyLifecycleColumnLayout(tableState, raw, columnKeys, columnPinPolicy) // `tableState` is freshly returned each render; depending on it would // re-apply persisted state on every keystroke and undo edits. Depend only // on the load scope (namespace + tabId + column fingerprint). // eslint-disable-next-line react-hooks/exhaustive-deps }, [namespace, tabId, columnKeysFingerprint]) // ── Save ──────────────────────────────────────────────────────────────── // Serialise + debounce on every persisted slice change. Don't depend on // the full `tableState` (fresh per render); depend on each slice instead so // a no-op render doesn't trigger a no-op save. const extrasJson = React.useMemo(() => (extras ? JSON.stringify(extras) : ""), [extras]) React.useEffect(() => { const payload = serializeLifecycle(tableState, extras as Record | undefined) scheduleLifecycleSave(namespace, tabId, payload) // eslint-disable-next-line react-hooks/exhaustive-deps }, [ namespace, tabId, extrasJson, tableState.sortRules, tableState.search, tableState.activeFilters, tableState.filterConnectors, tableState.groupBy, tableState.colOrder, tableState.hiddenCols, tableState.colWidths, tableState.colPins, tableState.colWrap, tableState.colMenuSearch, tableState.rowHeight, tableState.showGridlines, tableState.filterBarVisible, tableState.searchOpen, ]) }