@ethisyscore/plugin-ui 1.112.0 → 1.114.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,10 +2,38 @@
2
2
 
3
3
  var react = require('react');
4
4
  var xDataGrid = require('@mui/x-data-grid');
5
- var plugin = require('@ethisyscore/extension-runtime/plugin');
6
5
  var jsxRuntime = require('react/jsx-runtime');
6
+ var plugin = require('@ethisyscore/extension-runtime/plugin');
7
7
 
8
8
  // src/components/data-grid/PersistentDataGrid.tsx
9
+ var TEXT_COLUMN_TYPES = /* @__PURE__ */ new Set([
10
+ "string",
11
+ "number",
12
+ "date",
13
+ "dateTime",
14
+ "singleSelect"
15
+ ]);
16
+ var ELLIPSIS_STYLE = {
17
+ minWidth: 0,
18
+ overflow: "hidden",
19
+ textOverflow: "ellipsis",
20
+ whiteSpace: "nowrap"
21
+ };
22
+ function EllipsisCell(params) {
23
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { style: ELLIPSIS_STYLE, children: params.formattedValue });
24
+ }
25
+ function withCenteredColumns(columns) {
26
+ return columns.map((column) => {
27
+ const next = { ...column };
28
+ if (next.display == null) {
29
+ next.display = "flex";
30
+ }
31
+ if (!column.renderCell && TEXT_COLUMN_TYPES.has(column.type ?? "string")) {
32
+ next.renderCell = EllipsisCell;
33
+ }
34
+ return next;
35
+ });
36
+ }
9
37
  function useCurrentUser() {
10
38
  const identity = plugin.useHostIdentity();
11
39
  const user = identity?.user ?? null;
@@ -93,9 +121,11 @@ function PersistentDataGrid({
93
121
  persistDebounceMs = DEFAULT_GRID_DEBOUNCE_MS,
94
122
  initialState: callerInitialState,
95
123
  onStateChange: callerOnStateChange,
124
+ columns,
96
125
  ...rest
97
126
  }) {
98
127
  const apiRef = xDataGrid.useGridApiRef();
128
+ const centeredColumns = react.useMemo(() => withCenteredColumns(columns), [columns]);
99
129
  const pathname = typeof window !== "undefined" ? window.location.pathname : "";
100
130
  const effectiveGridKey = gridKey ?? deriveGridKeyFromPath(pathname);
101
131
  const userId = useCurrentUser()?.id ?? getGridUserId();
@@ -132,6 +162,7 @@ function PersistentDataGrid({
132
162
  xDataGrid.DataGrid,
133
163
  {
134
164
  ...rest,
165
+ columns: centeredColumns,
135
166
  apiRef,
136
167
  initialState: effectiveInitialState,
137
168
  onStateChange: handleStateChange
@@ -181,6 +212,7 @@ exports.getGridUserId = getGridUserId;
181
212
  exports.readPersistedGridState = readPersistedGridState;
182
213
  exports.setGridUserId = setGridUserId;
183
214
  exports.stripGridPagination = stripGridPagination;
215
+ exports.withCenteredColumns = withCenteredColumns;
184
216
  exports.writePersistedGridState = writePersistedGridState;
185
217
  //# sourceMappingURL=index.cjs.map
186
218
  //# sourceMappingURL=index.cjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/currentUser.ts","../../../src/components/data-grid/gridUserContext.ts","../../../src/components/data-grid/gridStatePersistence.ts","../../../src/components/data-grid/PersistentDataGrid.tsx","../../../src/components/data-grid/pagination.ts","../../../src/components/data-grid/dataGridPagination.ts"],"names":["useHostIdentity","useGridApiRef","useMemo","useRef","useEffect","useCallback","jsx","DataGrid"],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * `useCurrentUser` — the display-only current-user hook for plugin frontends\n * (WI 5154 follow-up #2, plugin-ui host-identity).\n *\n * A plugin page often needs the signed-in user for UX bits — a \"you are signed\n * in as …\" hint, pre-selecting the caller in a sign-off panel, or a\n * \"my acknowledgements\" heading. This hook surfaces that identity from the\n * host without the plugin importing the lower-level {@link useHostIdentity}\n * seam directly.\n *\n * It is a thin adapter over the host-provided {@link HostIdentity} context\n * (bound by the host through `ExtensionRuntimeProvider`'s `identity` prop):\n * it projects the host's richer {@link HostIdentityUser} down to the minimal\n * `{ id, displayName }` shape a plugin page needs for display.\n *\n * ⚠️ SECURITY — DISPLAY ONLY. This is presentation/UX data, NOT an\n * authorization source. Authorization is enforced HOST-SIDE at the MCP\n * boundary: the plugin backend re-authorises every tool/resource call from the\n * trusted server session. Never gate a mutation or a data read on this value.\n *\n * Returns `null` when the host has not provided an identity — standalone / mock\n * hosts, a host that predates the identity seam, or while host auth is still\n * loading or the caller is unauthenticated. Callers MUST handle `null`\n * (this matches the pre-existing plugin `useAuth().user` shim, which returned\n * `undefined`, so adopting this hook is a no-regression change).\n */\nimport { useHostIdentity } from \"@ethisyscore/extension-runtime/plugin\";\n\n/**\n * Minimal current-user shape a plugin page reads for display. Mirrors the\n * identity the host authenticates: a stable `id` and a human-readable\n * `displayName`. (No email — the host identity seam does not currently\n * forward one; if that changes, extend {@link HostIdentityUser} first and\n * project it here.)\n */\nexport interface CurrentUser {\n /** Stable user id (matches the host's authenticated caller id). */\n id: string;\n /** Human-readable display name (the host's full name). */\n displayName: string;\n}\n\n/**\n * Returns the host-authenticated current user projected to the display-only\n * {@link CurrentUser} shape, or `null` when no host identity is available.\n *\n * Display-only — see the module doc: authorization stays host-enforced at the\n * MCP boundary. Do NOT use the returned value as an authorization decision.\n */\nexport function useCurrentUser(): CurrentUser | null {\n const identity = useHostIdentity();\n const user = identity?.user ?? null;\n if (!user) {\n return null;\n }\n return {\n id: user.id,\n // The host binds `fullName`; fall back to composing first + last so a host\n // that only sets the parts still yields a usable display name.\n displayName:\n user.fullName?.trim() || `${user.firstName ?? \"\"} ${user.lastName ?? \"\"}`.trim(),\n };\n}\n","/**\n * Module-level mirror of the current signed-in user's id — the FALLBACK source for the grid\n * persistence key.\n *\n * ## Read this before using it\n *\n * For plugin surfaces you do NOT need to stamp anything. {@link PersistentDataGrid} reads the host\n * identity via `useCurrentUser` and only consults this ref when no identity is present. This exists\n * for an embedder that has no host identity to provide — the monolith arrangement, where a host-app\n * hook stamped the ref from its own auth context.\n *\n * ## Why it is the fallback and not the primary\n *\n * It used to be the only source, and that shipped a silent defect: the doc below described a\n * `useGridUserSync` hook in the host app, which was never carried over when these components were\n * lifted into plugin-ui. Measured across all eleven plugin repos on 2026-08-26, `setGridUserId` had\n * ZERO call sites — only its own declaration and this comment. So `getGridUserId()` always returned\n * `null`, `buildGridStorageKey` always returned `null`, and 17 grids ran a persistence wrapper that\n * read nothing and wrote nothing. It failed invisibly, because a grid with no saved preferences\n * looks exactly like a grid whose preferences were never saved.\n *\n * The lesson is in the ordering, not the code: a seam that requires the CONSUMER to remember a wiring\n * step will eventually meet a consumer who does not. Reading the identity the host already binds\n * removes the step.\n *\n * ## Why a module-level ref rather than a context\n *\n * The persistence layer needs the id synchronously when a grid mounts, to build the localStorage\n * key — not as a render-time prop, and never plumbed through every view.\n *\n * When `null` (pre-auth / signed out / standalone harness) persistence is simply off: the key builder\n * returns `null`, reads fall back to grid defaults, and writes are ignored.\n */\n\nlet gridUserIdRef: string | null = null;\n\n/** Returns the current user's id, or `null` while auth is unresolved / signed out. */\nexport function getGridUserId(): string | null {\n return gridUserIdRef;\n}\n\n/**\n * Stamps the module-level user-id ref.\n *\n * Plugin surfaces do not need this — {@link PersistentDataGrid} prefers the host identity, so\n * calling it is optional and stamping is not part of adopting the grid. Use it only in an embedder\n * with no host identity, each time its authenticated user resolves or changes. Pass `null` to\n * disable persistence.\n */\nexport function setGridUserId(userId: string | null): void {\n gridUserIdRef = userId;\n}\n","/**\n * Pure, gogo-ui-safe persistence logic for MUI DataGrid state (column\n * visibility, sort, filter, and column widths). This is the single source of\n * truth for the key shape, the localStorage read/write, the debounce window,\n * and the pagination-slice stripping that the AB#4840 column-visibility feature\n * relies on.\n *\n * It lives in gogo-ui (which MAY import `@mui/x-data-grid` types directly) so\n * the reusable {@link PersistentDataGrid} wrapper can own the apiRef and MUI\n * types in one place — the host app's `src/**` may NOT import `@mui/x-data-grid`\n * (values or types), so this logic cannot live in the host hook.\n *\n * Auth-agnostic: the user id comes from the host-stamped module ref in\n * `gridUserContextValue.ts` at the call sites (the wrapper), not from this\n * module — keeping these functions pure and unit-testable.\n *\n * No React, no MUI values — only the `GridInitialState` TYPE import, so this\n * module is a plain value module (`.ts`) free of `react-refresh` concerns.\n */\nimport type { GridInitialState } from \"@mui/x-data-grid\";\n\n/**\n * Schema version for the persisted grid-state payload. Bump this when the shape\n * of what we store changes incompatibly so stale entries from an older app\n * version are ignored (the key changes, the old key is simply orphaned) rather\n * than restored into a grid that can no longer interpret them.\n */\nexport const GRID_STATE_VERSION = \"v1\";\nexport const GRID_STATE_KEY_PREFIX = \"cc-grid-state\";\n\n/**\n * Default debounce window for persisting grid state. `onStateChange` fires very\n * frequently (every hover, focus, resize tick) — debouncing collapses a burst\n * of changes into a single localStorage write.\n */\nexport const DEFAULT_GRID_DEBOUNCE_MS = 400;\n\n/**\n * Builds the per-user, per-grid localStorage key. Keying on the user id keeps a\n * shared browser's saved preferences separate per signed-in user; keying on\n * `gridKey` keeps each grid's preferences independent. Returns `null` when there\n * is no user id yet (pre-auth / signed out) — callers then behave as a no-op\n * persistence layer and the grid renders with its built-in defaults.\n */\nexport function buildGridStorageKey(userId: string | null | undefined, gridKey: string): string | null {\n if (!userId) return null;\n return `${GRID_STATE_KEY_PREFIX}:${GRID_STATE_VERSION}:${userId}:${gridKey}`;\n}\n\n/**\n * Matches a single path segment that is \"id-ish\" — an entity identifier rather\n * than a stable grid-type segment — so {@link deriveGridKeyFromPath} can strip it\n * and key preferences per grid TYPE instead of per entity. A segment is dropped\n * when it is either:\n *\n * - **all-numeric** — `123`, `0042` (numeric DB ids / sequence numbers); or\n * - **a UUID** — canonical 8-4-4-4-12 hex form, case-insensitive\n * (e.g. `3fa85f64-5717-4562-b3fc-2c963f66afa6`).\n *\n * Deliberately NARROW: only these two shapes are treated as ids. Slugs, ULIDs,\n * mixed alphanumerics, and ordinary words (`new`, `all`, `documents`,\n * `position-history`) are KEPT, so two genuinely different grid pages never\n * collide on the same key. The trade-off — a non-numeric, non-UUID id (e.g. a\n * short code or a base-62 id) is not stripped, so its grid keys per entity — is\n * acceptable: such routes are rare, and an explicit `gridKey` prop overrides the\n * auto-key whenever per-entity keying is wrong (see {@link PersistentDataGrid}).\n */\nconst ID_ISH_SEGMENT = /^(?:\\d+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;\n\n/**\n * Derives a STABLE, per-grid-TYPE key from a route pathname, used by\n * {@link PersistentDataGrid} when no explicit `gridKey` prop is supplied.\n *\n * Stripping rule (so prefs are per-grid-type, not per-entity instance):\n * 1. Split the pathname on `/` and drop empty segments (leading/trailing/double\n * slashes, and a bare `/` root).\n * 2. Drop every \"id-ish\" segment per {@link ID_ISH_SEGMENT} — all-numeric ids and\n * canonical UUIDs.\n * 3. Join the survivors with `/`.\n *\n * Examples:\n * - `/projects/123/documents` → `projects/documents`\n * - `/hr/employees/3fa85f64-…-66afa6` → `hr/employees`\n * - `/super-admin/organisations` → `super-admin/organisations`\n * - `/` (root) → `route:root` (fallback)\n *\n * The returned key is prefixed with `route:` so an auto-derived key can never\n * be mistaken for, or collide with, a hand-authored `\"<module>.<view>\"` gridKey\n * (which uses dots, not slashes). When stripping leaves nothing (e.g. a route\n * that is ONLY ids, or the bare root), it falls back to `route:root` rather than\n * an empty string, so the storage key stays well-formed.\n */\nexport function deriveGridKeyFromPath(pathname: string): string {\n const kept = pathname\n .split(\"/\")\n .filter((segment) => segment.length > 0 && !ID_ISH_SEGMENT.test(segment));\n return kept.length > 0 ? `route:${kept.join(\"/\")}` : \"route:root\";\n}\n\n/**\n * Strips the `pagination` slice from a grid state. Pagination on these grids is\n * server-owned and supplied as a controlled `paginationModel` via the pagination\n * helper; persisting/restoring it would clash with the controlled prop (MUI\n * warns when `initialState.pagination.paginationModel` is set alongside a\n * controlled model) and isn't part of the AB#4840 scope (visibility / sort /\n * filter / column widths). We keep everything else `exportState()` produces —\n * including `columns` (visibility + widths + order), `sorting`, and `filter`.\n */\nexport function stripGridPagination(state: GridInitialState): GridInitialState {\n const { pagination: _pagination, ...rest } = state;\n return rest;\n}\n\n/**\n * Reads a previously-persisted grid state from localStorage. SSR / throw-safe:\n * any failure (storage blocked, corrupted JSON, quota errors, no `localStorage`\n * during SSR) falls back to `undefined` so the grid renders with defaults rather\n * than crashing.\n */\nexport function readPersistedGridState(storageKey: string | null): GridInitialState | undefined {\n if (!storageKey) return undefined;\n if (typeof localStorage === \"undefined\") return undefined;\n try {\n const raw = localStorage.getItem(storageKey);\n if (!raw) return undefined;\n return stripGridPagination(JSON.parse(raw) as GridInitialState);\n } catch {\n return undefined;\n }\n}\n\n/**\n * Writes a grid state to localStorage under `storageKey` (the server-owned\n * pagination slice stripped first). SSR / throw-safe: storage blocked, quota\n * exceeded, or serialization failure are swallowed — a persistence failure must\n * never crash the grid. No-ops on a nullish key (no user) or nullish state\n * (apiRef not yet attached).\n */\nexport function writePersistedGridState(storageKey: string | null, state: GridInitialState | undefined): void {\n if (!storageKey || !state) return;\n if (typeof localStorage === \"undefined\") return;\n try {\n localStorage.setItem(storageKey, JSON.stringify(stripGridPagination(state)));\n } catch {\n // Storage blocked / quota exceeded / serialization failure — preferences\n // just won't persist this session. Never let a persistence failure crash\n // the grid.\n }\n}\n\n/**\n * Creates a debounced writer bound to a fresh timer. Each returned function\n * collapses a burst of `onStateChange` calls into a single localStorage write\n * after `debounceMs`. The caller owns one instance per grid (e.g. via a ref) so\n * timers don't leak across grids.\n */\nexport function createDebouncedGridWriter(debounceMs: number = DEFAULT_GRID_DEBOUNCE_MS) {\n let timer: ReturnType<typeof setTimeout> | null = null;\n const flushNow = (storageKey: string | null, state: GridInitialState | undefined) => {\n writePersistedGridState(storageKey, state);\n };\n const schedule = (storageKey: string | null, state: GridInitialState | undefined) => {\n if (!storageKey || !state) return;\n if (timer) clearTimeout(timer);\n timer = setTimeout(() => flushNow(storageKey, state), debounceMs);\n };\n const cancel = () => {\n if (timer) {\n clearTimeout(timer);\n timer = null;\n }\n };\n return { schedule, cancel };\n}\n","/**\n * PersistentDataGrid — a thin, drop-in wrapper around MUI `<DataGrid>` that\n * persists the user's column visibility, sort, filter, and column-width\n * preferences to localStorage, keyed per-user + per-grid (AB#4840).\n *\n * Adoption for ANY paginated grid is a near-one-line swap:\n *\n * ```tsx\n * // before\n * import { DataGrid } from \"@mui/x-data-grid\";\n * <DataGrid rows={rows} columns={cols} {...buildDataGridPaginationProps(pagination)} ... />\n *\n * // after\n * import { PersistentDataGrid } from \"../shared/PersistentDataGrid\";\n * <PersistentDataGrid gridKey=\"projects.all\" rows={rows} columns={cols} {...buildDataGridPaginationProps(pagination)} ... />\n * ```\n *\n * It composes with `buildDataGridPaginationProps` / `buildDataGridSlots`: those\n * prop bundles are spread straight through (`...rest`) untouched. The wrapper\n * owns the grid `apiRef` (created internally via `useGridApiRef()` so it can\n * read `exportState()`) and two props it MERGES rather than clobbers:\n *\n * - `initialState`: persisted state is layered OVER any caller default, so a\n * view can still seed default column visibility while the user's saved\n * preferences win on the keys they've touched.\n * - `onStateChange`: the caller's handler (if any) still fires; the wrapper\n * additionally schedules a debounced persist.\n *\n * The current user id comes from the HOST IDENTITY (`useCurrentUser`, which reads\n * the identity the host binds to every mounted plugin surface), falling back to the\n * stamped module ref (`gridUserContext.getGridUserId`) for an embedder that provides\n * no identity. Either way NO `userId` prop is threaded through views. When there is\n * no user at all (pre-auth / signed out / standalone harness) the storage key is\n * `null` and the wrapper degrades to a plain DataGrid — no reads, no writes.\n *\n * gogo-ui MAY import `@mui/x-data-grid` directly, so the apiRef + MUI types live\n * here, keeping the host `src/**` free of any `@mui/x-data-grid` import.\n *\n * ## Auto-key (no `gridKey` prop)\n *\n * `gridKey` is OPTIONAL. When omitted, the grid key is derived from the current\n * route via `window.location.pathname` and {@link deriveGridKeyFromPath} — id-ish\n * segments (all-numeric ids, UUIDs) are stripped so preferences are keyed per\n * grid TYPE, not per entity (e.g. `/projects/123/documents` and\n * `/projects/456/documents` share one key `route:projects/documents`). This makes\n * adoption a one-line IMPORT REDIRECT for the common single-grid-per-page case —\n * a view swaps its DataGrid import to the barrel and gets persistence for free.\n *\n * An explicit `gridKey` ALWAYS overrides the auto-key. Pass one for:\n * - multi-grid pages (2+ grids on one route would otherwise share a key), and\n * - long-lived grids whose key must stay stable independent of route changes\n * (e.g. the canonical list grids keep `\"projects.all\"` etc.).\n *\n * NOTE: the wrapper creates its OWN apiRef. A view that also needs direct apiRef\n * access for unrelated reasons should keep a plain `<DataGrid>` — that's rare;\n * persistence is the only thing 99% of grids want the apiRef for.\n */\nimport { useCallback, useEffect, useMemo, useRef } from \"react\";\n\nimport { DataGrid, useGridApiRef, type DataGridProps, type GridInitialState } from \"@mui/x-data-grid\";\n\nimport { useCurrentUser } from \"../../currentUser\";\nimport { getGridUserId } from \"./gridUserContext\";\nimport {\n buildGridStorageKey,\n createDebouncedGridWriter,\n deriveGridKeyFromPath,\n readPersistedGridState,\n DEFAULT_GRID_DEBOUNCE_MS,\n} from \"./gridStatePersistence\";\n\nexport interface PersistentDataGridProps extends Omit<DataGridProps, \"apiRef\"> {\n /**\n * Stable identifier for this grid, unique per logical grid across the app.\n * Used as the per-grid segment of the localStorage key. Convention:\n * `\"<module>.<view>\"` — e.g. `\"projects.all\"`, `\"legal.risks\"`,\n * `\"superAdmin.organisations\"`. Must not change between renders/sessions or\n * the saved preferences orphan.\n *\n * OPTIONAL: when omitted, an auto-key is derived from the current route (see\n * the file-level \"Auto-key\" note). Pass an explicit key for multi-grid pages\n * or to pin a key independent of the route. An explicit key always wins.\n */\n gridKey?: string;\n /** Debounce window (ms) for the persist write. Defaults to 400ms. */\n persistDebounceMs?: number;\n}\n\n/**\n * Shallow-merges a restored grid state OVER a caller-provided `initialState`.\n * Top-level slices (`columns`, `sorting`, `filter`, `pinnedColumns`, …) from the\n * restored state replace the caller's; slices the user never touched fall back\n * to the caller default. A deep merge isn't warranted — MUI's `exportState()`\n * emits a complete slice per touched feature, so slice-level replacement is the\n * correct granularity (and matches how `initialState` is consumed once on mount).\n */\nfunction mergeInitialState(\n callerInitialState: GridInitialState | undefined,\n restored: GridInitialState | undefined,\n): GridInitialState | undefined {\n if (!restored) return callerInitialState;\n if (!callerInitialState) return restored;\n return { ...callerInitialState, ...restored };\n}\n\nexport function PersistentDataGrid({\n gridKey,\n persistDebounceMs = DEFAULT_GRID_DEBOUNCE_MS,\n initialState: callerInitialState,\n onStateChange: callerOnStateChange,\n ...rest\n}: PersistentDataGridProps) {\n // Own an apiRef so we can read exportState() on every state change.\n const apiRef = useGridApiRef();\n\n // Resolve the effective grid key: an explicit `gridKey` prop ALWAYS wins;\n // otherwise derive a stable per-grid-type key from the current route (id-ish\n // segments stripped). We read `window.location.pathname` directly rather than\n // `useLocation()` so this leaf wrapper needs no `<Router>` context — accurate at\n // render time (react-router drives navigation through the history API, which\n // updates `window.location`), and grid views remount on navigation so the path\n // is re-read on the next mount. Guarded for SSR / non-DOM environments (falls\n // back to \"\" → `route:root`).\n const pathname = typeof window !== \"undefined\" ? window.location.pathname : \"\";\n const effectiveGridKey = gridKey ?? deriveGridKeyFromPath(pathname);\n\n // Resolve the per-user storage key. The HOST IDENTITY is the primary source:\n // `useCurrentUser` reads the identity the host binds to every mounted surface, so a plugin\n // gets persistence with nothing to wire up. `getGridUserId()` is the fallback, for an\n // embedder that stamps the ref instead of providing an identity (the monolith's arrangement).\n //\n // That order matters, and it is the fix for a real defect: this component previously read the\n // ref ONLY, and no plugin ever stamped it - the `useGridUserSync` hook its docs named was a\n // host-app hook that was not carried over on the lift. So the key was permanently null and\n // every plugin grid silently persisted nothing. Reading the identity means it cannot be\n // forgotten again, because there is nothing left to forget.\n //\n // `userId` is in the dependency list, unlike the bare `getGridUserId()` call it replaces: the\n // identity resolves ASYNCHRONOUSLY (`isLoading` while the host's /auth/user is in flight), so a\n // mount-time-only read would miss it and fall back to no persistence on every first paint.\n const userId = useCurrentUser()?.id ?? getGridUserId();\n const storageKey = useMemo(() => buildGridStorageKey(userId, effectiveGridKey), [userId, effectiveGridKey]);\n const restoredState = useMemo(() => readPersistedGridState(storageKey), [storageKey]);\n const effectiveInitialState = useMemo(\n () => mergeInitialState(callerInitialState, restoredState),\n [callerInitialState, restoredState],\n );\n\n // Apply the persisted state when the key changes AFTER mount, which is what the async identity\n // actually produces: the grid mounts pre-auth with no user, then the host resolves one.\n //\n // Recomputing `storageKey` and `restoredState` is not enough on its own, because MUI consumes\n // `initialState` on the FIRST RENDER ONLY — so the correct state would be read and then have\n // nowhere to go. `restoreState` is the documented route for injecting it later (\"These values can\n // then be passed to the `initialState` prop or injected using the `restoreState` method\").\n //\n // `appliedKeyRef` starts at the mount-time key, so the mount case stays with `initialState` and is\n // never restored twice. Only a genuine key CHANGE reaches the api call — in practice once per\n // session, when auth resolves — so this cannot clobber preferences a user is actively changing.\n const appliedKeyRef = useRef<string | null>(storageKey);\n useEffect(() => {\n if (storageKey === appliedKeyRef.current) {\n return;\n }\n appliedKeyRef.current = storageKey;\n if (storageKey && restoredState) {\n apiRef.current?.restoreState(restoredState);\n }\n }, [storageKey, restoredState, apiRef]);\n\n // One debounced writer per grid instance, kept stable across renders.\n const writerRef = useRef<ReturnType<typeof createDebouncedGridWriter> | null>(null);\n if (writerRef.current === null) {\n writerRef.current = createDebouncedGridWriter(persistDebounceMs);\n }\n\n // Signature-agnostic forwarder: `onStateChange`'s exact param tuple varies by\n // MUI x-data-grid minor, so we accept the args as a rest tuple typed off the\n // prop itself and forward them verbatim. This stays assignable to\n // `DataGridProps[\"onStateChange\"]` regardless of the concrete arity.\n type StateChangeArgs = Parameters<NonNullable<DataGridProps[\"onStateChange\"]>>;\n const handleStateChange = useCallback(\n (...args: StateChangeArgs) => {\n callerOnStateChange?.(...args);\n // exportState() returns the full persistable snapshot; the writer strips\n // the server-owned pagination slice before persisting.\n if (storageKey) {\n writerRef.current?.schedule(storageKey, apiRef.current?.exportState());\n }\n },\n [callerOnStateChange, storageKey, apiRef],\n );\n\n return (\n <DataGrid\n {...rest}\n apiRef={apiRef}\n initialState={effectiveInitialState}\n onStateChange={handleStateChange}\n />\n );\n}\n","/**\n * Canonical page-size options and default, promoted out of the plugins.\n *\n * A `constants/pagination.ts` appeared in three of the five plugin repos measured on\n * 2026-08-20, each declaring its own option list. Four copies of a list of page sizes is four\n * answers to \"what page sizes does this product offer\", and the divergence shows up as a grid\n * that offers 25 rows next to one that offers 20.\n */\n\n/** Page sizes a data grid offers. */\nexport const PAGE_SIZE_OPTIONS = [5, 10, 25, 50] as const;\n\n/** Page size a list starts on. */\nexport const DEFAULT_PAGE_SIZE = 10;\n","import type { GridPaginationModel } from \"@mui/x-data-grid\";\nimport { PAGE_SIZE_OPTIONS } from \"./pagination\";\n\n/**\n * Pagination object accepted by {@link buildDataGridPaginationProps}. Aligns with\n * `ContractPagination` in `@coreconnect/contracts`: 1-based page numbers with separate\n * page-change and page-size-change callbacks. The `STATIC_PAGINATION` placeholder used by\n * client-side-paginated views is also accepted (it omits `onPageSizeChange` — the helper\n * detects the omission and skips the size-change branch).\n */\nexport interface DataGridPaginationInput {\n page: number;\n pageSize: number;\n total: number;\n onPageChange: (page: number) => void;\n onPageSizeChange?: (pageSize: number) => void;\n}\n\n/**\n * Bundle of MUI DataGrid pagination props. Spread into a `<DataGrid {...}>` to wire\n * server-paginated lists in one line and avoid the per-view `onPaginationModelChange`\n * boilerplate.\n */\nexport interface DataGridPaginationProps {\n pagination: true;\n paginationMode: \"server\";\n rowCount: number;\n paginationModel: { page: number; pageSize: number };\n onPaginationModelChange: (model: GridPaginationModel) => void;\n pageSizeOptions: readonly number[];\n}\n\n/**\n * Builds the canonical pagination prop bundle for a server-paginated MUI DataGrid.\n *\n * The hand-written equivalent across the codebase looked like:\n *\n * ```tsx\n * paginationMode=\"server\"\n * rowCount={pagination.total}\n * paginationModel={{ page: pagination.page - 1, pageSize: pagination.pageSize }}\n * onPaginationModelChange={(model) => {\n * pagination.onPageChange(model.page + 1);\n * if (\"onPageSizeChange\" in pagination && pagination.onPageSizeChange) {\n * pagination.onPageSizeChange(model.pageSize);\n * }\n * }}\n * pageSizeOptions={[...PAGE_SIZE_OPTIONS]}\n * ```\n *\n * The naive form had a latent bug: changing page size called BOTH callbacks, so the\n * size-change handler (which typically resets page to 1) cancelled out subsequent\n * page-N navigation, pinning the user to page 1. This helper distinguishes the two\n * cases and only fires the relevant callback.\n *\n * Usage:\n *\n * ```tsx\n * <DataGrid\n * {...buildDataGridPaginationProps(effectivePagination)}\n * rows={items}\n * columns={columns}\n * ...\n * />\n * ```\n */\nexport function buildDataGridPaginationProps(\n pagination: DataGridPaginationInput,\n): DataGridPaginationProps {\n return {\n pagination: true,\n paginationMode: \"server\",\n rowCount: pagination.total,\n paginationModel: {\n page: pagination.page - 1,\n pageSize: pagination.pageSize,\n },\n onPaginationModelChange: (model) => {\n // Distinguish page-only navigation from page-size change. The naive\n // \"always call both callbacks\" approach pinned the grid to page 1 forever:\n // a page-size change would call onPageSizeChange (which resets page to 1)\n // AND onPageChange(model.page + 1), and on the next page navigation\n // onPageSizeChange would fire again from the updated model.pageSize.\n const pageSizeChanged = model.pageSize !== pagination.pageSize;\n if (pageSizeChanged) {\n if (pagination.onPageSizeChange) {\n pagination.onPageSizeChange(model.pageSize);\n }\n return;\n }\n pagination.onPageChange(model.page + 1);\n },\n pageSizeOptions: [...PAGE_SIZE_OPTIONS],\n };\n}\n"]}
1
+ {"version":3,"sources":["../../../src/components/data-grid/centeredColumns.tsx","../../../src/currentUser.ts","../../../src/components/data-grid/gridUserContext.ts","../../../src/components/data-grid/gridStatePersistence.ts","../../../src/components/data-grid/PersistentDataGrid.tsx","../../../src/components/data-grid/pagination.ts","../../../src/components/data-grid/dataGridPagination.ts"],"names":["jsx","useHostIdentity","useGridApiRef","useMemo","useRef","useEffect","useCallback","DataGrid"],"mappings":"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","file":"index.cjs","sourcesContent":["import type { ReactElement, ReactNode } from \"react\";\nimport type { GridColDef, GridRenderCellParams, GridValidRowModel } from \"@mui/x-data-grid\";\n\n/**\n * The column types whose VIEW-mode cell is a formatted string, so they get the\n * ellipsis wrapper below. `singleSelect` is included deliberately: its view cell is\n * the option's formatted label — the Select control only appears in EDIT mode via\n * `renderEditCell`, which this leaves untouched — so a long label needs the same\n * truncation as plain text. Types that render their OWN view control (`actions`,\n * `boolean`) are excluded, as is any column that already has a `renderCell`. An\n * unset `type` is `'string'`.\n */\nconst TEXT_COLUMN_TYPES: ReadonlySet<string> = new Set([\n \"string\",\n \"number\",\n \"date\",\n \"dateTime\",\n \"singleSelect\",\n]);\n\n/**\n * Inline styles that restore single-line \"…\" truncation for a value that now sits\n * inside a `display: flex` cell. `minWidth: 0` is the load-bearing part — a flex\n * item defaults to `min-width: auto` (its content size) and would refuse to shrink,\n * so without it the text overflows the cell instead of ellipsing.\n */\nconst ELLIPSIS_STYLE = {\n minWidth: 0,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n} as const;\n\nfunction EllipsisCell(params: GridRenderCellParams): ReactElement {\n // `formattedValue` is MUI's own display value (it honours `valueFormatter` and\n // falls back to the raw value when none is set), so this renders exactly what the\n // default text cell would — just inside a shrinkable, ellipsing wrapper.\n return <div style={ELLIPSIS_STYLE}>{params.formattedValue as ReactNode}</div>;\n}\n\n/**\n * Vertically-center every cell's content while keeping single-line text truncation.\n *\n * WHY. A Data Grid cell's default column `display` is `'text'` (block): single-line\n * text is centred by line-height and truncates with an ellipsis, but ANYTHING else —\n * chips, icons, a custom `renderCell`, or any content on a dynamic-height row — sits\n * at the TOP of a taller row. MUI's own fix is per-column `display: 'flex'`, which\n * centres arbitrary content but drops the built-in ellipsis, because the default\n * value is a raw text node with no element to apply `text-overflow` to. This helper\n * applies both halves together, for every column, so a grid gets consistent vertical\n * centring WITHOUT losing text truncation:\n *\n * 1. `display: 'flex'` on every column that doesn't already declare one — centres\n * all content types, on fixed- and dynamic-height rows alike.\n * 2. an ellipsis-wrapper `renderCell` on renderer-less text columns — re-adds the\n * \"…\" truncation that step 1 would otherwise remove. Columns with their own\n * `renderCell`, and non-text types, are left untouched: they own their content\n * and already lay it out centred.\n *\n * Pure and idempotent: a column that already has a `display` or a `renderCell` is\n * passed through unchanged, so re-running over an already-processed list is a no-op.\n * The input array and its column objects are not mutated. Memoise the result at the\n * call site so cells don't remount on every render.\n */\nexport function withCenteredColumns<R extends GridValidRowModel = GridValidRowModel>(\n columns: readonly GridColDef<R>[],\n): GridColDef<R>[] {\n return columns.map((column) => {\n const next: GridColDef<R> = { ...column };\n\n // (1) Centre content unless the column declares its own display mode (respecting\n // an explicit `display: 'text'` an author chose on purpose).\n if (next.display == null) {\n next.display = \"flex\";\n }\n\n // (2) Restore ellipsis on renderer-less text columns only.\n if (!column.renderCell && TEXT_COLUMN_TYPES.has(column.type ?? \"string\")) {\n next.renderCell = EllipsisCell;\n }\n\n return next;\n });\n}\n","/**\n * `useCurrentUser` — the display-only current-user hook for plugin frontends\n * (WI 5154 follow-up #2, plugin-ui host-identity).\n *\n * A plugin page often needs the signed-in user for UX bits — a \"you are signed\n * in as …\" hint, pre-selecting the caller in a sign-off panel, or a\n * \"my acknowledgements\" heading. This hook surfaces that identity from the\n * host without the plugin importing the lower-level {@link useHostIdentity}\n * seam directly.\n *\n * It is a thin adapter over the host-provided {@link HostIdentity} context\n * (bound by the host through `ExtensionRuntimeProvider`'s `identity` prop):\n * it projects the host's richer {@link HostIdentityUser} down to the minimal\n * `{ id, displayName }` shape a plugin page needs for display.\n *\n * ⚠️ SECURITY — DISPLAY ONLY. This is presentation/UX data, NOT an\n * authorization source. Authorization is enforced HOST-SIDE at the MCP\n * boundary: the plugin backend re-authorises every tool/resource call from the\n * trusted server session. Never gate a mutation or a data read on this value.\n *\n * Returns `null` when the host has not provided an identity — standalone / mock\n * hosts, a host that predates the identity seam, or while host auth is still\n * loading or the caller is unauthenticated. Callers MUST handle `null`\n * (this matches the pre-existing plugin `useAuth().user` shim, which returned\n * `undefined`, so adopting this hook is a no-regression change).\n */\nimport { useHostIdentity } from \"@ethisyscore/extension-runtime/plugin\";\n\n/**\n * Minimal current-user shape a plugin page reads for display. Mirrors the\n * identity the host authenticates: a stable `id` and a human-readable\n * `displayName`. (No email — the host identity seam does not currently\n * forward one; if that changes, extend {@link HostIdentityUser} first and\n * project it here.)\n */\nexport interface CurrentUser {\n /** Stable user id (matches the host's authenticated caller id). */\n id: string;\n /** Human-readable display name (the host's full name). */\n displayName: string;\n}\n\n/**\n * Returns the host-authenticated current user projected to the display-only\n * {@link CurrentUser} shape, or `null` when no host identity is available.\n *\n * Display-only — see the module doc: authorization stays host-enforced at the\n * MCP boundary. Do NOT use the returned value as an authorization decision.\n */\nexport function useCurrentUser(): CurrentUser | null {\n const identity = useHostIdentity();\n const user = identity?.user ?? null;\n if (!user) {\n return null;\n }\n return {\n id: user.id,\n // The host binds `fullName`; fall back to composing first + last so a host\n // that only sets the parts still yields a usable display name.\n displayName:\n user.fullName?.trim() || `${user.firstName ?? \"\"} ${user.lastName ?? \"\"}`.trim(),\n };\n}\n","/**\n * Module-level mirror of the current signed-in user's id — the FALLBACK source for the grid\n * persistence key.\n *\n * ## Read this before using it\n *\n * For plugin surfaces you do NOT need to stamp anything. {@link PersistentDataGrid} reads the host\n * identity via `useCurrentUser` and only consults this ref when no identity is present. This exists\n * for an embedder that has no host identity to provide — the monolith arrangement, where a host-app\n * hook stamped the ref from its own auth context.\n *\n * ## Why it is the fallback and not the primary\n *\n * It used to be the only source, and that shipped a silent defect: the doc below described a\n * `useGridUserSync` hook in the host app, which was never carried over when these components were\n * lifted into plugin-ui. Measured across all eleven plugin repos on 2026-08-26, `setGridUserId` had\n * ZERO call sites — only its own declaration and this comment. So `getGridUserId()` always returned\n * `null`, `buildGridStorageKey` always returned `null`, and 17 grids ran a persistence wrapper that\n * read nothing and wrote nothing. It failed invisibly, because a grid with no saved preferences\n * looks exactly like a grid whose preferences were never saved.\n *\n * The lesson is in the ordering, not the code: a seam that requires the CONSUMER to remember a wiring\n * step will eventually meet a consumer who does not. Reading the identity the host already binds\n * removes the step.\n *\n * ## Why a module-level ref rather than a context\n *\n * The persistence layer needs the id synchronously when a grid mounts, to build the localStorage\n * key — not as a render-time prop, and never plumbed through every view.\n *\n * When `null` (pre-auth / signed out / standalone harness) persistence is simply off: the key builder\n * returns `null`, reads fall back to grid defaults, and writes are ignored.\n */\n\nlet gridUserIdRef: string | null = null;\n\n/** Returns the current user's id, or `null` while auth is unresolved / signed out. */\nexport function getGridUserId(): string | null {\n return gridUserIdRef;\n}\n\n/**\n * Stamps the module-level user-id ref.\n *\n * Plugin surfaces do not need this — {@link PersistentDataGrid} prefers the host identity, so\n * calling it is optional and stamping is not part of adopting the grid. Use it only in an embedder\n * with no host identity, each time its authenticated user resolves or changes. Pass `null` to\n * disable persistence.\n */\nexport function setGridUserId(userId: string | null): void {\n gridUserIdRef = userId;\n}\n","/**\n * Pure, gogo-ui-safe persistence logic for MUI DataGrid state (column\n * visibility, sort, filter, and column widths). This is the single source of\n * truth for the key shape, the localStorage read/write, the debounce window,\n * and the pagination-slice stripping that the AB#4840 column-visibility feature\n * relies on.\n *\n * It lives in gogo-ui (which MAY import `@mui/x-data-grid` types directly) so\n * the reusable {@link PersistentDataGrid} wrapper can own the apiRef and MUI\n * types in one place — the host app's `src/**` may NOT import `@mui/x-data-grid`\n * (values or types), so this logic cannot live in the host hook.\n *\n * Auth-agnostic: the user id comes from the host-stamped module ref in\n * `gridUserContextValue.ts` at the call sites (the wrapper), not from this\n * module — keeping these functions pure and unit-testable.\n *\n * No React, no MUI values — only the `GridInitialState` TYPE import, so this\n * module is a plain value module (`.ts`) free of `react-refresh` concerns.\n */\nimport type { GridInitialState } from \"@mui/x-data-grid\";\n\n/**\n * Schema version for the persisted grid-state payload. Bump this when the shape\n * of what we store changes incompatibly so stale entries from an older app\n * version are ignored (the key changes, the old key is simply orphaned) rather\n * than restored into a grid that can no longer interpret them.\n */\nexport const GRID_STATE_VERSION = \"v1\";\nexport const GRID_STATE_KEY_PREFIX = \"cc-grid-state\";\n\n/**\n * Default debounce window for persisting grid state. `onStateChange` fires very\n * frequently (every hover, focus, resize tick) — debouncing collapses a burst\n * of changes into a single localStorage write.\n */\nexport const DEFAULT_GRID_DEBOUNCE_MS = 400;\n\n/**\n * Builds the per-user, per-grid localStorage key. Keying on the user id keeps a\n * shared browser's saved preferences separate per signed-in user; keying on\n * `gridKey` keeps each grid's preferences independent. Returns `null` when there\n * is no user id yet (pre-auth / signed out) — callers then behave as a no-op\n * persistence layer and the grid renders with its built-in defaults.\n */\nexport function buildGridStorageKey(userId: string | null | undefined, gridKey: string): string | null {\n if (!userId) return null;\n return `${GRID_STATE_KEY_PREFIX}:${GRID_STATE_VERSION}:${userId}:${gridKey}`;\n}\n\n/**\n * Matches a single path segment that is \"id-ish\" — an entity identifier rather\n * than a stable grid-type segment — so {@link deriveGridKeyFromPath} can strip it\n * and key preferences per grid TYPE instead of per entity. A segment is dropped\n * when it is either:\n *\n * - **all-numeric** — `123`, `0042` (numeric DB ids / sequence numbers); or\n * - **a UUID** — canonical 8-4-4-4-12 hex form, case-insensitive\n * (e.g. `3fa85f64-5717-4562-b3fc-2c963f66afa6`).\n *\n * Deliberately NARROW: only these two shapes are treated as ids. Slugs, ULIDs,\n * mixed alphanumerics, and ordinary words (`new`, `all`, `documents`,\n * `position-history`) are KEPT, so two genuinely different grid pages never\n * collide on the same key. The trade-off — a non-numeric, non-UUID id (e.g. a\n * short code or a base-62 id) is not stripped, so its grid keys per entity — is\n * acceptable: such routes are rare, and an explicit `gridKey` prop overrides the\n * auto-key whenever per-entity keying is wrong (see {@link PersistentDataGrid}).\n */\nconst ID_ISH_SEGMENT = /^(?:\\d+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;\n\n/**\n * Derives a STABLE, per-grid-TYPE key from a route pathname, used by\n * {@link PersistentDataGrid} when no explicit `gridKey` prop is supplied.\n *\n * Stripping rule (so prefs are per-grid-type, not per-entity instance):\n * 1. Split the pathname on `/` and drop empty segments (leading/trailing/double\n * slashes, and a bare `/` root).\n * 2. Drop every \"id-ish\" segment per {@link ID_ISH_SEGMENT} — all-numeric ids and\n * canonical UUIDs.\n * 3. Join the survivors with `/`.\n *\n * Examples:\n * - `/projects/123/documents` → `projects/documents`\n * - `/hr/employees/3fa85f64-…-66afa6` → `hr/employees`\n * - `/super-admin/organisations` → `super-admin/organisations`\n * - `/` (root) → `route:root` (fallback)\n *\n * The returned key is prefixed with `route:` so an auto-derived key can never\n * be mistaken for, or collide with, a hand-authored `\"<module>.<view>\"` gridKey\n * (which uses dots, not slashes). When stripping leaves nothing (e.g. a route\n * that is ONLY ids, or the bare root), it falls back to `route:root` rather than\n * an empty string, so the storage key stays well-formed.\n */\nexport function deriveGridKeyFromPath(pathname: string): string {\n const kept = pathname\n .split(\"/\")\n .filter((segment) => segment.length > 0 && !ID_ISH_SEGMENT.test(segment));\n return kept.length > 0 ? `route:${kept.join(\"/\")}` : \"route:root\";\n}\n\n/**\n * Strips the `pagination` slice from a grid state. Pagination on these grids is\n * server-owned and supplied as a controlled `paginationModel` via the pagination\n * helper; persisting/restoring it would clash with the controlled prop (MUI\n * warns when `initialState.pagination.paginationModel` is set alongside a\n * controlled model) and isn't part of the AB#4840 scope (visibility / sort /\n * filter / column widths). We keep everything else `exportState()` produces —\n * including `columns` (visibility + widths + order), `sorting`, and `filter`.\n */\nexport function stripGridPagination(state: GridInitialState): GridInitialState {\n const { pagination: _pagination, ...rest } = state;\n return rest;\n}\n\n/**\n * Reads a previously-persisted grid state from localStorage. SSR / throw-safe:\n * any failure (storage blocked, corrupted JSON, quota errors, no `localStorage`\n * during SSR) falls back to `undefined` so the grid renders with defaults rather\n * than crashing.\n */\nexport function readPersistedGridState(storageKey: string | null): GridInitialState | undefined {\n if (!storageKey) return undefined;\n if (typeof localStorage === \"undefined\") return undefined;\n try {\n const raw = localStorage.getItem(storageKey);\n if (!raw) return undefined;\n return stripGridPagination(JSON.parse(raw) as GridInitialState);\n } catch {\n return undefined;\n }\n}\n\n/**\n * Writes a grid state to localStorage under `storageKey` (the server-owned\n * pagination slice stripped first). SSR / throw-safe: storage blocked, quota\n * exceeded, or serialization failure are swallowed — a persistence failure must\n * never crash the grid. No-ops on a nullish key (no user) or nullish state\n * (apiRef not yet attached).\n */\nexport function writePersistedGridState(storageKey: string | null, state: GridInitialState | undefined): void {\n if (!storageKey || !state) return;\n if (typeof localStorage === \"undefined\") return;\n try {\n localStorage.setItem(storageKey, JSON.stringify(stripGridPagination(state)));\n } catch {\n // Storage blocked / quota exceeded / serialization failure — preferences\n // just won't persist this session. Never let a persistence failure crash\n // the grid.\n }\n}\n\n/**\n * Creates a debounced writer bound to a fresh timer. Each returned function\n * collapses a burst of `onStateChange` calls into a single localStorage write\n * after `debounceMs`. The caller owns one instance per grid (e.g. via a ref) so\n * timers don't leak across grids.\n */\nexport function createDebouncedGridWriter(debounceMs: number = DEFAULT_GRID_DEBOUNCE_MS) {\n let timer: ReturnType<typeof setTimeout> | null = null;\n const flushNow = (storageKey: string | null, state: GridInitialState | undefined) => {\n writePersistedGridState(storageKey, state);\n };\n const schedule = (storageKey: string | null, state: GridInitialState | undefined) => {\n if (!storageKey || !state) return;\n if (timer) clearTimeout(timer);\n timer = setTimeout(() => flushNow(storageKey, state), debounceMs);\n };\n const cancel = () => {\n if (timer) {\n clearTimeout(timer);\n timer = null;\n }\n };\n return { schedule, cancel };\n}\n","/**\n * PersistentDataGrid — a thin, drop-in wrapper around MUI `<DataGrid>` that\n * persists the user's column visibility, sort, filter, and column-width\n * preferences to localStorage, keyed per-user + per-grid (AB#4840).\n *\n * Adoption for ANY paginated grid is a near-one-line swap:\n *\n * ```tsx\n * // before\n * import { DataGrid } from \"@mui/x-data-grid\";\n * <DataGrid rows={rows} columns={cols} {...buildDataGridPaginationProps(pagination)} ... />\n *\n * // after\n * import { PersistentDataGrid } from \"../shared/PersistentDataGrid\";\n * <PersistentDataGrid gridKey=\"projects.all\" rows={rows} columns={cols} {...buildDataGridPaginationProps(pagination)} ... />\n * ```\n *\n * It composes with `buildDataGridPaginationProps` / `buildDataGridSlots`: those\n * prop bundles are spread straight through (`...rest`) untouched. The wrapper\n * owns the grid `apiRef` (created internally via `useGridApiRef()` so it can\n * read `exportState()`) and two props it MERGES rather than clobbers:\n *\n * - `initialState`: persisted state is layered OVER any caller default, so a\n * view can still seed default column visibility while the user's saved\n * preferences win on the keys they've touched.\n * - `onStateChange`: the caller's handler (if any) still fires; the wrapper\n * additionally schedules a debounced persist.\n *\n * The current user id comes from the HOST IDENTITY (`useCurrentUser`, which reads\n * the identity the host binds to every mounted plugin surface), falling back to the\n * stamped module ref (`gridUserContext.getGridUserId`) for an embedder that provides\n * no identity. Either way NO `userId` prop is threaded through views. When there is\n * no user at all (pre-auth / signed out / standalone harness) the storage key is\n * `null` and the wrapper degrades to a plain DataGrid — no reads, no writes.\n *\n * gogo-ui MAY import `@mui/x-data-grid` directly, so the apiRef + MUI types live\n * here, keeping the host `src/**` free of any `@mui/x-data-grid` import.\n *\n * ## Auto-key (no `gridKey` prop)\n *\n * `gridKey` is OPTIONAL. When omitted, the grid key is derived from the current\n * route via `window.location.pathname` and {@link deriveGridKeyFromPath} — id-ish\n * segments (all-numeric ids, UUIDs) are stripped so preferences are keyed per\n * grid TYPE, not per entity (e.g. `/projects/123/documents` and\n * `/projects/456/documents` share one key `route:projects/documents`). This makes\n * adoption a one-line IMPORT REDIRECT for the common single-grid-per-page case —\n * a view swaps its DataGrid import to the barrel and gets persistence for free.\n *\n * An explicit `gridKey` ALWAYS overrides the auto-key. Pass one for:\n * - multi-grid pages (2+ grids on one route would otherwise share a key), and\n * - long-lived grids whose key must stay stable independent of route changes\n * (e.g. the canonical list grids keep `\"projects.all\"` etc.).\n *\n * NOTE: the wrapper creates its OWN apiRef. A view that also needs direct apiRef\n * access for unrelated reasons should keep a plain `<DataGrid>` — that's rare;\n * persistence is the only thing 99% of grids want the apiRef for.\n */\nimport { useCallback, useEffect, useMemo, useRef } from \"react\";\n\nimport { DataGrid, useGridApiRef, type DataGridProps, type GridInitialState } from \"@mui/x-data-grid\";\n\nimport { withCenteredColumns } from \"./centeredColumns\";\nimport { useCurrentUser } from \"../../currentUser\";\nimport { getGridUserId } from \"./gridUserContext\";\nimport {\n buildGridStorageKey,\n createDebouncedGridWriter,\n deriveGridKeyFromPath,\n readPersistedGridState,\n DEFAULT_GRID_DEBOUNCE_MS,\n} from \"./gridStatePersistence\";\n\nexport interface PersistentDataGridProps extends Omit<DataGridProps, \"apiRef\"> {\n /**\n * Stable identifier for this grid, unique per logical grid across the app.\n * Used as the per-grid segment of the localStorage key. Convention:\n * `\"<module>.<view>\"` — e.g. `\"projects.all\"`, `\"legal.risks\"`,\n * `\"superAdmin.organisations\"`. Must not change between renders/sessions or\n * the saved preferences orphan.\n *\n * OPTIONAL: when omitted, an auto-key is derived from the current route (see\n * the file-level \"Auto-key\" note). Pass an explicit key for multi-grid pages\n * or to pin a key independent of the route. An explicit key always wins.\n */\n gridKey?: string;\n /** Debounce window (ms) for the persist write. Defaults to 400ms. */\n persistDebounceMs?: number;\n}\n\n/**\n * Shallow-merges a restored grid state OVER a caller-provided `initialState`.\n * Top-level slices (`columns`, `sorting`, `filter`, `pinnedColumns`, …) from the\n * restored state replace the caller's; slices the user never touched fall back\n * to the caller default. A deep merge isn't warranted — MUI's `exportState()`\n * emits a complete slice per touched feature, so slice-level replacement is the\n * correct granularity (and matches how `initialState` is consumed once on mount).\n */\nfunction mergeInitialState(\n callerInitialState: GridInitialState | undefined,\n restored: GridInitialState | undefined,\n): GridInitialState | undefined {\n if (!restored) return callerInitialState;\n if (!callerInitialState) return restored;\n return { ...callerInitialState, ...restored };\n}\n\nexport function PersistentDataGrid({\n gridKey,\n persistDebounceMs = DEFAULT_GRID_DEBOUNCE_MS,\n initialState: callerInitialState,\n onStateChange: callerOnStateChange,\n columns,\n ...rest\n}: PersistentDataGridProps) {\n // Own an apiRef so we can read exportState() on every state change.\n const apiRef = useGridApiRef();\n\n // Vertically center every cell's content (chips/icons/custom/dynamic-height rows\n // otherwise top-align) while keeping single-line text truncation. Memoised so the\n // grid doesn't remount cells each render. See `withCenteredColumns`.\n const centeredColumns = useMemo(() => withCenteredColumns(columns), [columns]);\n\n // Resolve the effective grid key: an explicit `gridKey` prop ALWAYS wins;\n // otherwise derive a stable per-grid-type key from the current route (id-ish\n // segments stripped). We read `window.location.pathname` directly rather than\n // `useLocation()` so this leaf wrapper needs no `<Router>` context — accurate at\n // render time (react-router drives navigation through the history API, which\n // updates `window.location`), and grid views remount on navigation so the path\n // is re-read on the next mount. Guarded for SSR / non-DOM environments (falls\n // back to \"\" → `route:root`).\n const pathname = typeof window !== \"undefined\" ? window.location.pathname : \"\";\n const effectiveGridKey = gridKey ?? deriveGridKeyFromPath(pathname);\n\n // Resolve the per-user storage key. The HOST IDENTITY is the primary source:\n // `useCurrentUser` reads the identity the host binds to every mounted surface, so a plugin\n // gets persistence with nothing to wire up. `getGridUserId()` is the fallback, for an\n // embedder that stamps the ref instead of providing an identity (the monolith's arrangement).\n //\n // That order matters, and it is the fix for a real defect: this component previously read the\n // ref ONLY, and no plugin ever stamped it - the `useGridUserSync` hook its docs named was a\n // host-app hook that was not carried over on the lift. So the key was permanently null and\n // every plugin grid silently persisted nothing. Reading the identity means it cannot be\n // forgotten again, because there is nothing left to forget.\n //\n // `userId` is in the dependency list, unlike the bare `getGridUserId()` call it replaces: the\n // identity resolves ASYNCHRONOUSLY (`isLoading` while the host's /auth/user is in flight), so a\n // mount-time-only read would miss it and fall back to no persistence on every first paint.\n const userId = useCurrentUser()?.id ?? getGridUserId();\n const storageKey = useMemo(() => buildGridStorageKey(userId, effectiveGridKey), [userId, effectiveGridKey]);\n const restoredState = useMemo(() => readPersistedGridState(storageKey), [storageKey]);\n const effectiveInitialState = useMemo(\n () => mergeInitialState(callerInitialState, restoredState),\n [callerInitialState, restoredState],\n );\n\n // Apply the persisted state when the key changes AFTER mount, which is what the async identity\n // actually produces: the grid mounts pre-auth with no user, then the host resolves one.\n //\n // Recomputing `storageKey` and `restoredState` is not enough on its own, because MUI consumes\n // `initialState` on the FIRST RENDER ONLY — so the correct state would be read and then have\n // nowhere to go. `restoreState` is the documented route for injecting it later (\"These values can\n // then be passed to the `initialState` prop or injected using the `restoreState` method\").\n //\n // `appliedKeyRef` starts at the mount-time key, so the mount case stays with `initialState` and is\n // never restored twice. Only a genuine key CHANGE reaches the api call — in practice once per\n // session, when auth resolves — so this cannot clobber preferences a user is actively changing.\n const appliedKeyRef = useRef<string | null>(storageKey);\n useEffect(() => {\n if (storageKey === appliedKeyRef.current) {\n return;\n }\n appliedKeyRef.current = storageKey;\n if (storageKey && restoredState) {\n apiRef.current?.restoreState(restoredState);\n }\n }, [storageKey, restoredState, apiRef]);\n\n // One debounced writer per grid instance, kept stable across renders.\n const writerRef = useRef<ReturnType<typeof createDebouncedGridWriter> | null>(null);\n if (writerRef.current === null) {\n writerRef.current = createDebouncedGridWriter(persistDebounceMs);\n }\n\n // Signature-agnostic forwarder: `onStateChange`'s exact param tuple varies by\n // MUI x-data-grid minor, so we accept the args as a rest tuple typed off the\n // prop itself and forward them verbatim. This stays assignable to\n // `DataGridProps[\"onStateChange\"]` regardless of the concrete arity.\n type StateChangeArgs = Parameters<NonNullable<DataGridProps[\"onStateChange\"]>>;\n const handleStateChange = useCallback(\n (...args: StateChangeArgs) => {\n callerOnStateChange?.(...args);\n // exportState() returns the full persistable snapshot; the writer strips\n // the server-owned pagination slice before persisting.\n if (storageKey) {\n writerRef.current?.schedule(storageKey, apiRef.current?.exportState());\n }\n },\n [callerOnStateChange, storageKey, apiRef],\n );\n\n return (\n <DataGrid\n {...rest}\n columns={centeredColumns}\n apiRef={apiRef}\n initialState={effectiveInitialState}\n onStateChange={handleStateChange}\n />\n );\n}\n","/**\n * Canonical page-size options and default, promoted out of the plugins.\n *\n * A `constants/pagination.ts` appeared in three of the five plugin repos measured on\n * 2026-08-20, each declaring its own option list. Four copies of a list of page sizes is four\n * answers to \"what page sizes does this product offer\", and the divergence shows up as a grid\n * that offers 25 rows next to one that offers 20.\n */\n\n/** Page sizes a data grid offers. */\nexport const PAGE_SIZE_OPTIONS = [5, 10, 25, 50] as const;\n\n/** Page size a list starts on. */\nexport const DEFAULT_PAGE_SIZE = 10;\n","import type { GridPaginationModel } from \"@mui/x-data-grid\";\nimport { PAGE_SIZE_OPTIONS } from \"./pagination\";\n\n/**\n * Pagination object accepted by {@link buildDataGridPaginationProps}. Aligns with\n * `ContractPagination` in `@coreconnect/contracts`: 1-based page numbers with separate\n * page-change and page-size-change callbacks. The `STATIC_PAGINATION` placeholder used by\n * client-side-paginated views is also accepted (it omits `onPageSizeChange` — the helper\n * detects the omission and skips the size-change branch).\n */\nexport interface DataGridPaginationInput {\n page: number;\n pageSize: number;\n total: number;\n onPageChange: (page: number) => void;\n onPageSizeChange?: (pageSize: number) => void;\n}\n\n/**\n * Bundle of MUI DataGrid pagination props. Spread into a `<DataGrid {...}>` to wire\n * server-paginated lists in one line and avoid the per-view `onPaginationModelChange`\n * boilerplate.\n */\nexport interface DataGridPaginationProps {\n pagination: true;\n paginationMode: \"server\";\n rowCount: number;\n paginationModel: { page: number; pageSize: number };\n onPaginationModelChange: (model: GridPaginationModel) => void;\n pageSizeOptions: readonly number[];\n}\n\n/**\n * Builds the canonical pagination prop bundle for a server-paginated MUI DataGrid.\n *\n * The hand-written equivalent across the codebase looked like:\n *\n * ```tsx\n * paginationMode=\"server\"\n * rowCount={pagination.total}\n * paginationModel={{ page: pagination.page - 1, pageSize: pagination.pageSize }}\n * onPaginationModelChange={(model) => {\n * pagination.onPageChange(model.page + 1);\n * if (\"onPageSizeChange\" in pagination && pagination.onPageSizeChange) {\n * pagination.onPageSizeChange(model.pageSize);\n * }\n * }}\n * pageSizeOptions={[...PAGE_SIZE_OPTIONS]}\n * ```\n *\n * The naive form had a latent bug: changing page size called BOTH callbacks, so the\n * size-change handler (which typically resets page to 1) cancelled out subsequent\n * page-N navigation, pinning the user to page 1. This helper distinguishes the two\n * cases and only fires the relevant callback.\n *\n * Usage:\n *\n * ```tsx\n * <DataGrid\n * {...buildDataGridPaginationProps(effectivePagination)}\n * rows={items}\n * columns={columns}\n * ...\n * />\n * ```\n */\nexport function buildDataGridPaginationProps(\n pagination: DataGridPaginationInput,\n): DataGridPaginationProps {\n return {\n pagination: true,\n paginationMode: \"server\",\n rowCount: pagination.total,\n paginationModel: {\n page: pagination.page - 1,\n pageSize: pagination.pageSize,\n },\n onPaginationModelChange: (model) => {\n // Distinguish page-only navigation from page-size change. The naive\n // \"always call both callbacks\" approach pinned the grid to page 1 forever:\n // a page-size change would call onPageSizeChange (which resets page to 1)\n // AND onPageChange(model.page + 1), and on the next page navigation\n // onPageSizeChange would fire again from the updated model.pageSize.\n const pageSizeChanged = model.pageSize !== pagination.pageSize;\n if (pageSizeChanged) {\n if (pagination.onPageSizeChange) {\n pagination.onPageSizeChange(model.pageSize);\n }\n return;\n }\n pagination.onPageChange(model.page + 1);\n },\n pageSizeOptions: [...PAGE_SIZE_OPTIONS],\n };\n}\n"]}
@@ -1,5 +1,5 @@
1
1
  import * as react from 'react';
2
- import { DataGridProps, GridPaginationModel, GridInitialState } from '@mui/x-data-grid';
2
+ import { DataGridProps, GridValidRowModel, GridColDef, GridPaginationModel, GridInitialState } from '@mui/x-data-grid';
3
3
 
4
4
  interface PersistentDataGridProps extends Omit<DataGridProps, "apiRef"> {
5
5
  /**
@@ -17,7 +17,33 @@ interface PersistentDataGridProps extends Omit<DataGridProps, "apiRef"> {
17
17
  /** Debounce window (ms) for the persist write. Defaults to 400ms. */
18
18
  persistDebounceMs?: number;
19
19
  }
20
- declare function PersistentDataGrid({ gridKey, persistDebounceMs, initialState: callerInitialState, onStateChange: callerOnStateChange, ...rest }: PersistentDataGridProps): react.JSX.Element;
20
+ declare function PersistentDataGrid({ gridKey, persistDebounceMs, initialState: callerInitialState, onStateChange: callerOnStateChange, columns, ...rest }: PersistentDataGridProps): react.JSX.Element;
21
+
22
+ /**
23
+ * Vertically-center every cell's content while keeping single-line text truncation.
24
+ *
25
+ * WHY. A Data Grid cell's default column `display` is `'text'` (block): single-line
26
+ * text is centred by line-height and truncates with an ellipsis, but ANYTHING else —
27
+ * chips, icons, a custom `renderCell`, or any content on a dynamic-height row — sits
28
+ * at the TOP of a taller row. MUI's own fix is per-column `display: 'flex'`, which
29
+ * centres arbitrary content but drops the built-in ellipsis, because the default
30
+ * value is a raw text node with no element to apply `text-overflow` to. This helper
31
+ * applies both halves together, for every column, so a grid gets consistent vertical
32
+ * centring WITHOUT losing text truncation:
33
+ *
34
+ * 1. `display: 'flex'` on every column that doesn't already declare one — centres
35
+ * all content types, on fixed- and dynamic-height rows alike.
36
+ * 2. an ellipsis-wrapper `renderCell` on renderer-less text columns — re-adds the
37
+ * "…" truncation that step 1 would otherwise remove. Columns with their own
38
+ * `renderCell`, and non-text types, are left untouched: they own their content
39
+ * and already lay it out centred.
40
+ *
41
+ * Pure and idempotent: a column that already has a `display` or a `renderCell` is
42
+ * passed through unchanged, so re-running over an already-processed list is a no-op.
43
+ * The input array and its column objects are not mutated. Memoise the result at the
44
+ * call site so cells don't remount on every render.
45
+ */
46
+ declare function withCenteredColumns<R extends GridValidRowModel = GridValidRowModel>(columns: readonly GridColDef<R>[]): GridColDef<R>[];
21
47
 
22
48
  /**
23
49
  * Module-level mirror of the current signed-in user's id — the FALLBACK source for the grid
@@ -245,4 +271,4 @@ declare function createDebouncedGridWriter(debounceMs?: number): {
245
271
  cancel: () => void;
246
272
  };
247
273
 
248
- export { DEFAULT_GRID_DEBOUNCE_MS, DEFAULT_PAGE_SIZE, type DataGridPaginationInput, type DataGridPaginationProps, GRID_STATE_KEY_PREFIX, GRID_STATE_VERSION, PAGE_SIZE_OPTIONS, PersistentDataGrid, type PersistentDataGridProps, buildDataGridPaginationProps, buildGridStorageKey, createDebouncedGridWriter, deriveGridKeyFromPath, getGridUserId, readPersistedGridState, setGridUserId, stripGridPagination, writePersistedGridState };
274
+ export { DEFAULT_GRID_DEBOUNCE_MS, DEFAULT_PAGE_SIZE, type DataGridPaginationInput, type DataGridPaginationProps, GRID_STATE_KEY_PREFIX, GRID_STATE_VERSION, PAGE_SIZE_OPTIONS, PersistentDataGrid, type PersistentDataGridProps, buildDataGridPaginationProps, buildGridStorageKey, createDebouncedGridWriter, deriveGridKeyFromPath, getGridUserId, readPersistedGridState, setGridUserId, stripGridPagination, withCenteredColumns, writePersistedGridState };
@@ -1,5 +1,5 @@
1
1
  import * as react from 'react';
2
- import { DataGridProps, GridPaginationModel, GridInitialState } from '@mui/x-data-grid';
2
+ import { DataGridProps, GridValidRowModel, GridColDef, GridPaginationModel, GridInitialState } from '@mui/x-data-grid';
3
3
 
4
4
  interface PersistentDataGridProps extends Omit<DataGridProps, "apiRef"> {
5
5
  /**
@@ -17,7 +17,33 @@ interface PersistentDataGridProps extends Omit<DataGridProps, "apiRef"> {
17
17
  /** Debounce window (ms) for the persist write. Defaults to 400ms. */
18
18
  persistDebounceMs?: number;
19
19
  }
20
- declare function PersistentDataGrid({ gridKey, persistDebounceMs, initialState: callerInitialState, onStateChange: callerOnStateChange, ...rest }: PersistentDataGridProps): react.JSX.Element;
20
+ declare function PersistentDataGrid({ gridKey, persistDebounceMs, initialState: callerInitialState, onStateChange: callerOnStateChange, columns, ...rest }: PersistentDataGridProps): react.JSX.Element;
21
+
22
+ /**
23
+ * Vertically-center every cell's content while keeping single-line text truncation.
24
+ *
25
+ * WHY. A Data Grid cell's default column `display` is `'text'` (block): single-line
26
+ * text is centred by line-height and truncates with an ellipsis, but ANYTHING else —
27
+ * chips, icons, a custom `renderCell`, or any content on a dynamic-height row — sits
28
+ * at the TOP of a taller row. MUI's own fix is per-column `display: 'flex'`, which
29
+ * centres arbitrary content but drops the built-in ellipsis, because the default
30
+ * value is a raw text node with no element to apply `text-overflow` to. This helper
31
+ * applies both halves together, for every column, so a grid gets consistent vertical
32
+ * centring WITHOUT losing text truncation:
33
+ *
34
+ * 1. `display: 'flex'` on every column that doesn't already declare one — centres
35
+ * all content types, on fixed- and dynamic-height rows alike.
36
+ * 2. an ellipsis-wrapper `renderCell` on renderer-less text columns — re-adds the
37
+ * "…" truncation that step 1 would otherwise remove. Columns with their own
38
+ * `renderCell`, and non-text types, are left untouched: they own their content
39
+ * and already lay it out centred.
40
+ *
41
+ * Pure and idempotent: a column that already has a `display` or a `renderCell` is
42
+ * passed through unchanged, so re-running over an already-processed list is a no-op.
43
+ * The input array and its column objects are not mutated. Memoise the result at the
44
+ * call site so cells don't remount on every render.
45
+ */
46
+ declare function withCenteredColumns<R extends GridValidRowModel = GridValidRowModel>(columns: readonly GridColDef<R>[]): GridColDef<R>[];
21
47
 
22
48
  /**
23
49
  * Module-level mirror of the current signed-in user's id — the FALLBACK source for the grid
@@ -245,4 +271,4 @@ declare function createDebouncedGridWriter(debounceMs?: number): {
245
271
  cancel: () => void;
246
272
  };
247
273
 
248
- export { DEFAULT_GRID_DEBOUNCE_MS, DEFAULT_PAGE_SIZE, type DataGridPaginationInput, type DataGridPaginationProps, GRID_STATE_KEY_PREFIX, GRID_STATE_VERSION, PAGE_SIZE_OPTIONS, PersistentDataGrid, type PersistentDataGridProps, buildDataGridPaginationProps, buildGridStorageKey, createDebouncedGridWriter, deriveGridKeyFromPath, getGridUserId, readPersistedGridState, setGridUserId, stripGridPagination, writePersistedGridState };
274
+ export { DEFAULT_GRID_DEBOUNCE_MS, DEFAULT_PAGE_SIZE, type DataGridPaginationInput, type DataGridPaginationProps, GRID_STATE_KEY_PREFIX, GRID_STATE_VERSION, PAGE_SIZE_OPTIONS, PersistentDataGrid, type PersistentDataGridProps, buildDataGridPaginationProps, buildGridStorageKey, createDebouncedGridWriter, deriveGridKeyFromPath, getGridUserId, readPersistedGridState, setGridUserId, stripGridPagination, withCenteredColumns, writePersistedGridState };
@@ -1,9 +1,37 @@
1
1
  import { useMemo, useRef, useEffect, useCallback } from 'react';
2
2
  import { useGridApiRef, DataGrid } from '@mui/x-data-grid';
3
- import { useHostIdentity } from '@ethisyscore/extension-runtime/plugin';
4
3
  import { jsx } from 'react/jsx-runtime';
4
+ import { useHostIdentity } from '@ethisyscore/extension-runtime/plugin';
5
5
 
6
6
  // src/components/data-grid/PersistentDataGrid.tsx
7
+ var TEXT_COLUMN_TYPES = /* @__PURE__ */ new Set([
8
+ "string",
9
+ "number",
10
+ "date",
11
+ "dateTime",
12
+ "singleSelect"
13
+ ]);
14
+ var ELLIPSIS_STYLE = {
15
+ minWidth: 0,
16
+ overflow: "hidden",
17
+ textOverflow: "ellipsis",
18
+ whiteSpace: "nowrap"
19
+ };
20
+ function EllipsisCell(params) {
21
+ return /* @__PURE__ */ jsx("div", { style: ELLIPSIS_STYLE, children: params.formattedValue });
22
+ }
23
+ function withCenteredColumns(columns) {
24
+ return columns.map((column) => {
25
+ const next = { ...column };
26
+ if (next.display == null) {
27
+ next.display = "flex";
28
+ }
29
+ if (!column.renderCell && TEXT_COLUMN_TYPES.has(column.type ?? "string")) {
30
+ next.renderCell = EllipsisCell;
31
+ }
32
+ return next;
33
+ });
34
+ }
7
35
  function useCurrentUser() {
8
36
  const identity = useHostIdentity();
9
37
  const user = identity?.user ?? null;
@@ -91,9 +119,11 @@ function PersistentDataGrid({
91
119
  persistDebounceMs = DEFAULT_GRID_DEBOUNCE_MS,
92
120
  initialState: callerInitialState,
93
121
  onStateChange: callerOnStateChange,
122
+ columns,
94
123
  ...rest
95
124
  }) {
96
125
  const apiRef = useGridApiRef();
126
+ const centeredColumns = useMemo(() => withCenteredColumns(columns), [columns]);
97
127
  const pathname = typeof window !== "undefined" ? window.location.pathname : "";
98
128
  const effectiveGridKey = gridKey ?? deriveGridKeyFromPath(pathname);
99
129
  const userId = useCurrentUser()?.id ?? getGridUserId();
@@ -130,6 +160,7 @@ function PersistentDataGrid({
130
160
  DataGrid,
131
161
  {
132
162
  ...rest,
163
+ columns: centeredColumns,
133
164
  apiRef,
134
165
  initialState: effectiveInitialState,
135
166
  onStateChange: handleStateChange
@@ -165,6 +196,6 @@ function buildDataGridPaginationProps(pagination) {
165
196
  };
166
197
  }
167
198
 
168
- export { DEFAULT_GRID_DEBOUNCE_MS, DEFAULT_PAGE_SIZE, GRID_STATE_KEY_PREFIX, GRID_STATE_VERSION, PAGE_SIZE_OPTIONS, PersistentDataGrid, buildDataGridPaginationProps, buildGridStorageKey, createDebouncedGridWriter, deriveGridKeyFromPath, getGridUserId, readPersistedGridState, setGridUserId, stripGridPagination, writePersistedGridState };
199
+ export { DEFAULT_GRID_DEBOUNCE_MS, DEFAULT_PAGE_SIZE, GRID_STATE_KEY_PREFIX, GRID_STATE_VERSION, PAGE_SIZE_OPTIONS, PersistentDataGrid, buildDataGridPaginationProps, buildGridStorageKey, createDebouncedGridWriter, deriveGridKeyFromPath, getGridUserId, readPersistedGridState, setGridUserId, stripGridPagination, withCenteredColumns, writePersistedGridState };
169
200
  //# sourceMappingURL=index.js.map
170
201
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/currentUser.ts","../../../src/components/data-grid/gridUserContext.ts","../../../src/components/data-grid/gridStatePersistence.ts","../../../src/components/data-grid/PersistentDataGrid.tsx","../../../src/components/data-grid/pagination.ts","../../../src/components/data-grid/dataGridPagination.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * `useCurrentUser` — the display-only current-user hook for plugin frontends\n * (WI 5154 follow-up #2, plugin-ui host-identity).\n *\n * A plugin page often needs the signed-in user for UX bits — a \"you are signed\n * in as …\" hint, pre-selecting the caller in a sign-off panel, or a\n * \"my acknowledgements\" heading. This hook surfaces that identity from the\n * host without the plugin importing the lower-level {@link useHostIdentity}\n * seam directly.\n *\n * It is a thin adapter over the host-provided {@link HostIdentity} context\n * (bound by the host through `ExtensionRuntimeProvider`'s `identity` prop):\n * it projects the host's richer {@link HostIdentityUser} down to the minimal\n * `{ id, displayName }` shape a plugin page needs for display.\n *\n * ⚠️ SECURITY — DISPLAY ONLY. This is presentation/UX data, NOT an\n * authorization source. Authorization is enforced HOST-SIDE at the MCP\n * boundary: the plugin backend re-authorises every tool/resource call from the\n * trusted server session. Never gate a mutation or a data read on this value.\n *\n * Returns `null` when the host has not provided an identity — standalone / mock\n * hosts, a host that predates the identity seam, or while host auth is still\n * loading or the caller is unauthenticated. Callers MUST handle `null`\n * (this matches the pre-existing plugin `useAuth().user` shim, which returned\n * `undefined`, so adopting this hook is a no-regression change).\n */\nimport { useHostIdentity } from \"@ethisyscore/extension-runtime/plugin\";\n\n/**\n * Minimal current-user shape a plugin page reads for display. Mirrors the\n * identity the host authenticates: a stable `id` and a human-readable\n * `displayName`. (No email — the host identity seam does not currently\n * forward one; if that changes, extend {@link HostIdentityUser} first and\n * project it here.)\n */\nexport interface CurrentUser {\n /** Stable user id (matches the host's authenticated caller id). */\n id: string;\n /** Human-readable display name (the host's full name). */\n displayName: string;\n}\n\n/**\n * Returns the host-authenticated current user projected to the display-only\n * {@link CurrentUser} shape, or `null` when no host identity is available.\n *\n * Display-only — see the module doc: authorization stays host-enforced at the\n * MCP boundary. Do NOT use the returned value as an authorization decision.\n */\nexport function useCurrentUser(): CurrentUser | null {\n const identity = useHostIdentity();\n const user = identity?.user ?? null;\n if (!user) {\n return null;\n }\n return {\n id: user.id,\n // The host binds `fullName`; fall back to composing first + last so a host\n // that only sets the parts still yields a usable display name.\n displayName:\n user.fullName?.trim() || `${user.firstName ?? \"\"} ${user.lastName ?? \"\"}`.trim(),\n };\n}\n","/**\n * Module-level mirror of the current signed-in user's id — the FALLBACK source for the grid\n * persistence key.\n *\n * ## Read this before using it\n *\n * For plugin surfaces you do NOT need to stamp anything. {@link PersistentDataGrid} reads the host\n * identity via `useCurrentUser` and only consults this ref when no identity is present. This exists\n * for an embedder that has no host identity to provide — the monolith arrangement, where a host-app\n * hook stamped the ref from its own auth context.\n *\n * ## Why it is the fallback and not the primary\n *\n * It used to be the only source, and that shipped a silent defect: the doc below described a\n * `useGridUserSync` hook in the host app, which was never carried over when these components were\n * lifted into plugin-ui. Measured across all eleven plugin repos on 2026-08-26, `setGridUserId` had\n * ZERO call sites — only its own declaration and this comment. So `getGridUserId()` always returned\n * `null`, `buildGridStorageKey` always returned `null`, and 17 grids ran a persistence wrapper that\n * read nothing and wrote nothing. It failed invisibly, because a grid with no saved preferences\n * looks exactly like a grid whose preferences were never saved.\n *\n * The lesson is in the ordering, not the code: a seam that requires the CONSUMER to remember a wiring\n * step will eventually meet a consumer who does not. Reading the identity the host already binds\n * removes the step.\n *\n * ## Why a module-level ref rather than a context\n *\n * The persistence layer needs the id synchronously when a grid mounts, to build the localStorage\n * key — not as a render-time prop, and never plumbed through every view.\n *\n * When `null` (pre-auth / signed out / standalone harness) persistence is simply off: the key builder\n * returns `null`, reads fall back to grid defaults, and writes are ignored.\n */\n\nlet gridUserIdRef: string | null = null;\n\n/** Returns the current user's id, or `null` while auth is unresolved / signed out. */\nexport function getGridUserId(): string | null {\n return gridUserIdRef;\n}\n\n/**\n * Stamps the module-level user-id ref.\n *\n * Plugin surfaces do not need this — {@link PersistentDataGrid} prefers the host identity, so\n * calling it is optional and stamping is not part of adopting the grid. Use it only in an embedder\n * with no host identity, each time its authenticated user resolves or changes. Pass `null` to\n * disable persistence.\n */\nexport function setGridUserId(userId: string | null): void {\n gridUserIdRef = userId;\n}\n","/**\n * Pure, gogo-ui-safe persistence logic for MUI DataGrid state (column\n * visibility, sort, filter, and column widths). This is the single source of\n * truth for the key shape, the localStorage read/write, the debounce window,\n * and the pagination-slice stripping that the AB#4840 column-visibility feature\n * relies on.\n *\n * It lives in gogo-ui (which MAY import `@mui/x-data-grid` types directly) so\n * the reusable {@link PersistentDataGrid} wrapper can own the apiRef and MUI\n * types in one place — the host app's `src/**` may NOT import `@mui/x-data-grid`\n * (values or types), so this logic cannot live in the host hook.\n *\n * Auth-agnostic: the user id comes from the host-stamped module ref in\n * `gridUserContextValue.ts` at the call sites (the wrapper), not from this\n * module — keeping these functions pure and unit-testable.\n *\n * No React, no MUI values — only the `GridInitialState` TYPE import, so this\n * module is a plain value module (`.ts`) free of `react-refresh` concerns.\n */\nimport type { GridInitialState } from \"@mui/x-data-grid\";\n\n/**\n * Schema version for the persisted grid-state payload. Bump this when the shape\n * of what we store changes incompatibly so stale entries from an older app\n * version are ignored (the key changes, the old key is simply orphaned) rather\n * than restored into a grid that can no longer interpret them.\n */\nexport const GRID_STATE_VERSION = \"v1\";\nexport const GRID_STATE_KEY_PREFIX = \"cc-grid-state\";\n\n/**\n * Default debounce window for persisting grid state. `onStateChange` fires very\n * frequently (every hover, focus, resize tick) — debouncing collapses a burst\n * of changes into a single localStorage write.\n */\nexport const DEFAULT_GRID_DEBOUNCE_MS = 400;\n\n/**\n * Builds the per-user, per-grid localStorage key. Keying on the user id keeps a\n * shared browser's saved preferences separate per signed-in user; keying on\n * `gridKey` keeps each grid's preferences independent. Returns `null` when there\n * is no user id yet (pre-auth / signed out) — callers then behave as a no-op\n * persistence layer and the grid renders with its built-in defaults.\n */\nexport function buildGridStorageKey(userId: string | null | undefined, gridKey: string): string | null {\n if (!userId) return null;\n return `${GRID_STATE_KEY_PREFIX}:${GRID_STATE_VERSION}:${userId}:${gridKey}`;\n}\n\n/**\n * Matches a single path segment that is \"id-ish\" — an entity identifier rather\n * than a stable grid-type segment — so {@link deriveGridKeyFromPath} can strip it\n * and key preferences per grid TYPE instead of per entity. A segment is dropped\n * when it is either:\n *\n * - **all-numeric** — `123`, `0042` (numeric DB ids / sequence numbers); or\n * - **a UUID** — canonical 8-4-4-4-12 hex form, case-insensitive\n * (e.g. `3fa85f64-5717-4562-b3fc-2c963f66afa6`).\n *\n * Deliberately NARROW: only these two shapes are treated as ids. Slugs, ULIDs,\n * mixed alphanumerics, and ordinary words (`new`, `all`, `documents`,\n * `position-history`) are KEPT, so two genuinely different grid pages never\n * collide on the same key. The trade-off — a non-numeric, non-UUID id (e.g. a\n * short code or a base-62 id) is not stripped, so its grid keys per entity — is\n * acceptable: such routes are rare, and an explicit `gridKey` prop overrides the\n * auto-key whenever per-entity keying is wrong (see {@link PersistentDataGrid}).\n */\nconst ID_ISH_SEGMENT = /^(?:\\d+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;\n\n/**\n * Derives a STABLE, per-grid-TYPE key from a route pathname, used by\n * {@link PersistentDataGrid} when no explicit `gridKey` prop is supplied.\n *\n * Stripping rule (so prefs are per-grid-type, not per-entity instance):\n * 1. Split the pathname on `/` and drop empty segments (leading/trailing/double\n * slashes, and a bare `/` root).\n * 2. Drop every \"id-ish\" segment per {@link ID_ISH_SEGMENT} — all-numeric ids and\n * canonical UUIDs.\n * 3. Join the survivors with `/`.\n *\n * Examples:\n * - `/projects/123/documents` → `projects/documents`\n * - `/hr/employees/3fa85f64-…-66afa6` → `hr/employees`\n * - `/super-admin/organisations` → `super-admin/organisations`\n * - `/` (root) → `route:root` (fallback)\n *\n * The returned key is prefixed with `route:` so an auto-derived key can never\n * be mistaken for, or collide with, a hand-authored `\"<module>.<view>\"` gridKey\n * (which uses dots, not slashes). When stripping leaves nothing (e.g. a route\n * that is ONLY ids, or the bare root), it falls back to `route:root` rather than\n * an empty string, so the storage key stays well-formed.\n */\nexport function deriveGridKeyFromPath(pathname: string): string {\n const kept = pathname\n .split(\"/\")\n .filter((segment) => segment.length > 0 && !ID_ISH_SEGMENT.test(segment));\n return kept.length > 0 ? `route:${kept.join(\"/\")}` : \"route:root\";\n}\n\n/**\n * Strips the `pagination` slice from a grid state. Pagination on these grids is\n * server-owned and supplied as a controlled `paginationModel` via the pagination\n * helper; persisting/restoring it would clash with the controlled prop (MUI\n * warns when `initialState.pagination.paginationModel` is set alongside a\n * controlled model) and isn't part of the AB#4840 scope (visibility / sort /\n * filter / column widths). We keep everything else `exportState()` produces —\n * including `columns` (visibility + widths + order), `sorting`, and `filter`.\n */\nexport function stripGridPagination(state: GridInitialState): GridInitialState {\n const { pagination: _pagination, ...rest } = state;\n return rest;\n}\n\n/**\n * Reads a previously-persisted grid state from localStorage. SSR / throw-safe:\n * any failure (storage blocked, corrupted JSON, quota errors, no `localStorage`\n * during SSR) falls back to `undefined` so the grid renders with defaults rather\n * than crashing.\n */\nexport function readPersistedGridState(storageKey: string | null): GridInitialState | undefined {\n if (!storageKey) return undefined;\n if (typeof localStorage === \"undefined\") return undefined;\n try {\n const raw = localStorage.getItem(storageKey);\n if (!raw) return undefined;\n return stripGridPagination(JSON.parse(raw) as GridInitialState);\n } catch {\n return undefined;\n }\n}\n\n/**\n * Writes a grid state to localStorage under `storageKey` (the server-owned\n * pagination slice stripped first). SSR / throw-safe: storage blocked, quota\n * exceeded, or serialization failure are swallowed — a persistence failure must\n * never crash the grid. No-ops on a nullish key (no user) or nullish state\n * (apiRef not yet attached).\n */\nexport function writePersistedGridState(storageKey: string | null, state: GridInitialState | undefined): void {\n if (!storageKey || !state) return;\n if (typeof localStorage === \"undefined\") return;\n try {\n localStorage.setItem(storageKey, JSON.stringify(stripGridPagination(state)));\n } catch {\n // Storage blocked / quota exceeded / serialization failure — preferences\n // just won't persist this session. Never let a persistence failure crash\n // the grid.\n }\n}\n\n/**\n * Creates a debounced writer bound to a fresh timer. Each returned function\n * collapses a burst of `onStateChange` calls into a single localStorage write\n * after `debounceMs`. The caller owns one instance per grid (e.g. via a ref) so\n * timers don't leak across grids.\n */\nexport function createDebouncedGridWriter(debounceMs: number = DEFAULT_GRID_DEBOUNCE_MS) {\n let timer: ReturnType<typeof setTimeout> | null = null;\n const flushNow = (storageKey: string | null, state: GridInitialState | undefined) => {\n writePersistedGridState(storageKey, state);\n };\n const schedule = (storageKey: string | null, state: GridInitialState | undefined) => {\n if (!storageKey || !state) return;\n if (timer) clearTimeout(timer);\n timer = setTimeout(() => flushNow(storageKey, state), debounceMs);\n };\n const cancel = () => {\n if (timer) {\n clearTimeout(timer);\n timer = null;\n }\n };\n return { schedule, cancel };\n}\n","/**\n * PersistentDataGrid — a thin, drop-in wrapper around MUI `<DataGrid>` that\n * persists the user's column visibility, sort, filter, and column-width\n * preferences to localStorage, keyed per-user + per-grid (AB#4840).\n *\n * Adoption for ANY paginated grid is a near-one-line swap:\n *\n * ```tsx\n * // before\n * import { DataGrid } from \"@mui/x-data-grid\";\n * <DataGrid rows={rows} columns={cols} {...buildDataGridPaginationProps(pagination)} ... />\n *\n * // after\n * import { PersistentDataGrid } from \"../shared/PersistentDataGrid\";\n * <PersistentDataGrid gridKey=\"projects.all\" rows={rows} columns={cols} {...buildDataGridPaginationProps(pagination)} ... />\n * ```\n *\n * It composes with `buildDataGridPaginationProps` / `buildDataGridSlots`: those\n * prop bundles are spread straight through (`...rest`) untouched. The wrapper\n * owns the grid `apiRef` (created internally via `useGridApiRef()` so it can\n * read `exportState()`) and two props it MERGES rather than clobbers:\n *\n * - `initialState`: persisted state is layered OVER any caller default, so a\n * view can still seed default column visibility while the user's saved\n * preferences win on the keys they've touched.\n * - `onStateChange`: the caller's handler (if any) still fires; the wrapper\n * additionally schedules a debounced persist.\n *\n * The current user id comes from the HOST IDENTITY (`useCurrentUser`, which reads\n * the identity the host binds to every mounted plugin surface), falling back to the\n * stamped module ref (`gridUserContext.getGridUserId`) for an embedder that provides\n * no identity. Either way NO `userId` prop is threaded through views. When there is\n * no user at all (pre-auth / signed out / standalone harness) the storage key is\n * `null` and the wrapper degrades to a plain DataGrid — no reads, no writes.\n *\n * gogo-ui MAY import `@mui/x-data-grid` directly, so the apiRef + MUI types live\n * here, keeping the host `src/**` free of any `@mui/x-data-grid` import.\n *\n * ## Auto-key (no `gridKey` prop)\n *\n * `gridKey` is OPTIONAL. When omitted, the grid key is derived from the current\n * route via `window.location.pathname` and {@link deriveGridKeyFromPath} — id-ish\n * segments (all-numeric ids, UUIDs) are stripped so preferences are keyed per\n * grid TYPE, not per entity (e.g. `/projects/123/documents` and\n * `/projects/456/documents` share one key `route:projects/documents`). This makes\n * adoption a one-line IMPORT REDIRECT for the common single-grid-per-page case —\n * a view swaps its DataGrid import to the barrel and gets persistence for free.\n *\n * An explicit `gridKey` ALWAYS overrides the auto-key. Pass one for:\n * - multi-grid pages (2+ grids on one route would otherwise share a key), and\n * - long-lived grids whose key must stay stable independent of route changes\n * (e.g. the canonical list grids keep `\"projects.all\"` etc.).\n *\n * NOTE: the wrapper creates its OWN apiRef. A view that also needs direct apiRef\n * access for unrelated reasons should keep a plain `<DataGrid>` — that's rare;\n * persistence is the only thing 99% of grids want the apiRef for.\n */\nimport { useCallback, useEffect, useMemo, useRef } from \"react\";\n\nimport { DataGrid, useGridApiRef, type DataGridProps, type GridInitialState } from \"@mui/x-data-grid\";\n\nimport { useCurrentUser } from \"../../currentUser\";\nimport { getGridUserId } from \"./gridUserContext\";\nimport {\n buildGridStorageKey,\n createDebouncedGridWriter,\n deriveGridKeyFromPath,\n readPersistedGridState,\n DEFAULT_GRID_DEBOUNCE_MS,\n} from \"./gridStatePersistence\";\n\nexport interface PersistentDataGridProps extends Omit<DataGridProps, \"apiRef\"> {\n /**\n * Stable identifier for this grid, unique per logical grid across the app.\n * Used as the per-grid segment of the localStorage key. Convention:\n * `\"<module>.<view>\"` — e.g. `\"projects.all\"`, `\"legal.risks\"`,\n * `\"superAdmin.organisations\"`. Must not change between renders/sessions or\n * the saved preferences orphan.\n *\n * OPTIONAL: when omitted, an auto-key is derived from the current route (see\n * the file-level \"Auto-key\" note). Pass an explicit key for multi-grid pages\n * or to pin a key independent of the route. An explicit key always wins.\n */\n gridKey?: string;\n /** Debounce window (ms) for the persist write. Defaults to 400ms. */\n persistDebounceMs?: number;\n}\n\n/**\n * Shallow-merges a restored grid state OVER a caller-provided `initialState`.\n * Top-level slices (`columns`, `sorting`, `filter`, `pinnedColumns`, …) from the\n * restored state replace the caller's; slices the user never touched fall back\n * to the caller default. A deep merge isn't warranted — MUI's `exportState()`\n * emits a complete slice per touched feature, so slice-level replacement is the\n * correct granularity (and matches how `initialState` is consumed once on mount).\n */\nfunction mergeInitialState(\n callerInitialState: GridInitialState | undefined,\n restored: GridInitialState | undefined,\n): GridInitialState | undefined {\n if (!restored) return callerInitialState;\n if (!callerInitialState) return restored;\n return { ...callerInitialState, ...restored };\n}\n\nexport function PersistentDataGrid({\n gridKey,\n persistDebounceMs = DEFAULT_GRID_DEBOUNCE_MS,\n initialState: callerInitialState,\n onStateChange: callerOnStateChange,\n ...rest\n}: PersistentDataGridProps) {\n // Own an apiRef so we can read exportState() on every state change.\n const apiRef = useGridApiRef();\n\n // Resolve the effective grid key: an explicit `gridKey` prop ALWAYS wins;\n // otherwise derive a stable per-grid-type key from the current route (id-ish\n // segments stripped). We read `window.location.pathname` directly rather than\n // `useLocation()` so this leaf wrapper needs no `<Router>` context — accurate at\n // render time (react-router drives navigation through the history API, which\n // updates `window.location`), and grid views remount on navigation so the path\n // is re-read on the next mount. Guarded for SSR / non-DOM environments (falls\n // back to \"\" → `route:root`).\n const pathname = typeof window !== \"undefined\" ? window.location.pathname : \"\";\n const effectiveGridKey = gridKey ?? deriveGridKeyFromPath(pathname);\n\n // Resolve the per-user storage key. The HOST IDENTITY is the primary source:\n // `useCurrentUser` reads the identity the host binds to every mounted surface, so a plugin\n // gets persistence with nothing to wire up. `getGridUserId()` is the fallback, for an\n // embedder that stamps the ref instead of providing an identity (the monolith's arrangement).\n //\n // That order matters, and it is the fix for a real defect: this component previously read the\n // ref ONLY, and no plugin ever stamped it - the `useGridUserSync` hook its docs named was a\n // host-app hook that was not carried over on the lift. So the key was permanently null and\n // every plugin grid silently persisted nothing. Reading the identity means it cannot be\n // forgotten again, because there is nothing left to forget.\n //\n // `userId` is in the dependency list, unlike the bare `getGridUserId()` call it replaces: the\n // identity resolves ASYNCHRONOUSLY (`isLoading` while the host's /auth/user is in flight), so a\n // mount-time-only read would miss it and fall back to no persistence on every first paint.\n const userId = useCurrentUser()?.id ?? getGridUserId();\n const storageKey = useMemo(() => buildGridStorageKey(userId, effectiveGridKey), [userId, effectiveGridKey]);\n const restoredState = useMemo(() => readPersistedGridState(storageKey), [storageKey]);\n const effectiveInitialState = useMemo(\n () => mergeInitialState(callerInitialState, restoredState),\n [callerInitialState, restoredState],\n );\n\n // Apply the persisted state when the key changes AFTER mount, which is what the async identity\n // actually produces: the grid mounts pre-auth with no user, then the host resolves one.\n //\n // Recomputing `storageKey` and `restoredState` is not enough on its own, because MUI consumes\n // `initialState` on the FIRST RENDER ONLY — so the correct state would be read and then have\n // nowhere to go. `restoreState` is the documented route for injecting it later (\"These values can\n // then be passed to the `initialState` prop or injected using the `restoreState` method\").\n //\n // `appliedKeyRef` starts at the mount-time key, so the mount case stays with `initialState` and is\n // never restored twice. Only a genuine key CHANGE reaches the api call — in practice once per\n // session, when auth resolves — so this cannot clobber preferences a user is actively changing.\n const appliedKeyRef = useRef<string | null>(storageKey);\n useEffect(() => {\n if (storageKey === appliedKeyRef.current) {\n return;\n }\n appliedKeyRef.current = storageKey;\n if (storageKey && restoredState) {\n apiRef.current?.restoreState(restoredState);\n }\n }, [storageKey, restoredState, apiRef]);\n\n // One debounced writer per grid instance, kept stable across renders.\n const writerRef = useRef<ReturnType<typeof createDebouncedGridWriter> | null>(null);\n if (writerRef.current === null) {\n writerRef.current = createDebouncedGridWriter(persistDebounceMs);\n }\n\n // Signature-agnostic forwarder: `onStateChange`'s exact param tuple varies by\n // MUI x-data-grid minor, so we accept the args as a rest tuple typed off the\n // prop itself and forward them verbatim. This stays assignable to\n // `DataGridProps[\"onStateChange\"]` regardless of the concrete arity.\n type StateChangeArgs = Parameters<NonNullable<DataGridProps[\"onStateChange\"]>>;\n const handleStateChange = useCallback(\n (...args: StateChangeArgs) => {\n callerOnStateChange?.(...args);\n // exportState() returns the full persistable snapshot; the writer strips\n // the server-owned pagination slice before persisting.\n if (storageKey) {\n writerRef.current?.schedule(storageKey, apiRef.current?.exportState());\n }\n },\n [callerOnStateChange, storageKey, apiRef],\n );\n\n return (\n <DataGrid\n {...rest}\n apiRef={apiRef}\n initialState={effectiveInitialState}\n onStateChange={handleStateChange}\n />\n );\n}\n","/**\n * Canonical page-size options and default, promoted out of the plugins.\n *\n * A `constants/pagination.ts` appeared in three of the five plugin repos measured on\n * 2026-08-20, each declaring its own option list. Four copies of a list of page sizes is four\n * answers to \"what page sizes does this product offer\", and the divergence shows up as a grid\n * that offers 25 rows next to one that offers 20.\n */\n\n/** Page sizes a data grid offers. */\nexport const PAGE_SIZE_OPTIONS = [5, 10, 25, 50] as const;\n\n/** Page size a list starts on. */\nexport const DEFAULT_PAGE_SIZE = 10;\n","import type { GridPaginationModel } from \"@mui/x-data-grid\";\nimport { PAGE_SIZE_OPTIONS } from \"./pagination\";\n\n/**\n * Pagination object accepted by {@link buildDataGridPaginationProps}. Aligns with\n * `ContractPagination` in `@coreconnect/contracts`: 1-based page numbers with separate\n * page-change and page-size-change callbacks. The `STATIC_PAGINATION` placeholder used by\n * client-side-paginated views is also accepted (it omits `onPageSizeChange` — the helper\n * detects the omission and skips the size-change branch).\n */\nexport interface DataGridPaginationInput {\n page: number;\n pageSize: number;\n total: number;\n onPageChange: (page: number) => void;\n onPageSizeChange?: (pageSize: number) => void;\n}\n\n/**\n * Bundle of MUI DataGrid pagination props. Spread into a `<DataGrid {...}>` to wire\n * server-paginated lists in one line and avoid the per-view `onPaginationModelChange`\n * boilerplate.\n */\nexport interface DataGridPaginationProps {\n pagination: true;\n paginationMode: \"server\";\n rowCount: number;\n paginationModel: { page: number; pageSize: number };\n onPaginationModelChange: (model: GridPaginationModel) => void;\n pageSizeOptions: readonly number[];\n}\n\n/**\n * Builds the canonical pagination prop bundle for a server-paginated MUI DataGrid.\n *\n * The hand-written equivalent across the codebase looked like:\n *\n * ```tsx\n * paginationMode=\"server\"\n * rowCount={pagination.total}\n * paginationModel={{ page: pagination.page - 1, pageSize: pagination.pageSize }}\n * onPaginationModelChange={(model) => {\n * pagination.onPageChange(model.page + 1);\n * if (\"onPageSizeChange\" in pagination && pagination.onPageSizeChange) {\n * pagination.onPageSizeChange(model.pageSize);\n * }\n * }}\n * pageSizeOptions={[...PAGE_SIZE_OPTIONS]}\n * ```\n *\n * The naive form had a latent bug: changing page size called BOTH callbacks, so the\n * size-change handler (which typically resets page to 1) cancelled out subsequent\n * page-N navigation, pinning the user to page 1. This helper distinguishes the two\n * cases and only fires the relevant callback.\n *\n * Usage:\n *\n * ```tsx\n * <DataGrid\n * {...buildDataGridPaginationProps(effectivePagination)}\n * rows={items}\n * columns={columns}\n * ...\n * />\n * ```\n */\nexport function buildDataGridPaginationProps(\n pagination: DataGridPaginationInput,\n): DataGridPaginationProps {\n return {\n pagination: true,\n paginationMode: \"server\",\n rowCount: pagination.total,\n paginationModel: {\n page: pagination.page - 1,\n pageSize: pagination.pageSize,\n },\n onPaginationModelChange: (model) => {\n // Distinguish page-only navigation from page-size change. The naive\n // \"always call both callbacks\" approach pinned the grid to page 1 forever:\n // a page-size change would call onPageSizeChange (which resets page to 1)\n // AND onPageChange(model.page + 1), and on the next page navigation\n // onPageSizeChange would fire again from the updated model.pageSize.\n const pageSizeChanged = model.pageSize !== pagination.pageSize;\n if (pageSizeChanged) {\n if (pagination.onPageSizeChange) {\n pagination.onPageSizeChange(model.pageSize);\n }\n return;\n }\n pagination.onPageChange(model.page + 1);\n },\n pageSizeOptions: [...PAGE_SIZE_OPTIONS],\n };\n}\n"]}
1
+ {"version":3,"sources":["../../../src/components/data-grid/centeredColumns.tsx","../../../src/currentUser.ts","../../../src/components/data-grid/gridUserContext.ts","../../../src/components/data-grid/gridStatePersistence.ts","../../../src/components/data-grid/PersistentDataGrid.tsx","../../../src/components/data-grid/pagination.ts","../../../src/components/data-grid/dataGridPagination.ts"],"names":["jsx"],"mappings":"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","file":"index.js","sourcesContent":["import type { ReactElement, ReactNode } from \"react\";\nimport type { GridColDef, GridRenderCellParams, GridValidRowModel } from \"@mui/x-data-grid\";\n\n/**\n * The column types whose VIEW-mode cell is a formatted string, so they get the\n * ellipsis wrapper below. `singleSelect` is included deliberately: its view cell is\n * the option's formatted label — the Select control only appears in EDIT mode via\n * `renderEditCell`, which this leaves untouched — so a long label needs the same\n * truncation as plain text. Types that render their OWN view control (`actions`,\n * `boolean`) are excluded, as is any column that already has a `renderCell`. An\n * unset `type` is `'string'`.\n */\nconst TEXT_COLUMN_TYPES: ReadonlySet<string> = new Set([\n \"string\",\n \"number\",\n \"date\",\n \"dateTime\",\n \"singleSelect\",\n]);\n\n/**\n * Inline styles that restore single-line \"…\" truncation for a value that now sits\n * inside a `display: flex` cell. `minWidth: 0` is the load-bearing part — a flex\n * item defaults to `min-width: auto` (its content size) and would refuse to shrink,\n * so without it the text overflows the cell instead of ellipsing.\n */\nconst ELLIPSIS_STYLE = {\n minWidth: 0,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n} as const;\n\nfunction EllipsisCell(params: GridRenderCellParams): ReactElement {\n // `formattedValue` is MUI's own display value (it honours `valueFormatter` and\n // falls back to the raw value when none is set), so this renders exactly what the\n // default text cell would — just inside a shrinkable, ellipsing wrapper.\n return <div style={ELLIPSIS_STYLE}>{params.formattedValue as ReactNode}</div>;\n}\n\n/**\n * Vertically-center every cell's content while keeping single-line text truncation.\n *\n * WHY. A Data Grid cell's default column `display` is `'text'` (block): single-line\n * text is centred by line-height and truncates with an ellipsis, but ANYTHING else —\n * chips, icons, a custom `renderCell`, or any content on a dynamic-height row — sits\n * at the TOP of a taller row. MUI's own fix is per-column `display: 'flex'`, which\n * centres arbitrary content but drops the built-in ellipsis, because the default\n * value is a raw text node with no element to apply `text-overflow` to. This helper\n * applies both halves together, for every column, so a grid gets consistent vertical\n * centring WITHOUT losing text truncation:\n *\n * 1. `display: 'flex'` on every column that doesn't already declare one — centres\n * all content types, on fixed- and dynamic-height rows alike.\n * 2. an ellipsis-wrapper `renderCell` on renderer-less text columns — re-adds the\n * \"…\" truncation that step 1 would otherwise remove. Columns with their own\n * `renderCell`, and non-text types, are left untouched: they own their content\n * and already lay it out centred.\n *\n * Pure and idempotent: a column that already has a `display` or a `renderCell` is\n * passed through unchanged, so re-running over an already-processed list is a no-op.\n * The input array and its column objects are not mutated. Memoise the result at the\n * call site so cells don't remount on every render.\n */\nexport function withCenteredColumns<R extends GridValidRowModel = GridValidRowModel>(\n columns: readonly GridColDef<R>[],\n): GridColDef<R>[] {\n return columns.map((column) => {\n const next: GridColDef<R> = { ...column };\n\n // (1) Centre content unless the column declares its own display mode (respecting\n // an explicit `display: 'text'` an author chose on purpose).\n if (next.display == null) {\n next.display = \"flex\";\n }\n\n // (2) Restore ellipsis on renderer-less text columns only.\n if (!column.renderCell && TEXT_COLUMN_TYPES.has(column.type ?? \"string\")) {\n next.renderCell = EllipsisCell;\n }\n\n return next;\n });\n}\n","/**\n * `useCurrentUser` — the display-only current-user hook for plugin frontends\n * (WI 5154 follow-up #2, plugin-ui host-identity).\n *\n * A plugin page often needs the signed-in user for UX bits — a \"you are signed\n * in as …\" hint, pre-selecting the caller in a sign-off panel, or a\n * \"my acknowledgements\" heading. This hook surfaces that identity from the\n * host without the plugin importing the lower-level {@link useHostIdentity}\n * seam directly.\n *\n * It is a thin adapter over the host-provided {@link HostIdentity} context\n * (bound by the host through `ExtensionRuntimeProvider`'s `identity` prop):\n * it projects the host's richer {@link HostIdentityUser} down to the minimal\n * `{ id, displayName }` shape a plugin page needs for display.\n *\n * ⚠️ SECURITY — DISPLAY ONLY. This is presentation/UX data, NOT an\n * authorization source. Authorization is enforced HOST-SIDE at the MCP\n * boundary: the plugin backend re-authorises every tool/resource call from the\n * trusted server session. Never gate a mutation or a data read on this value.\n *\n * Returns `null` when the host has not provided an identity — standalone / mock\n * hosts, a host that predates the identity seam, or while host auth is still\n * loading or the caller is unauthenticated. Callers MUST handle `null`\n * (this matches the pre-existing plugin `useAuth().user` shim, which returned\n * `undefined`, so adopting this hook is a no-regression change).\n */\nimport { useHostIdentity } from \"@ethisyscore/extension-runtime/plugin\";\n\n/**\n * Minimal current-user shape a plugin page reads for display. Mirrors the\n * identity the host authenticates: a stable `id` and a human-readable\n * `displayName`. (No email — the host identity seam does not currently\n * forward one; if that changes, extend {@link HostIdentityUser} first and\n * project it here.)\n */\nexport interface CurrentUser {\n /** Stable user id (matches the host's authenticated caller id). */\n id: string;\n /** Human-readable display name (the host's full name). */\n displayName: string;\n}\n\n/**\n * Returns the host-authenticated current user projected to the display-only\n * {@link CurrentUser} shape, or `null` when no host identity is available.\n *\n * Display-only — see the module doc: authorization stays host-enforced at the\n * MCP boundary. Do NOT use the returned value as an authorization decision.\n */\nexport function useCurrentUser(): CurrentUser | null {\n const identity = useHostIdentity();\n const user = identity?.user ?? null;\n if (!user) {\n return null;\n }\n return {\n id: user.id,\n // The host binds `fullName`; fall back to composing first + last so a host\n // that only sets the parts still yields a usable display name.\n displayName:\n user.fullName?.trim() || `${user.firstName ?? \"\"} ${user.lastName ?? \"\"}`.trim(),\n };\n}\n","/**\n * Module-level mirror of the current signed-in user's id — the FALLBACK source for the grid\n * persistence key.\n *\n * ## Read this before using it\n *\n * For plugin surfaces you do NOT need to stamp anything. {@link PersistentDataGrid} reads the host\n * identity via `useCurrentUser` and only consults this ref when no identity is present. This exists\n * for an embedder that has no host identity to provide — the monolith arrangement, where a host-app\n * hook stamped the ref from its own auth context.\n *\n * ## Why it is the fallback and not the primary\n *\n * It used to be the only source, and that shipped a silent defect: the doc below described a\n * `useGridUserSync` hook in the host app, which was never carried over when these components were\n * lifted into plugin-ui. Measured across all eleven plugin repos on 2026-08-26, `setGridUserId` had\n * ZERO call sites — only its own declaration and this comment. So `getGridUserId()` always returned\n * `null`, `buildGridStorageKey` always returned `null`, and 17 grids ran a persistence wrapper that\n * read nothing and wrote nothing. It failed invisibly, because a grid with no saved preferences\n * looks exactly like a grid whose preferences were never saved.\n *\n * The lesson is in the ordering, not the code: a seam that requires the CONSUMER to remember a wiring\n * step will eventually meet a consumer who does not. Reading the identity the host already binds\n * removes the step.\n *\n * ## Why a module-level ref rather than a context\n *\n * The persistence layer needs the id synchronously when a grid mounts, to build the localStorage\n * key — not as a render-time prop, and never plumbed through every view.\n *\n * When `null` (pre-auth / signed out / standalone harness) persistence is simply off: the key builder\n * returns `null`, reads fall back to grid defaults, and writes are ignored.\n */\n\nlet gridUserIdRef: string | null = null;\n\n/** Returns the current user's id, or `null` while auth is unresolved / signed out. */\nexport function getGridUserId(): string | null {\n return gridUserIdRef;\n}\n\n/**\n * Stamps the module-level user-id ref.\n *\n * Plugin surfaces do not need this — {@link PersistentDataGrid} prefers the host identity, so\n * calling it is optional and stamping is not part of adopting the grid. Use it only in an embedder\n * with no host identity, each time its authenticated user resolves or changes. Pass `null` to\n * disable persistence.\n */\nexport function setGridUserId(userId: string | null): void {\n gridUserIdRef = userId;\n}\n","/**\n * Pure, gogo-ui-safe persistence logic for MUI DataGrid state (column\n * visibility, sort, filter, and column widths). This is the single source of\n * truth for the key shape, the localStorage read/write, the debounce window,\n * and the pagination-slice stripping that the AB#4840 column-visibility feature\n * relies on.\n *\n * It lives in gogo-ui (which MAY import `@mui/x-data-grid` types directly) so\n * the reusable {@link PersistentDataGrid} wrapper can own the apiRef and MUI\n * types in one place — the host app's `src/**` may NOT import `@mui/x-data-grid`\n * (values or types), so this logic cannot live in the host hook.\n *\n * Auth-agnostic: the user id comes from the host-stamped module ref in\n * `gridUserContextValue.ts` at the call sites (the wrapper), not from this\n * module — keeping these functions pure and unit-testable.\n *\n * No React, no MUI values — only the `GridInitialState` TYPE import, so this\n * module is a plain value module (`.ts`) free of `react-refresh` concerns.\n */\nimport type { GridInitialState } from \"@mui/x-data-grid\";\n\n/**\n * Schema version for the persisted grid-state payload. Bump this when the shape\n * of what we store changes incompatibly so stale entries from an older app\n * version are ignored (the key changes, the old key is simply orphaned) rather\n * than restored into a grid that can no longer interpret them.\n */\nexport const GRID_STATE_VERSION = \"v1\";\nexport const GRID_STATE_KEY_PREFIX = \"cc-grid-state\";\n\n/**\n * Default debounce window for persisting grid state. `onStateChange` fires very\n * frequently (every hover, focus, resize tick) — debouncing collapses a burst\n * of changes into a single localStorage write.\n */\nexport const DEFAULT_GRID_DEBOUNCE_MS = 400;\n\n/**\n * Builds the per-user, per-grid localStorage key. Keying on the user id keeps a\n * shared browser's saved preferences separate per signed-in user; keying on\n * `gridKey` keeps each grid's preferences independent. Returns `null` when there\n * is no user id yet (pre-auth / signed out) — callers then behave as a no-op\n * persistence layer and the grid renders with its built-in defaults.\n */\nexport function buildGridStorageKey(userId: string | null | undefined, gridKey: string): string | null {\n if (!userId) return null;\n return `${GRID_STATE_KEY_PREFIX}:${GRID_STATE_VERSION}:${userId}:${gridKey}`;\n}\n\n/**\n * Matches a single path segment that is \"id-ish\" — an entity identifier rather\n * than a stable grid-type segment — so {@link deriveGridKeyFromPath} can strip it\n * and key preferences per grid TYPE instead of per entity. A segment is dropped\n * when it is either:\n *\n * - **all-numeric** — `123`, `0042` (numeric DB ids / sequence numbers); or\n * - **a UUID** — canonical 8-4-4-4-12 hex form, case-insensitive\n * (e.g. `3fa85f64-5717-4562-b3fc-2c963f66afa6`).\n *\n * Deliberately NARROW: only these two shapes are treated as ids. Slugs, ULIDs,\n * mixed alphanumerics, and ordinary words (`new`, `all`, `documents`,\n * `position-history`) are KEPT, so two genuinely different grid pages never\n * collide on the same key. The trade-off — a non-numeric, non-UUID id (e.g. a\n * short code or a base-62 id) is not stripped, so its grid keys per entity — is\n * acceptable: such routes are rare, and an explicit `gridKey` prop overrides the\n * auto-key whenever per-entity keying is wrong (see {@link PersistentDataGrid}).\n */\nconst ID_ISH_SEGMENT = /^(?:\\d+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;\n\n/**\n * Derives a STABLE, per-grid-TYPE key from a route pathname, used by\n * {@link PersistentDataGrid} when no explicit `gridKey` prop is supplied.\n *\n * Stripping rule (so prefs are per-grid-type, not per-entity instance):\n * 1. Split the pathname on `/` and drop empty segments (leading/trailing/double\n * slashes, and a bare `/` root).\n * 2. Drop every \"id-ish\" segment per {@link ID_ISH_SEGMENT} — all-numeric ids and\n * canonical UUIDs.\n * 3. Join the survivors with `/`.\n *\n * Examples:\n * - `/projects/123/documents` → `projects/documents`\n * - `/hr/employees/3fa85f64-…-66afa6` → `hr/employees`\n * - `/super-admin/organisations` → `super-admin/organisations`\n * - `/` (root) → `route:root` (fallback)\n *\n * The returned key is prefixed with `route:` so an auto-derived key can never\n * be mistaken for, or collide with, a hand-authored `\"<module>.<view>\"` gridKey\n * (which uses dots, not slashes). When stripping leaves nothing (e.g. a route\n * that is ONLY ids, or the bare root), it falls back to `route:root` rather than\n * an empty string, so the storage key stays well-formed.\n */\nexport function deriveGridKeyFromPath(pathname: string): string {\n const kept = pathname\n .split(\"/\")\n .filter((segment) => segment.length > 0 && !ID_ISH_SEGMENT.test(segment));\n return kept.length > 0 ? `route:${kept.join(\"/\")}` : \"route:root\";\n}\n\n/**\n * Strips the `pagination` slice from a grid state. Pagination on these grids is\n * server-owned and supplied as a controlled `paginationModel` via the pagination\n * helper; persisting/restoring it would clash with the controlled prop (MUI\n * warns when `initialState.pagination.paginationModel` is set alongside a\n * controlled model) and isn't part of the AB#4840 scope (visibility / sort /\n * filter / column widths). We keep everything else `exportState()` produces —\n * including `columns` (visibility + widths + order), `sorting`, and `filter`.\n */\nexport function stripGridPagination(state: GridInitialState): GridInitialState {\n const { pagination: _pagination, ...rest } = state;\n return rest;\n}\n\n/**\n * Reads a previously-persisted grid state from localStorage. SSR / throw-safe:\n * any failure (storage blocked, corrupted JSON, quota errors, no `localStorage`\n * during SSR) falls back to `undefined` so the grid renders with defaults rather\n * than crashing.\n */\nexport function readPersistedGridState(storageKey: string | null): GridInitialState | undefined {\n if (!storageKey) return undefined;\n if (typeof localStorage === \"undefined\") return undefined;\n try {\n const raw = localStorage.getItem(storageKey);\n if (!raw) return undefined;\n return stripGridPagination(JSON.parse(raw) as GridInitialState);\n } catch {\n return undefined;\n }\n}\n\n/**\n * Writes a grid state to localStorage under `storageKey` (the server-owned\n * pagination slice stripped first). SSR / throw-safe: storage blocked, quota\n * exceeded, or serialization failure are swallowed — a persistence failure must\n * never crash the grid. No-ops on a nullish key (no user) or nullish state\n * (apiRef not yet attached).\n */\nexport function writePersistedGridState(storageKey: string | null, state: GridInitialState | undefined): void {\n if (!storageKey || !state) return;\n if (typeof localStorage === \"undefined\") return;\n try {\n localStorage.setItem(storageKey, JSON.stringify(stripGridPagination(state)));\n } catch {\n // Storage blocked / quota exceeded / serialization failure — preferences\n // just won't persist this session. Never let a persistence failure crash\n // the grid.\n }\n}\n\n/**\n * Creates a debounced writer bound to a fresh timer. Each returned function\n * collapses a burst of `onStateChange` calls into a single localStorage write\n * after `debounceMs`. The caller owns one instance per grid (e.g. via a ref) so\n * timers don't leak across grids.\n */\nexport function createDebouncedGridWriter(debounceMs: number = DEFAULT_GRID_DEBOUNCE_MS) {\n let timer: ReturnType<typeof setTimeout> | null = null;\n const flushNow = (storageKey: string | null, state: GridInitialState | undefined) => {\n writePersistedGridState(storageKey, state);\n };\n const schedule = (storageKey: string | null, state: GridInitialState | undefined) => {\n if (!storageKey || !state) return;\n if (timer) clearTimeout(timer);\n timer = setTimeout(() => flushNow(storageKey, state), debounceMs);\n };\n const cancel = () => {\n if (timer) {\n clearTimeout(timer);\n timer = null;\n }\n };\n return { schedule, cancel };\n}\n","/**\n * PersistentDataGrid — a thin, drop-in wrapper around MUI `<DataGrid>` that\n * persists the user's column visibility, sort, filter, and column-width\n * preferences to localStorage, keyed per-user + per-grid (AB#4840).\n *\n * Adoption for ANY paginated grid is a near-one-line swap:\n *\n * ```tsx\n * // before\n * import { DataGrid } from \"@mui/x-data-grid\";\n * <DataGrid rows={rows} columns={cols} {...buildDataGridPaginationProps(pagination)} ... />\n *\n * // after\n * import { PersistentDataGrid } from \"../shared/PersistentDataGrid\";\n * <PersistentDataGrid gridKey=\"projects.all\" rows={rows} columns={cols} {...buildDataGridPaginationProps(pagination)} ... />\n * ```\n *\n * It composes with `buildDataGridPaginationProps` / `buildDataGridSlots`: those\n * prop bundles are spread straight through (`...rest`) untouched. The wrapper\n * owns the grid `apiRef` (created internally via `useGridApiRef()` so it can\n * read `exportState()`) and two props it MERGES rather than clobbers:\n *\n * - `initialState`: persisted state is layered OVER any caller default, so a\n * view can still seed default column visibility while the user's saved\n * preferences win on the keys they've touched.\n * - `onStateChange`: the caller's handler (if any) still fires; the wrapper\n * additionally schedules a debounced persist.\n *\n * The current user id comes from the HOST IDENTITY (`useCurrentUser`, which reads\n * the identity the host binds to every mounted plugin surface), falling back to the\n * stamped module ref (`gridUserContext.getGridUserId`) for an embedder that provides\n * no identity. Either way NO `userId` prop is threaded through views. When there is\n * no user at all (pre-auth / signed out / standalone harness) the storage key is\n * `null` and the wrapper degrades to a plain DataGrid — no reads, no writes.\n *\n * gogo-ui MAY import `@mui/x-data-grid` directly, so the apiRef + MUI types live\n * here, keeping the host `src/**` free of any `@mui/x-data-grid` import.\n *\n * ## Auto-key (no `gridKey` prop)\n *\n * `gridKey` is OPTIONAL. When omitted, the grid key is derived from the current\n * route via `window.location.pathname` and {@link deriveGridKeyFromPath} — id-ish\n * segments (all-numeric ids, UUIDs) are stripped so preferences are keyed per\n * grid TYPE, not per entity (e.g. `/projects/123/documents` and\n * `/projects/456/documents` share one key `route:projects/documents`). This makes\n * adoption a one-line IMPORT REDIRECT for the common single-grid-per-page case —\n * a view swaps its DataGrid import to the barrel and gets persistence for free.\n *\n * An explicit `gridKey` ALWAYS overrides the auto-key. Pass one for:\n * - multi-grid pages (2+ grids on one route would otherwise share a key), and\n * - long-lived grids whose key must stay stable independent of route changes\n * (e.g. the canonical list grids keep `\"projects.all\"` etc.).\n *\n * NOTE: the wrapper creates its OWN apiRef. A view that also needs direct apiRef\n * access for unrelated reasons should keep a plain `<DataGrid>` — that's rare;\n * persistence is the only thing 99% of grids want the apiRef for.\n */\nimport { useCallback, useEffect, useMemo, useRef } from \"react\";\n\nimport { DataGrid, useGridApiRef, type DataGridProps, type GridInitialState } from \"@mui/x-data-grid\";\n\nimport { withCenteredColumns } from \"./centeredColumns\";\nimport { useCurrentUser } from \"../../currentUser\";\nimport { getGridUserId } from \"./gridUserContext\";\nimport {\n buildGridStorageKey,\n createDebouncedGridWriter,\n deriveGridKeyFromPath,\n readPersistedGridState,\n DEFAULT_GRID_DEBOUNCE_MS,\n} from \"./gridStatePersistence\";\n\nexport interface PersistentDataGridProps extends Omit<DataGridProps, \"apiRef\"> {\n /**\n * Stable identifier for this grid, unique per logical grid across the app.\n * Used as the per-grid segment of the localStorage key. Convention:\n * `\"<module>.<view>\"` — e.g. `\"projects.all\"`, `\"legal.risks\"`,\n * `\"superAdmin.organisations\"`. Must not change between renders/sessions or\n * the saved preferences orphan.\n *\n * OPTIONAL: when omitted, an auto-key is derived from the current route (see\n * the file-level \"Auto-key\" note). Pass an explicit key for multi-grid pages\n * or to pin a key independent of the route. An explicit key always wins.\n */\n gridKey?: string;\n /** Debounce window (ms) for the persist write. Defaults to 400ms. */\n persistDebounceMs?: number;\n}\n\n/**\n * Shallow-merges a restored grid state OVER a caller-provided `initialState`.\n * Top-level slices (`columns`, `sorting`, `filter`, `pinnedColumns`, …) from the\n * restored state replace the caller's; slices the user never touched fall back\n * to the caller default. A deep merge isn't warranted — MUI's `exportState()`\n * emits a complete slice per touched feature, so slice-level replacement is the\n * correct granularity (and matches how `initialState` is consumed once on mount).\n */\nfunction mergeInitialState(\n callerInitialState: GridInitialState | undefined,\n restored: GridInitialState | undefined,\n): GridInitialState | undefined {\n if (!restored) return callerInitialState;\n if (!callerInitialState) return restored;\n return { ...callerInitialState, ...restored };\n}\n\nexport function PersistentDataGrid({\n gridKey,\n persistDebounceMs = DEFAULT_GRID_DEBOUNCE_MS,\n initialState: callerInitialState,\n onStateChange: callerOnStateChange,\n columns,\n ...rest\n}: PersistentDataGridProps) {\n // Own an apiRef so we can read exportState() on every state change.\n const apiRef = useGridApiRef();\n\n // Vertically center every cell's content (chips/icons/custom/dynamic-height rows\n // otherwise top-align) while keeping single-line text truncation. Memoised so the\n // grid doesn't remount cells each render. See `withCenteredColumns`.\n const centeredColumns = useMemo(() => withCenteredColumns(columns), [columns]);\n\n // Resolve the effective grid key: an explicit `gridKey` prop ALWAYS wins;\n // otherwise derive a stable per-grid-type key from the current route (id-ish\n // segments stripped). We read `window.location.pathname` directly rather than\n // `useLocation()` so this leaf wrapper needs no `<Router>` context — accurate at\n // render time (react-router drives navigation through the history API, which\n // updates `window.location`), and grid views remount on navigation so the path\n // is re-read on the next mount. Guarded for SSR / non-DOM environments (falls\n // back to \"\" → `route:root`).\n const pathname = typeof window !== \"undefined\" ? window.location.pathname : \"\";\n const effectiveGridKey = gridKey ?? deriveGridKeyFromPath(pathname);\n\n // Resolve the per-user storage key. The HOST IDENTITY is the primary source:\n // `useCurrentUser` reads the identity the host binds to every mounted surface, so a plugin\n // gets persistence with nothing to wire up. `getGridUserId()` is the fallback, for an\n // embedder that stamps the ref instead of providing an identity (the monolith's arrangement).\n //\n // That order matters, and it is the fix for a real defect: this component previously read the\n // ref ONLY, and no plugin ever stamped it - the `useGridUserSync` hook its docs named was a\n // host-app hook that was not carried over on the lift. So the key was permanently null and\n // every plugin grid silently persisted nothing. Reading the identity means it cannot be\n // forgotten again, because there is nothing left to forget.\n //\n // `userId` is in the dependency list, unlike the bare `getGridUserId()` call it replaces: the\n // identity resolves ASYNCHRONOUSLY (`isLoading` while the host's /auth/user is in flight), so a\n // mount-time-only read would miss it and fall back to no persistence on every first paint.\n const userId = useCurrentUser()?.id ?? getGridUserId();\n const storageKey = useMemo(() => buildGridStorageKey(userId, effectiveGridKey), [userId, effectiveGridKey]);\n const restoredState = useMemo(() => readPersistedGridState(storageKey), [storageKey]);\n const effectiveInitialState = useMemo(\n () => mergeInitialState(callerInitialState, restoredState),\n [callerInitialState, restoredState],\n );\n\n // Apply the persisted state when the key changes AFTER mount, which is what the async identity\n // actually produces: the grid mounts pre-auth with no user, then the host resolves one.\n //\n // Recomputing `storageKey` and `restoredState` is not enough on its own, because MUI consumes\n // `initialState` on the FIRST RENDER ONLY — so the correct state would be read and then have\n // nowhere to go. `restoreState` is the documented route for injecting it later (\"These values can\n // then be passed to the `initialState` prop or injected using the `restoreState` method\").\n //\n // `appliedKeyRef` starts at the mount-time key, so the mount case stays with `initialState` and is\n // never restored twice. Only a genuine key CHANGE reaches the api call — in practice once per\n // session, when auth resolves — so this cannot clobber preferences a user is actively changing.\n const appliedKeyRef = useRef<string | null>(storageKey);\n useEffect(() => {\n if (storageKey === appliedKeyRef.current) {\n return;\n }\n appliedKeyRef.current = storageKey;\n if (storageKey && restoredState) {\n apiRef.current?.restoreState(restoredState);\n }\n }, [storageKey, restoredState, apiRef]);\n\n // One debounced writer per grid instance, kept stable across renders.\n const writerRef = useRef<ReturnType<typeof createDebouncedGridWriter> | null>(null);\n if (writerRef.current === null) {\n writerRef.current = createDebouncedGridWriter(persistDebounceMs);\n }\n\n // Signature-agnostic forwarder: `onStateChange`'s exact param tuple varies by\n // MUI x-data-grid minor, so we accept the args as a rest tuple typed off the\n // prop itself and forward them verbatim. This stays assignable to\n // `DataGridProps[\"onStateChange\"]` regardless of the concrete arity.\n type StateChangeArgs = Parameters<NonNullable<DataGridProps[\"onStateChange\"]>>;\n const handleStateChange = useCallback(\n (...args: StateChangeArgs) => {\n callerOnStateChange?.(...args);\n // exportState() returns the full persistable snapshot; the writer strips\n // the server-owned pagination slice before persisting.\n if (storageKey) {\n writerRef.current?.schedule(storageKey, apiRef.current?.exportState());\n }\n },\n [callerOnStateChange, storageKey, apiRef],\n );\n\n return (\n <DataGrid\n {...rest}\n columns={centeredColumns}\n apiRef={apiRef}\n initialState={effectiveInitialState}\n onStateChange={handleStateChange}\n />\n );\n}\n","/**\n * Canonical page-size options and default, promoted out of the plugins.\n *\n * A `constants/pagination.ts` appeared in three of the five plugin repos measured on\n * 2026-08-20, each declaring its own option list. Four copies of a list of page sizes is four\n * answers to \"what page sizes does this product offer\", and the divergence shows up as a grid\n * that offers 25 rows next to one that offers 20.\n */\n\n/** Page sizes a data grid offers. */\nexport const PAGE_SIZE_OPTIONS = [5, 10, 25, 50] as const;\n\n/** Page size a list starts on. */\nexport const DEFAULT_PAGE_SIZE = 10;\n","import type { GridPaginationModel } from \"@mui/x-data-grid\";\nimport { PAGE_SIZE_OPTIONS } from \"./pagination\";\n\n/**\n * Pagination object accepted by {@link buildDataGridPaginationProps}. Aligns with\n * `ContractPagination` in `@coreconnect/contracts`: 1-based page numbers with separate\n * page-change and page-size-change callbacks. The `STATIC_PAGINATION` placeholder used by\n * client-side-paginated views is also accepted (it omits `onPageSizeChange` — the helper\n * detects the omission and skips the size-change branch).\n */\nexport interface DataGridPaginationInput {\n page: number;\n pageSize: number;\n total: number;\n onPageChange: (page: number) => void;\n onPageSizeChange?: (pageSize: number) => void;\n}\n\n/**\n * Bundle of MUI DataGrid pagination props. Spread into a `<DataGrid {...}>` to wire\n * server-paginated lists in one line and avoid the per-view `onPaginationModelChange`\n * boilerplate.\n */\nexport interface DataGridPaginationProps {\n pagination: true;\n paginationMode: \"server\";\n rowCount: number;\n paginationModel: { page: number; pageSize: number };\n onPaginationModelChange: (model: GridPaginationModel) => void;\n pageSizeOptions: readonly number[];\n}\n\n/**\n * Builds the canonical pagination prop bundle for a server-paginated MUI DataGrid.\n *\n * The hand-written equivalent across the codebase looked like:\n *\n * ```tsx\n * paginationMode=\"server\"\n * rowCount={pagination.total}\n * paginationModel={{ page: pagination.page - 1, pageSize: pagination.pageSize }}\n * onPaginationModelChange={(model) => {\n * pagination.onPageChange(model.page + 1);\n * if (\"onPageSizeChange\" in pagination && pagination.onPageSizeChange) {\n * pagination.onPageSizeChange(model.pageSize);\n * }\n * }}\n * pageSizeOptions={[...PAGE_SIZE_OPTIONS]}\n * ```\n *\n * The naive form had a latent bug: changing page size called BOTH callbacks, so the\n * size-change handler (which typically resets page to 1) cancelled out subsequent\n * page-N navigation, pinning the user to page 1. This helper distinguishes the two\n * cases and only fires the relevant callback.\n *\n * Usage:\n *\n * ```tsx\n * <DataGrid\n * {...buildDataGridPaginationProps(effectivePagination)}\n * rows={items}\n * columns={columns}\n * ...\n * />\n * ```\n */\nexport function buildDataGridPaginationProps(\n pagination: DataGridPaginationInput,\n): DataGridPaginationProps {\n return {\n pagination: true,\n paginationMode: \"server\",\n rowCount: pagination.total,\n paginationModel: {\n page: pagination.page - 1,\n pageSize: pagination.pageSize,\n },\n onPaginationModelChange: (model) => {\n // Distinguish page-only navigation from page-size change. The naive\n // \"always call both callbacks\" approach pinned the grid to page 1 forever:\n // a page-size change would call onPageSizeChange (which resets page to 1)\n // AND onPageChange(model.page + 1), and on the next page navigation\n // onPageSizeChange would fire again from the updated model.pageSize.\n const pageSizeChanged = model.pageSize !== pagination.pageSize;\n if (pageSizeChanged) {\n if (pagination.onPageSizeChange) {\n pagination.onPageSizeChange(model.pageSize);\n }\n return;\n }\n pagination.onPageChange(model.page + 1);\n },\n pageSizeOptions: [...PAGE_SIZE_OPTIONS],\n };\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ethisyscore/plugin-ui",
3
- "version": "1.112.0",
3
+ "version": "1.114.0",
4
4
  "description": "Plugin-UI umbrella SDK: client bridge + a11y/l10n primitives + brokered-MCP client (WI 4858).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",