"use client" /** * `>` — single centralized table surface used by every list-page hub. * * **Bulk actions** — pass `bulkActions` as a structured array (Polaris IndexTable model): * `[ { id, label, icon, shortcut, onSelect: (selectedRows: TRow[]) => void, variant? } ]` * `HubTable` synthesises the toolbar inside the floating bulk-action bar from this prop * and resolves `onSelect` with the **actual selected `TRow` objects** (filtered by * `getRowId`). Every action **must** include `shortcut` (Tip `Kbd` + ``). * `bulkActionsSlot` is still available for fully-custom toolbars but structured * `bulkActions` should be preferred — they enforce `aria-label`, keep destructive * intent typed, and stay consistent across hubs. * * Owns all the per-hub scaffolding that was previously duplicated in `placements-table.tsx`, * `team-table.tsx`, `compliance-table.tsx`, `library-table.tsx`, and `sites-table.tsx`: * * • `useTableState` setup tied to the centralized row dataset * • `displayOptions` state + `patchDisplay` * • `conditionalRules` state + add/remove/update handlers * • `filterFields` derived from column defs (explicit `filter` or text fallback) * • `fieldDefinitions` + `resolveColumnLabel` for the Properties drawer * • `TablePropertiesDrawerButton` wiring with `currentView` + `supportedViewTypes` * • `DataTable` for `view === "table"`; `ListPageConnectedViewBody` for every other view * • Imperative `openPropertiesDrawer()` exposed via `handleRef` * * Each hub now only declares **what's actually different**: its column defs and a typed * `renderers` map for the views it implements. The renderers receive `state`, `toolbar`, * `toolbarShell`, `viewportToolbarShell`, `drawerToolbarProps`, * `displayOptions`, and `patchDisplay` so they can reuse the pre-composed toolbar or * build their own (e.g. dashboard view with extra layout-edit actions). * * **Single dataset rule:** rows flow through `useTableState`; `tableState.rows` is the * filtered/sorted row bag every non-table renderer reads. There is **no** parallel mock * array per view. See `.cursor/rules/exxat-centralized-list-dataset.mdc`. */ import * as React from "react" import { cn } from "../../lib/utils" import { DATA_TABLE_GRID_INSET_CLASS } from "../../lib/table-edge-inset" import { Button } from "../ui/button" import { Tip } from "../ui/tip" import { Kbd, KbdGroup } from "../ui/kbd" import { Shortcut } from "../ui/dropdown-menu" import type { ResponsiveAction } from "../ui/responsive-action-row" import { DataTable, DataTableToolbar } from "../data-table" import { CountSyncer, PaginationBar } from "../data-table/pagination" import { TableViewMoreFooter } from "../data-table/table-view-more-footer" import type { ColumnDef } from "../data-table/types" import { useTableState } from "../data-table/use-table-state" import { dataListViewLabel, type DataListViewType } from "../../lib/data-list-view" import { getDataListViewRenderKind, FULL_HUB_SUPPORTED_VIEWS, TABLE_ONLY_HUB_SUPPORTED_VIEWS, type DataListViewRenderKind, } from "../../lib/data-list-view-registry" import { DEFAULT_DATA_LIST_DISPLAY_OPTIONS, type DataListDisplayOptions, } from "../../lib/data-list-display-options" import { TablePropertiesDrawerButton } from "../table-properties/drawer-button" import type { ActiveFilter, ConditionalRule, FilterFieldDef, FilterOperator, SortRule, } from "../../lib/table-properties-types" import { resolveColumnFilter, augmentColumnsWithCompanionFilters } from "../../lib/column-filter" import { ListPageConnectedViewBody, type ListPageConnectedViewRenderers, } from "./list-page-connected-view-body" import { DataRowList } from "./data-row-list" import { ListPageBoardTemplate, type ListPageBoardColumnDef, } from "./list-page-board-template" import type { OpenTablePropertiesHandle } from "../../lib/list-page-table-properties" import { useTableStateLifecycle } from "../../lib/table-state-lifecycle" import { columnPinPolicyFromDefs } from "../../lib/column-pin-policy" import { usePersistedState } from "../../lib/persisted-state" import { HUB_TABLE_EMBEDDED_PREVIEW_ROW_LIMIT } from "../../lib/hub-table-embedded-preview" export { HUB_TABLE_EMBEDDED_PREVIEW_ROW_LIMIT } // ────────────────────────────────────────────────────────────────────────────── // Helpers // ────────────────────────────────────────────────────────────────────────────── /** * Stable empty `Set` passed to `useTableStateLifecycle` when * persistence is off. The hook bails out early on `columnKeys.size === 0`, * but we still want a stable identity to keep the `useMemo` in there from * thrashing. */ const EMPTY_COLUMN_KEYS = new Set() function columnToFilterFieldDef(c: ColumnDef): FilterFieldDef | null { const f = resolveColumnFilter(c) if (!f) return null const defaultOps: FilterOperator[] = f.type === "select" || f.type === "date" || f.type === "date-range" ? ["is", "is_not"] : f.type === "range" ? ["is"] : ["contains", "not_contains"] return { key: c.key, label: c.label, icon: f.icon ?? "fa-filter", type: f.type, operators: (f.operators ?? defaultOps) as FilterOperator[], selectVariant: f.selectVariant, dataBounds: f.dataBounds, options: f.options, ...(f.textMask ? { textMask: f.textMask } : {}), ...(f.type === "range" ? { rangeMin: f.rangeMin, rangeMax: f.rangeMax, rangeStep: f.rangeStep, rangeUnit: f.rangeUnit, } : {}), } } /** Derive `FilterFieldDef[]` from a hub's column defs — preferred to hand-writing both. */ export function columnsToFilterFields(cols: ColumnDef[]): FilterFieldDef[] { return augmentColumnsWithCompanionFilters(cols) .map(c => columnToFilterFieldDef(c)) .filter((x): x is FilterFieldDef => x !== null) } /** Field definitions for the drawer's Sort / Group / Columns panels — excludes utility columns. */ export function columnsToFieldDefinitions( cols: ColumnDef[], ): { key: string; label: string; sortable: boolean }[] { const fields: { key: string; label: string; sortable: boolean }[] = [] for (const c of cols) { if (c.key === "select" || c.key === "actions") continue fields.push({ key: c.key, label: c.label, sortable: !!(c.sortable && (c.sortKey ?? c.key)), }) } return fields } // ────────────────────────────────────────────────────────────────────────────── // Bulk actions (Polaris IndexTable model) // ────────────────────────────────────────────────────────────────────────────── /** * Structured bulk-action definition. `HubTable` renders one button per entry * inside the floating bulk-action bar when the row selection is non-empty. * * `onSelect` receives the **actual selected `TRow` objects** (filtered from * the source `rows` by `getRowId`). Consumers don't see the underlying * `Set` — they just react to typed rows. * * `variant`: * - `"default"` (omit) → outline button — Archive, Export, Tag, etc. * - `"destructive"` → `destructive-solid` button on the selection bar. * Use for Delete / Remove / irreversible actions. * * `shortcut` is **required** — Tip shows the chord; `` binds it * while the bar is mounted. Prefer ⌘/Ctrl+⌥/Alt+letter. Esc remains Clear. * See `.cursor/rules/exxat-bulk-action-shortcuts.mdc`. */ export interface BulkAction> { /** Stable id (used as React key). */ id: string /** Visible button label. */ label: string /** Font Awesome glyph (e.g. `"fa-box-archive"`). Rendered as ``. */ icon?: string /** Optional `aria-label` override. Defaults to `label`. Always set explicitly when `label` is too terse for AT. */ ariaLabel?: string /** Optional tooltip shown on hover/focus. Defaults to `label`. */ tooltip?: string /** * Keyboard chord — required. Shown in the Tip (`Kbd`) and bound with * `` while selection is active. Build with `useModKeyLabel()` / * `useAltKeyLabel()` (e.g. `` `${mod}${alt}F` ``). */ shortcut: string /** Fired with the actual selected `TRow` objects (already resolved by `getRowId`). */ onSelect: (selectedRows: TRow[]) => void /** `"destructive"` renders as `destructive-solid` on the selection bar. Default `"default"` (outline). */ variant?: "default" | "destructive" /** Disable the action without removing it from the bar. */ disabled?: boolean } // ────────────────────────────────────────────────────────────────────────────── // Public types // ────────────────────────────────────────────────────────────────────────────── /** Subset of `TablePropertiesDrawerButton` props that `HubTable` owns. Exposed so renderers * that build a custom toolbar (e.g. dashboard with an extra Edit-layout button) can splat * this back into their own ``. */ export interface HubDrawerToolbarProps { totalRows: number filterFields: FilterFieldDef[] fieldDefinitions: { key: string; label: string; sortable: boolean }[] resolveColumnLabel: (key: string) => string displayOptions: DataListDisplayOptions onDisplayOptionsChange: (patch: Partial) => void conditionalRules: ConditionalRule[] onAddConditionalRule: (rule: Omit) => void onRemoveConditionalRule: (id: string) => void onUpdateConditionalRule: (id: string, patch: Partial) => void currentView: DataListViewType onViewChange?: (v: DataListViewType) => void supportedViewTypes: readonly DataListViewType[] lifecycleTabLabel: string /** * Edit-mode rename support — forwarded to `TablePropertiesDrawerButton` / * `TablePropertiesDrawer`. When the hub client provides this pair, the * Properties drawer renders an editable Name input on its main panel so the * drawer becomes the single rename surface (no separate "Rename view" dialog * needed). Omit both to keep the legacy read-only header. */ viewName?: string onViewNameChange?: (name: string) => void boardGroupByColumnOptions?: { key: string; label: string }[] renderFilterOptionValue?: (fieldKey: string, value: string) => React.ReactNode filterFieldContext?: Record pagination?: boolean onPaginationChange?: (v: boolean) => void showViewCounts?: boolean onShowViewCountsChange?: (v: boolean) => void } /** Everything a non-table renderer needs. The pre-composed `toolbar` includes search + * filter chips + the Properties button. Pick a shell: * - `toolbarShell` — toolbar + body in one block; grows with `[data-page-scroll]` * - `viewportToolbarShell` — alias of `toolbarShell` (kept for hub call sites) * Dashboards or custom toolbars use `drawerToolbarProps` + `TablePropertiesDrawerButton`. */ export interface HubTableRendererArgs> { state: ReturnType> toolbar: React.ReactNode /** Toolbar + body — scrolls with the hub page (all view kinds). */ toolbarShell: (body: React.ReactNode) => React.ReactNode /** Alias of `toolbarShell` — page scroll for folder / panel / tree too. */ viewportToolbarShell: (body: React.ReactNode) => React.ReactNode drawerToolbarProps: HubDrawerToolbarProps displayOptions: DataListDisplayOptions patchDisplay: (patch: Partial) => void } /** Hubs provide one renderer per `DataListViewRenderKind` they implement. The `data-table` * kind is handled internally by `HubTable` (do not override unless you genuinely need a * different table surface). Missing kinds render `` — clear empty * state, never a silent dashboard fallback. */ export type HubTableRenderers> = Partial< Record) => React.ReactNode> > export interface HubTableHandle = Record> extends OpenTablePropertiesHandle { drawerToolbarProps: HubDrawerToolbarProps | null /** * The rows the user is currently looking at, in the order they appear: * filtered, searched, and sorted, and narrowed to the current page when the * hub paginates. * * A getter, read at the moment of the click, so a detail rail can step to the * next record without the hub re-rendering every time the set changes. Step * through this rather than the source array — the user's "next" means the next * row on screen, and a record on another page could not show the open-row * marker anyway. */ visibleRows: TRow[] } /** * Spec emitted by `` when the user clicks "Create view" in the * 2-step "Add view" creation drawer. The drawer is wired to a SECOND * ephemeral `useTableState` instance inside `HubTable` (separate from the * active view's live state), so the user can configure filters / sort / * columns / etc. without polluting the current view; the configured values * arrive here on commit. * * Hub clients typically forward `spec` to `seedTableStateLifecycle` so the * new tab restores these values on its first mount. */ export interface CreatedViewSpec { name: string viewType: DataListViewType filters: ActiveFilter[] sortRules: SortRule[] colOrder: string[] /** Persisted shape is an array; pass `Array.from(state.hiddenCols)` when constructing. */ hiddenCols: string[] groupBy: string | null conditionalRules: ConditionalRule[] displayOptions: DataListDisplayOptions } export interface HubTableProps> { /** Full row dataset (already scoped/filtered by the hub client, e.g. by URL nav). */ rows: TRow[] /** Column defs — `filter` blocks here become Properties drawer filter fields automatically. */ columns: ColumnDef[] /** Active view from the `ListPageTemplate` tab. */ view: DataListViewType onViewChange?: (v: DataListViewType) => void /** * Active view's name + commit handler — when both are provided, the Properties * drawer renders an editable Name input on its main panel (in addition to the * 2-step "Create new view" creation surface). Hubs typically bind this to the * active `ViewTab.label` and an `updateTab({ label })` callback. */ viewName?: string onViewNameChange?: (name: string) => void /** * Allowlist passed to `TablePropertiesDrawerButton` so Properties cannot offer unsupported views. * Defaults to {@link TABLE_ONLY_HUB_SUPPORTED_VIEWS} when omitted — opt into * {@link FULL_HUB_SUPPORTED_VIEWS} when the product asks for list/board/dashboard/folder views. */ supportedViewTypes?: readonly DataListViewType[] /** Used by `ListPageViewNotConfigured` when a supported view has no renderer. */ hubLabel: string /** Shown below "Properties" in the drawer header. */ lifecycleTabLabel: string /** Toolbar search input aria-label. */ searchAriaLabel: string getRowId: (row: TRow) => string | number getRowSelectionLabel: (row: TRow) => string defaultSort: { key: string; dir: "asc" | "desc" } /** DataTable empty state. Defaults to a muted "No records match your filters." */ emptyState?: React.ReactNode /** Per-view renderers (everything other than `view === "table"`). */ renderers: HubTableRenderers /** * Structured bulk-action definitions (Polaris IndexTable parity). When set * (and the active selection is non-empty), `HubTable` renders one button * per entry inside the floating bulk-action bar — using `Button` / * `` from the DS — and resolves `onSelect` with the actual selected * `TRow` objects. Use this in preference to {@link bulkActionsSlot}; the * slot remains for fully-custom toolbar bodies. */ bulkActions?: BulkAction[] /** * Record commands for the toolbar — Add, Import, Export and friends. They * render **after** the Properties button, behind a separator, so the row reads * as view controls first and commands second. * * At most three `placement: "row"` actions stay on the row (the same cap * `PageHeader` uses); the rest fall into **More** along with anything declared * `placement: "overflow"`. As the row narrows, labels drop to glyphs with a * Tip, then secondaries move into More while the filled primary stays put. * Every action keeps its `shortcut` bound wherever it sits. * * Labels also drop once the **filter chips** run out of line, at any width: * the chips would otherwise wrap below and leave the labels sitting on the * row that caused it. Selling the labels is the right trade, because an * action survives as a glyph plus a Tip and a chip's value does not. * * These are commands that act on the hub, so they do not depend on a * selection. Commands that act on *selected rows* belong in * {@link bulkActions}, which appears in the selection bar instead. * * A page gets one filled primary (`variant: "default"`), so put it here **or** * in `PageHeader.actionItems`, not both — the toolbar is the better home when * the command reads as part of the list rather than the page. */ toolbarActions?: ResponsiveAction[] /** * Escape hatch for bulk-action toolbars that need bespoke UI (custom * dropdowns, segmented controls, etc.). Receives the row id set and the * filtered row bag. Prefer {@link bulkActions} for normal button rows. */ bulkActionsSlot?: (selected: Set) => React.ReactNode /** Board-view group-by column options for the Properties drawer. */ boardGroupByColumnOptions?: { key: string; label: string }[] /** Initial display options (toolbar search visibility, calendar panel, etc.). */ displayOptionsInit?: DataListDisplayOptions /** Custom renderer for filter option values (e.g. status chips). */ renderFilterOptionValue?: (fieldKey: string, value: string) => React.ReactNode /** DataTable: enable "Group by" feature. Default `true`. */ groupable?: boolean /** DataTable: enable row selection checkboxes. Default `true`. */ selectable?: boolean /** DataTable: row click handler (e.g. navigate to detail route). */ onRowClick?: (row: TRow) => void /** * Id of the row a detail rail is currently showing (`null` when none is). * * Setting this — `null` included — declares that rows on this hub open a rail * rather than a route. The table marks the open row with a leading bar and the * list view rings its card, both with `aria-current`; rows also become * `railTriggerProps` targets, so clicking a second row retargets the rail * instead of dismissing it and reopening it a frame later. * * Keep the state in a URL param so a peeked record survives a reload and can * be shared, and pair it with `onRowClick` to open the rail. */ openRowId?: string | number | null /** * Controlled `displayOptions` — when provided (with `onDisplayOptionsChange`), the hub client * owns persistence (e.g. Placements page persists across tab switches). Otherwise `HubTable` * owns internal state from `displayOptionsInit`. */ displayOptions?: DataListDisplayOptions onDisplayOptionsChange?: (patch: Partial) => void /** * Pagination toggle forwarded to `TablePropertiesDrawerButton` so the drawer can offer * "Show pagination" on the Display panel. The hub still implements its own pagination chrome * around the table body via `tableRenderer`. Defaults to `false` / no toggle. */ pagination?: boolean onPaginationChange?: (v: boolean) => void /** Show record counts on each view tab — surfaced in the Properties Display panel. */ showViewCounts?: boolean onShowViewCountsChange?: (v: boolean) => void /** Imperative handle (`openPropertiesDrawer`, `visibleRows`) for hub clients that pass `tablePropertiesRef`. */ handleRef?: React.Ref> /** Optional override for the `data-table` view. Default mounts ``. */ tableRenderer?: (args: HubTableRendererArgs) => React.ReactNode /** * Forwarded to `useTableState` so the hub can switch on server-style pagination * with externally-controlled page/pageSize (advanced; most hubs should leave * this undefined and let `HubTable` own the internal page state — see * `pagination` + `paginationPageSizeOptions`). */ paginationOverride?: { page: number; pageSize: number } /** Page size options shown in the toolbar ``. Default `[10, 25, 50, 100]`. */ paginationPageSizeOptions?: number[] /** Initial page size when `HubTable` owns pagination internally. Default `10`. */ paginationInitialPageSize?: number /** * Cap visible rows on embedded surfaces (dashboard sections, design-system docs). * Shows a **View more** footer when the filtered count exceeds the limit. * Primary list hubs omit this prop. Default limit is * `HUB_TABLE_EMBEDDED_PREVIEW_ROW_LIMIT` (5). */ embeddedPreviewRowLimit?: number /** * Shorthand for embedded surfaces — applies `HUB_TABLE_EMBEDDED_PREVIEW_ROW_LIMIT` * when `embeddedPreviewRowLimit` is omitted. Primary hubs leave both unset. */ embeddedPreview?: boolean /** Controlled expand state for embedded preview. Omit for internal state. */ embeddedPreviewExpanded?: boolean onEmbeddedPreviewExpandedChange?: (expanded: boolean) => void /** Optional handler when the user clicks View more (e.g. navigate to full hub). */ onEmbeddedPreviewViewMore?: () => void embeddedPreviewViewMoreLabel?: string /** * Forwarded to `useTableState` to sync toolbar search from `?q=` (Library search routes). * Defining as `""` enables sync without an initial query. */ syncedSearchFromUrl?: string // ─── Centralized "list" and "board" defaults ─────────────────────────────── // When a hub does NOT provide a renderer for `list-with-toolbar`, but DOES provide // `renderListRow`, `HubTable` synthesises a default that wires `DataRowList` through // the shared toolbar. Same for `board-with-toolbar` + `renderBoardCard`. This is the // happy path for most hubs; the explicit renderer escape hatch is only needed for // exotic surfaces (e.g. Placements board with per-phase column search). /** Default `list-with-toolbar` renderer body — `HubTable` wraps with toolbar + `DataRowList`. */ renderListRow?: (row: TRow) => React.ReactNode /** Override the `aria-label` on the `
    ` from `renderListRow`. Defaults to `hubLabel`. */ listAriaLabel?: string /** Empty-state for the default list renderer. Defaults to "No records match your filters." */ listEmptyState?: React.ReactNode /** Virtualise after N rows in the default list renderer. Default 100; pass `0` to disable. */ listVirtualizeThreshold?: number /** Estimated row height (px) for the default list virtualiser. Default 96. */ listEstimatedRowHeight?: number /** Default `board-with-toolbar` renderer body — `HubTable` wraps with toolbar + `ListPageBoardTemplate`. */ renderBoardCard?: (row: TRow) => React.ReactNode /** Required if `renderBoardCard` is set: how rows group into columns. */ boardGroups?: ListPageBoardColumnDef[] /** Per-column count badge tint classes for the default board renderer. */ boardColumnCountBadgeClassName?: Record /** Empty column copy for the default board renderer. Defaults to "No items". */ boardEmptyColumnLabel?: string // ─── Persistence (opt-in) ────────────────────────────────────────────── /** * **Opt-in localStorage persistence** for the table lifecycle: sort, * search, filters, column order/widths/pins/hidden, row height, gridlines, * conditional rules, and pagination. When set, the hub state is restored * on reload — without this prop, the table works exactly as before * (in-memory only). * * The string is the namespace (e.g. `"placements"`, `"team"`, `"library"`) * — it becomes part of the storage key * `exxat-ds::lifecycle:v1:`. **Use the same key * across renders** of the same hub or persistence will reset. * * For per-tab persistence inside a hub (e.g. Placements "all / mine / * shared"), pass the active scope as `persistTabId`. * * @see `apps/web/docs/persisted-state-pattern.md` * @see `.cursor/rules/exxat-persisted-state.mdc` */ persistKey?: string /** * Sub-key for per-scope persistence (e.g. the active Placements scope id). * Defaults to `"main"` — most hubs only need a single scope. */ persistTabId?: string // ─── 2-step "Add view" creation flow ─────────────────────────────────── /** * When non-null, opens the Properties drawer in creation mode wired to a * SEPARATE, ephemeral `useTableState` buffer (NOT the active view's live * state). The user can configure filters / sort / columns / conditional * rules / display options for the new view without affecting the current * view. Set to `null` to close the creation drawer. * * Hub clients drive this from the `ListPageTemplate.onRequestCreateView` * callback. */ creatingViewType?: DataListViewType | null /** * Controlled name input for the new view. Required when `creatingViewType` * is non-null — the Create button is disabled while the trimmed name is * empty. */ creatingViewName?: string onCreatingViewNameChange?: (name: string) => void /** * Fired when the user dismisses the creation drawer (Esc, Cancel, or close * icon). The parent should set `creatingViewType` back to `null` and reset * any draft name. */ onCancelCreation?: () => void /** * Fired when the user clicks "Create view". Receives a fully-typed spec * reflecting the ephemeral creation state at commit time. The parent * typically calls `seedTableStateLifecycle` with the new tab id and the * spec's writable slices so the new tab restores them on first mount. */ onCommitCreation?: (spec: CreatedViewSpec) => void } // ────────────────────────────────────────────────────────────────────────────── // Component // ────────────────────────────────────────────────────────────────────────────── export function HubTable>({ rows, columns, view, onViewChange, viewName, onViewNameChange, supportedViewTypes: supportedViewTypesProp, hubLabel, lifecycleTabLabel, searchAriaLabel, getRowId, getRowSelectionLabel, defaultSort, emptyState, renderers, bulkActions, toolbarActions, bulkActionsSlot, boardGroupByColumnOptions, displayOptionsInit, renderFilterOptionValue, groupable = true, selectable = true, onRowClick, openRowId, displayOptions: displayOptionsControlled, onDisplayOptionsChange: onDisplayOptionsChangeControlled, pagination, onPaginationChange, showViewCounts, onShowViewCountsChange, handleRef, tableRenderer, paginationOverride, paginationPageSizeOptions = [10, 25, 50, 100], paginationInitialPageSize = 10, embeddedPreview, embeddedPreviewRowLimit, embeddedPreviewExpanded, onEmbeddedPreviewExpandedChange, onEmbeddedPreviewViewMore, embeddedPreviewViewMoreLabel, syncedSearchFromUrl, renderListRow, listAriaLabel, listEmptyState, listVirtualizeThreshold, listEstimatedRowHeight, renderBoardCard, boardGroups, boardColumnCountBadgeClassName, boardEmptyColumnLabel, persistKey, persistTabId = "main", creatingViewType = null, creatingViewName, onCreatingViewNameChange, onCancelCreation, onCommitCreation, }: HubTableProps) { const supportedViewTypes = supportedViewTypesProp ?? TABLE_ONLY_HUB_SUPPORTED_VIEWS const filterFields = React.useMemo(() => columnsToFilterFields(columns), [columns]) const fieldDefinitions = React.useMemo(() => columnsToFieldDefinitions(columns), [columns]) const resolveColumnLabel = React.useCallback( (key: string) => columns.find(c => c.key === key)?.label ?? key, [columns], ) // displayOptions: controlled (parent owns state via prop pair) OR uncontrolled (HubTable owns it). // Most hubs keep display options ephemeral; Placements persists them across tabs at the page level. const [internalDisplayOptions, setInternalDisplayOptions] = React.useState( displayOptionsInit ?? DEFAULT_DATA_LIST_DISPLAY_OPTIONS, ) const isControlled = displayOptionsControlled !== undefined && onDisplayOptionsChangeControlled !== undefined const displayOptions = isControlled ? displayOptionsControlled : internalDisplayOptions const patchDisplay = React.useCallback( (patch: Partial) => { if (isControlled) { onDisplayOptionsChangeControlled!(patch) } else { setInternalDisplayOptions(prev => ({ ...prev, ...patch })) } }, [isControlled, onDisplayOptionsChangeControlled], ) const [conditionalRules, setConditionalRules] = React.useState([]) const addConditionalRule = React.useCallback((rule: Omit, id?: string) => { setConditionalRules(prev => [ ...prev, { ...rule, id: id ?? `cr-${Date.now()}` }, ]) }, []) const removeConditionalRule = React.useCallback((id: string) => { setConditionalRules(prev => prev.filter(r => r.id !== id)) }, []) const updateConditionalRule = React.useCallback( (id: string, patch: Partial) => { setConditionalRules(prev => prev.map(r => (r.id === id ? { ...r, ...patch } : r))) }, [], ) // ─── Pagination enable + page chrome ───────────────────────────────────── // When the hub omits `onPaginationChange`, `HubTable` owns the Properties // "Pagination" toggle internally (Library / Placements pattern). Without this, // the drawer toggle is wired to a no-op and pagination never activates. const isPaginationControlled = onPaginationChange !== undefined const [internalPaginationEnabled, setInternalPaginationEnabled] = React.useState(false) const effectivePagination = isPaginationControlled ? (pagination ?? false) : internalPaginationEnabled const handlePaginationChange = React.useCallback( (enabled: boolean) => { if (isPaginationControlled) { onPaginationChange!(enabled) } else { setInternalPaginationEnabled(enabled) } if (!enabled) setInternalPage(1) }, [isPaginationControlled, onPaginationChange], ) // ─── Pagination page / pageSize (centralized) ───────────────────────────── // When `effectivePagination === true` and the parent did NOT supply `paginationOverride`, // `HubTable` owns the page/pageSize internally and wraps the default table + // list renderers with `` + ``. Hubs that need full // control (e.g. server-side pagination) keep using `paginationOverride`. const [internalPage, setInternalPage] = React.useState(1) const [internalPageSize, setInternalPageSize] = React.useState(paginationInitialPageSize) const [internalEmbeddedExpanded, setInternalEmbeddedExpanded] = React.useState(false) const resolvedEmbeddedPreviewRowLimit = embeddedPreviewRowLimit ?? (embeddedPreview ? HUB_TABLE_EMBEDDED_PREVIEW_ROW_LIMIT : undefined) const embeddedExpanded = embeddedPreviewExpanded ?? internalEmbeddedExpanded const setEmbeddedExpanded = onEmbeddedPreviewExpandedChange ?? setInternalEmbeddedExpanded const chromeOwnedPagination = effectivePagination === true && paginationOverride === undefined const embeddedPreviewActive = resolvedEmbeddedPreviewRowLimit != null && resolvedEmbeddedPreviewRowLimit > 0 && !embeddedExpanded && paginationOverride === undefined && !chromeOwnedPagination const tableEdgeInset = !embeddedPreviewActive const effectivePaginationOverride = paginationOverride ?? (chromeOwnedPagination ? { page: internalPage, pageSize: internalPageSize } : embeddedPreviewActive ? { page: 1, pageSize: resolvedEmbeddedPreviewRowLimit } : undefined) const tableState = useTableState( rows, columns, defaultSort, effectivePaginationOverride, syncedSearchFromUrl, ) // ─── 2-step "Add view" creation flow — ephemeral state buffer ───────────── // A SECOND `useTableState` instance dedicated to the creation drawer. It // never sees the real `rows` (the user is configuring a *new* view, not // editing one) and is intentionally NOT persisted — close-and-reopen always // starts from defaults. Always instantiated so we don't violate the rules // of hooks; cheap because `rows: []`. const creationEmptyRows = React.useMemo(() => [], []) const creationState = useTableState(creationEmptyRows, columns, defaultSort) const [creationDisplayOptions, setCreationDisplayOptions] = React.useState( DEFAULT_DATA_LIST_DISPLAY_OPTIONS, ) const patchCreationDisplay = React.useCallback( (patch: Partial) => { setCreationDisplayOptions(prev => ({ ...prev, ...patch })) }, [], ) const [creationConditionalRules, setCreationConditionalRules] = React.useState([]) const addCreationCR = React.useCallback((rule: Omit, id?: string) => { setCreationConditionalRules(prev => [ ...prev, { ...rule, id: id ?? `cr-${Date.now()}` }, ]) }, []) const removeCreationCR = React.useCallback((id: string) => { setCreationConditionalRules(prev => prev.filter(r => r.id !== id)) }, []) const updateCreationCR = React.useCallback( (id: string, patch: Partial) => { setCreationConditionalRules(prev => prev.map(r => (r.id === id ? { ...r, ...patch } : r))) }, [], ) // Default colOrder for reset — mirror `useTableState`'s initializer so the // creation drawer's "Columns" panel returns to the same baseline order as a // fresh hook mount. const columnKeysList = React.useMemo(() => columns.map(c => c.key), [columns]) // Keep refs to the reset targets so the effect below doesn't need to depend // on every individual setter (which is stable anyway, but listing them all // would balloon the deps array and obscure intent). const creationStateRef = React.useRef(creationState) React.useLayoutEffect(() => { creationStateRef.current = creationState }) const creationColKeysRef = React.useRef(columnKeysList) React.useLayoutEffect(() => { creationColKeysRef.current = columnKeysList }) const resetCreationBuffer = React.useCallback(() => { const s = creationStateRef.current s.setSheetOpen(false) s.setSortRules([]) for (const f of s.activeFilters) { s.removeFilter(f.id) } s.setColOrder(creationColKeysRef.current) s.setHiddenCols(new Set()) s.setGroupBy(null) s.setSearch("") s.setFilterBarVisible(true) s.setSearchOpen(false) s.setShowGridlines(true) setCreationDisplayOptions(DEFAULT_DATA_LIST_DISPLAY_OPTIONS) setCreationConditionalRules([]) }, []) React.useEffect(() => { if (!creatingViewType) return creationStateRef.current.setSheetOpen(true) return () => { resetCreationBuffer() } }, [creatingViewType, resetCreationBuffer]) // ─── Persistence (opt-in via `persistKey`) ─────────────────────────────── // When `persistKey` is set, mirror the table lifecycle (sort/search/filters/ // columns/row-height/gridlines) and the hub-managed `extras` (conditional // rules + pagination) into `localStorage`. SSR-safe + debounced — handled // entirely inside `useTableStateLifecycle`. const columnKeysSet = React.useMemo( () => new Set(columns.map(c => c.key)), [columns], ) const columnPinPolicy = React.useMemo( () => (persistKey ? columnPinPolicyFromDefs(columns) : undefined), [persistKey, columns], ) const persistedExtras = React.useMemo< | { conditionalRules: ConditionalRule[] pagination: boolean | undefined paginationPage: number paginationPageSize: number } | undefined >( () => persistKey ? { conditionalRules, pagination: effectivePagination, paginationPage: internalPage, paginationPageSize: internalPageSize, } : undefined, [persistKey, conditionalRules, effectivePagination, internalPage, internalPageSize], ) useTableStateLifecycle<{ conditionalRules: ConditionalRule[] pagination: boolean | undefined paginationPage: number paginationPageSize: number }>({ namespace: persistKey ?? "", tabId: persistTabId, tableState, columnKeys: persistKey ? columnKeysSet : EMPTY_COLUMN_KEYS, columnPinPolicy, extras: persistedExtras, onLoadExtras: persistKey ? loaded => { if (!loaded) return const cr = loaded.conditionalRules if (Array.isArray(cr)) setConditionalRules(cr as ConditionalRule[]) if (typeof loaded.pagination === "boolean") { handlePaginationChange(loaded.pagination) } const pp = loaded.paginationPage if (typeof pp === "number" && pp > 0) setInternalPage(pp) const pps = loaded.paginationPageSize if (typeof pps === "number" && pps > 0) setInternalPageSize(pps) } : undefined, }) const handlePageChange = React.useCallback((p: number) => setInternalPage(p), []) const handlePageSizeChange = React.useCallback((n: number) => { setInternalPageSize(n) setInternalPage(1) }, []) const resetPage = React.useCallback(() => setInternalPage(1), []) const clampPageToCount = React.useCallback( (count: number) => { const maxPage = Math.max(1, Math.ceil(count / Math.max(1, internalPageSize))) setInternalPage(p => (p > maxPage ? maxPage : p)) }, [internalPageSize], ) // Extract the stable setter from `useTableState` first so the // `useImperativeHandle` deps array sees the exact value the hook reads. // `setSheetOpen` is referentially stable, so the handle is created once. const { setSheetOpen: openPropertiesSheet } = tableState const drawerToolbarProps = React.useMemo( () => ({ totalRows: rows.length, filterFields, fieldDefinitions, resolveColumnLabel, displayOptions, onDisplayOptionsChange: patchDisplay, conditionalRules, onAddConditionalRule: addConditionalRule, onRemoveConditionalRule: removeConditionalRule, onUpdateConditionalRule: updateConditionalRule, currentView: view, onViewChange, supportedViewTypes, lifecycleTabLabel, boardGroupByColumnOptions, renderFilterOptionValue, filterFieldContext: tableState.filterFieldContext, ...(viewName !== undefined ? { viewName } : {}), ...(onViewNameChange !== undefined ? { onViewNameChange } : {}), pagination: effectivePagination, onPaginationChange: handlePaginationChange, ...(showViewCounts !== undefined ? { showViewCounts } : {}), ...(onShowViewCountsChange !== undefined ? { onShowViewCountsChange } : {}), }), [ addConditionalRule, boardGroupByColumnOptions, conditionalRules, displayOptions, effectivePagination, fieldDefinitions, filterFields, handlePaginationChange, lifecycleTabLabel, onShowViewCountsChange, onViewChange, onViewNameChange, patchDisplay, removeConditionalRule, renderFilterOptionValue, resolveColumnLabel, rows.length, tableState.filterFieldContext, showViewCounts, supportedViewTypes, updateConditionalRule, view, viewName, ], ) const drawerToolbarPropsRef = React.useRef(drawerToolbarProps) // Layout, not passive: the imperative handle below is attached during the // same commit and a caller can read it before passive effects flush. React.useLayoutEffect(() => { drawerToolbarPropsRef.current = drawerToolbarProps }) // What the grid is showing: the page when this hub paginates, the whole // filtered set when it does not. const visibleRowsRef = React.useRef([]) React.useLayoutEffect(() => { visibleRowsRef.current = ( chromeOwnedPagination ? tableState.pagedRows : tableState.rows ) as TRow[] }) React.useImperativeHandle( handleRef ?? null, () => ({ openPropertiesDrawer: () => openPropertiesSheet(true), get drawerToolbarProps() { return drawerToolbarPropsRef.current }, get visibleRows() { return visibleRowsRef.current }, }), [openPropertiesSheet], ) const [searchRecentQueries, setSearchRecentQueries] = usePersistedState( persistKey ? `${persistKey}:toolbar-search-recents` : "", [], { version: 1 }, ) const recordToolbarSearchRecent = React.useCallback( (query: string) => { const q = query.trim() if (!q) return setSearchRecentQueries(prev => [q, ...prev.filter(x => x.toLowerCase() !== q.toLowerCase())].slice(0, 12), ) }, [setSearchRecentQueries], ) const toolbar = React.useMemo( () => ( } toolbarActions={toolbarActions} searchRecentQueries={persistKey ? searchRecentQueries : undefined} onSearchRecentSelect={query => tableState.setSearch(query)} onSearchRecentClear={persistKey ? () => setSearchRecentQueries([]) : undefined} onSearchRecentRecord={persistKey ? recordToolbarSearchRecent : undefined} /> ), [ columns, displayOptions.showToolbarSearch, drawerToolbarProps, persistKey, recordToolbarSearchRecent, renderFilterOptionValue, searchAriaLabel, searchRecentQueries, setSearchRecentQueries, tableState, tableEdgeInset, toolbarActions, ], ) // ─── Bulk actions (structured > slot) ───────────────────────────────────── // When the consumer passes `bulkActions`, synthesize the floating bulk-bar // body from that typed array — resolving `onSelect` with the actual // selected `TRow` objects. The `bulkActionsSlot` escape hatch is still // honored when `bulkActions` is omitted, so existing call-sites (Library's // single-button Export slot, dashboards with bespoke chrome) keep working. // `bulkActions` wins when both are set, on the assumption that a hub // adopting the typed API does so intentionally. const effectiveBulkActionsSlot = React.useMemo(() => { if (bulkActions && bulkActions.length > 0) { return (selected: Set, dtRows: TRow[]) => { const selectedRows = dtRows.filter(r => selected.has(getRowId(r))) return ( <> {bulkActions.map(action => { const labelText = action.label.trim() const isIconOnly = !labelText && Boolean(action.icon) const aria = action.ariaLabel ?? labelText ?? action.id const tip = action.tooltip ?? labelText // Resolve to the actual DS Button variants. We never mint a // filled `default` button in the bar — `bulkActions` callers // either pick `"default"` → outline (the standard) or // `"destructive"` → destructive-solid on the selection bar. // Per `exxat-ux-principles` P3 the bar has no single primary product action. const buttonVariant: "outline" | "destructive-solid" = action.variant === "destructive" ? "destructive-solid" : "outline" // Labeled actions: Kbd inline at rest (discoverable without hover). // Icon-only: Tip carries the chord (button is too tight for bare Kbd). const shortcutKbd = ( {action.shortcut} ) const button = (