@open-mercato/ui 0.6.8-develop.6914.1.6c7a5dcb6b → 0.6.8-develop.6916.1.f04fff3436

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.
@@ -0,0 +1,55 @@
1
+ function stableStringify(value) {
2
+ if (value === null || typeof value !== "object") return JSON.stringify(value ?? null);
3
+ if (Array.isArray(value)) return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
4
+ const entries = Object.entries(value).filter(([, entryValue]) => entryValue !== void 0).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0);
5
+ return `{${entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`).join(",")}}`;
6
+ }
7
+ function normalizeOrder(order, fallback) {
8
+ return Array.isArray(order) && order.length > 0 ? order : fallback;
9
+ }
10
+ function normalizeVisibility(visibility) {
11
+ if (!visibility) return {};
12
+ const result = {};
13
+ for (const [key, value] of Object.entries(visibility)) {
14
+ if (value === false) result[key] = false;
15
+ }
16
+ return result;
17
+ }
18
+ function normalizeSizing(sizing) {
19
+ if (!sizing) return {};
20
+ const result = {};
21
+ for (const [key, value] of Object.entries(sizing)) {
22
+ if (typeof value === "number" && Number.isFinite(value)) result[key] = Math.round(value);
23
+ }
24
+ return result;
25
+ }
26
+ function normalizeSorting(sorting) {
27
+ if (!Array.isArray(sorting)) return [];
28
+ return sorting.map((entry) => ({ id: String(entry?.id ?? ""), desc: entry?.desc === true }));
29
+ }
30
+ function normalizeSearch(searchValue) {
31
+ return typeof searchValue === "string" ? searchValue.trim() : "";
32
+ }
33
+ function diffPerspectiveSettings(base, current, options) {
34
+ const baseSettings = base ?? {};
35
+ const currentSettings = current ?? {};
36
+ const defaultColumnOrder = options?.defaultColumnOrder ?? [];
37
+ const changed = [];
38
+ const baseOrder = normalizeOrder(baseSettings.columnOrder, defaultColumnOrder);
39
+ const currentOrder = normalizeOrder(currentSettings.columnOrder, defaultColumnOrder);
40
+ if (stableStringify(baseOrder) !== stableStringify(currentOrder)) changed.push("columnOrder");
41
+ if (stableStringify(normalizeVisibility(baseSettings.columnVisibility)) !== stableStringify(normalizeVisibility(currentSettings.columnVisibility))) changed.push("columnVisibility");
42
+ if (stableStringify(normalizeSizing(baseSettings.columnSizing)) !== stableStringify(normalizeSizing(currentSettings.columnSizing))) changed.push("columnSizing");
43
+ if (stableStringify(normalizeSorting(baseSettings.sorting)) !== stableStringify(normalizeSorting(currentSettings.sorting))) changed.push("sorting");
44
+ if (stableStringify(baseSettings.filters ?? null) !== stableStringify(currentSettings.filters ?? null)) {
45
+ changed.push("filters");
46
+ }
47
+ if (normalizeSearch(baseSettings.searchValue) !== normalizeSearch(currentSettings.searchValue)) {
48
+ changed.push("searchValue");
49
+ }
50
+ return changed;
51
+ }
52
+ export {
53
+ diffPerspectiveSettings
54
+ };
55
+ //# sourceMappingURL=perspectiveDirty.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/backend/perspectiveDirty.ts"],
4
+ "sourcesContent": ["import type { PerspectiveSettings } from '@open-mercato/shared/modules/perspectives/types'\n\n/**\n * Setting groups the unsaved-changes comparison tracks.\n *\n * `pageSize` is deliberately absent: `DataTable` never emits it from\n * `getCurrentSettings()`, so comparing it would report a permanent\n * false-positive for every saved view that carries one.\n */\nexport type DataTableViewSettingKey =\n | 'columnOrder'\n | 'columnVisibility'\n | 'columnSizing'\n | 'sorting'\n | 'filters'\n | 'searchValue'\n\n/** Read-only view state handed to `DataTableProps.onColumnsDirtyChange`. */\nexport type DataTableViewDirtyState = {\n /** True when the live table settings differ from the last applied/saved view. */\n isDirty: boolean\n /** Which setting groups differ, in a stable order. */\n changedKeys: DataTableViewSettingKey[]\n /** `changedKeys.length` \u2014 ready to render as a badge counter. */\n changedCount: number\n /** The currently applied perspective (personal or role), if any. */\n activePerspectiveId: string | null\n /**\n * True when a save can target the active personal view without asking for a\n * name. Role perspectives and \"No view\" both yield false \u2014 those need the\n * views sidebar (or an explicit `name`) to save.\n */\n canSaveToActiveView: boolean\n}\n\nfunction stableStringify(value: unknown): string {\n if (value === null || typeof value !== 'object') return JSON.stringify(value ?? null)\n if (Array.isArray(value)) return `[${value.map((entry) => stableStringify(entry)).join(',')}]`\n const entries = Object.entries(value as Record<string, unknown>)\n .filter(([, entryValue]) => entryValue !== undefined)\n .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))\n return `{${entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`).join(',')}}`\n}\n\nfunction normalizeOrder(order: string[] | undefined, fallback: string[]): string[] {\n return Array.isArray(order) && order.length > 0 ? order : fallback\n}\n\n/**\n * `true` entries are equivalent to an absent entry: the column chooser deletes a\n * column's record when it is turned back on, so `{ name: true }` and `{}` both\n * mean \"name is visible\".\n */\nfunction normalizeVisibility(visibility: Record<string, boolean> | undefined): Record<string, boolean> {\n if (!visibility) return {}\n const result: Record<string, boolean> = {}\n for (const [key, value] of Object.entries(visibility)) {\n if (value === false) result[key] = false\n }\n return result\n}\n\nfunction normalizeSizing(sizing: Record<string, number> | undefined): Record<string, number> {\n if (!sizing) return {}\n const result: Record<string, number> = {}\n for (const [key, value] of Object.entries(sizing)) {\n if (typeof value === 'number' && Number.isFinite(value)) result[key] = Math.round(value)\n }\n return result\n}\n\nfunction normalizeSorting(sorting: PerspectiveSettings['sorting']): Array<{ id: string; desc: boolean }> {\n if (!Array.isArray(sorting)) return []\n return sorting.map((entry) => ({ id: String(entry?.id ?? ''), desc: entry?.desc === true }))\n}\n\nfunction normalizeSearch(searchValue: string | undefined): string {\n return typeof searchValue === 'string' ? searchValue.trim() : ''\n}\n\nexport type DiffPerspectiveSettingsOptions = {\n /**\n * Column ids in their natural (unordered) sequence. Used as the baseline for\n * `columnOrder` when neither side stored one, so a table that merely\n * materialized its default order is not reported as dirty.\n */\n defaultColumnOrder?: string[]\n}\n\n/**\n * Compare the live table settings against the settings of the last applied or\n * saved view and return the setting groups that differ.\n *\n * Both sides are expected to be sanitized (`sanitizePerspectiveSettings`), and\n * the comparison is intentionally value-based rather than reference-based so a\n * re-render alone never flips the result.\n */\nexport function diffPerspectiveSettings(\n base: PerspectiveSettings | null | undefined,\n current: PerspectiveSettings | null | undefined,\n options?: DiffPerspectiveSettingsOptions,\n): DataTableViewSettingKey[] {\n const baseSettings = base ?? {}\n const currentSettings = current ?? {}\n const defaultColumnOrder = options?.defaultColumnOrder ?? []\n const changed: DataTableViewSettingKey[] = []\n\n const baseOrder = normalizeOrder(baseSettings.columnOrder, defaultColumnOrder)\n const currentOrder = normalizeOrder(currentSettings.columnOrder, defaultColumnOrder)\n if (stableStringify(baseOrder) !== stableStringify(currentOrder)) changed.push('columnOrder')\n\n if (\n stableStringify(normalizeVisibility(baseSettings.columnVisibility))\n !== stableStringify(normalizeVisibility(currentSettings.columnVisibility))\n ) changed.push('columnVisibility')\n\n if (\n stableStringify(normalizeSizing(baseSettings.columnSizing))\n !== stableStringify(normalizeSizing(currentSettings.columnSizing))\n ) changed.push('columnSizing')\n\n if (\n stableStringify(normalizeSorting(baseSettings.sorting))\n !== stableStringify(normalizeSorting(currentSettings.sorting))\n ) changed.push('sorting')\n\n if (stableStringify(baseSettings.filters ?? null) !== stableStringify(currentSettings.filters ?? null)) {\n changed.push('filters')\n }\n\n if (normalizeSearch(baseSettings.searchValue) !== normalizeSearch(currentSettings.searchValue)) {\n changed.push('searchValue')\n }\n\n return changed\n}\n"],
5
+ "mappings": "AAmCA,SAAS,gBAAgB,OAAwB;AAC/C,MAAI,UAAU,QAAQ,OAAO,UAAU,SAAU,QAAO,KAAK,UAAU,SAAS,IAAI;AACpF,MAAI,MAAM,QAAQ,KAAK,EAAG,QAAO,IAAI,MAAM,IAAI,CAAC,UAAU,gBAAgB,KAAK,CAAC,EAAE,KAAK,GAAG,CAAC;AAC3F,QAAM,UAAU,OAAO,QAAQ,KAAgC,EAC5D,OAAO,CAAC,CAAC,EAAE,UAAU,MAAM,eAAe,MAAS,EACnD,KAAK,CAAC,CAAC,IAAI,GAAG,CAAC,KAAK,MAAO,OAAO,QAAQ,KAAK,OAAO,QAAQ,IAAI,CAAE;AACvE,SAAO,IAAI,QAAQ,IAAI,CAAC,CAAC,KAAK,UAAU,MAAM,GAAG,KAAK,UAAU,GAAG,CAAC,IAAI,gBAAgB,UAAU,CAAC,EAAE,EAAE,KAAK,GAAG,CAAC;AAClH;AAEA,SAAS,eAAe,OAA6B,UAA8B;AACjF,SAAO,MAAM,QAAQ,KAAK,KAAK,MAAM,SAAS,IAAI,QAAQ;AAC5D;AAOA,SAAS,oBAAoB,YAA0E;AACrG,MAAI,CAAC,WAAY,QAAO,CAAC;AACzB,QAAM,SAAkC,CAAC;AACzC,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,UAAU,GAAG;AACrD,QAAI,UAAU,MAAO,QAAO,GAAG,IAAI;AAAA,EACrC;AACA,SAAO;AACT;AAEA,SAAS,gBAAgB,QAAoE;AAC3F,MAAI,CAAC,OAAQ,QAAO,CAAC;AACrB,QAAM,SAAiC,CAAC;AACxC,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,MAAM,GAAG;AACjD,QAAI,OAAO,UAAU,YAAY,OAAO,SAAS,KAAK,EAAG,QAAO,GAAG,IAAI,KAAK,MAAM,KAAK;AAAA,EACzF;AACA,SAAO;AACT;AAEA,SAAS,iBAAiB,SAA+E;AACvG,MAAI,CAAC,MAAM,QAAQ,OAAO,EAAG,QAAO,CAAC;AACrC,SAAO,QAAQ,IAAI,CAAC,WAAW,EAAE,IAAI,OAAO,OAAO,MAAM,EAAE,GAAG,MAAM,OAAO,SAAS,KAAK,EAAE;AAC7F;AAEA,SAAS,gBAAgB,aAAyC;AAChE,SAAO,OAAO,gBAAgB,WAAW,YAAY,KAAK,IAAI;AAChE;AAmBO,SAAS,wBACd,MACA,SACA,SAC2B;AAC3B,QAAM,eAAe,QAAQ,CAAC;AAC9B,QAAM,kBAAkB,WAAW,CAAC;AACpC,QAAM,qBAAqB,SAAS,sBAAsB,CAAC;AAC3D,QAAM,UAAqC,CAAC;AAE5C,QAAM,YAAY,eAAe,aAAa,aAAa,kBAAkB;AAC7E,QAAM,eAAe,eAAe,gBAAgB,aAAa,kBAAkB;AACnF,MAAI,gBAAgB,SAAS,MAAM,gBAAgB,YAAY,EAAG,SAAQ,KAAK,aAAa;AAE5F,MACE,gBAAgB,oBAAoB,aAAa,gBAAgB,CAAC,MAC9D,gBAAgB,oBAAoB,gBAAgB,gBAAgB,CAAC,EACzE,SAAQ,KAAK,kBAAkB;AAEjC,MACE,gBAAgB,gBAAgB,aAAa,YAAY,CAAC,MACtD,gBAAgB,gBAAgB,gBAAgB,YAAY,CAAC,EACjE,SAAQ,KAAK,cAAc;AAE7B,MACE,gBAAgB,iBAAiB,aAAa,OAAO,CAAC,MAClD,gBAAgB,iBAAiB,gBAAgB,OAAO,CAAC,EAC7D,SAAQ,KAAK,SAAS;AAExB,MAAI,gBAAgB,aAAa,WAAW,IAAI,MAAM,gBAAgB,gBAAgB,WAAW,IAAI,GAAG;AACtG,YAAQ,KAAK,SAAS;AAAA,EACxB;AAEA,MAAI,gBAAgB,aAAa,WAAW,MAAM,gBAAgB,gBAAgB,WAAW,GAAG;AAC9F,YAAQ,KAAK,aAAa;AAAA,EAC5B;AAEA,SAAO;AACT;",
6
+ "names": []
7
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/ui",
3
- "version": "0.6.8-develop.6914.1.6c7a5dcb6b",
3
+ "version": "0.6.8-develop.6916.1.f04fff3436",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -155,14 +155,14 @@
155
155
  "remark-gfm": "^4.0.1"
156
156
  },
157
157
  "peerDependencies": {
158
- "@open-mercato/shared": "0.6.8-develop.6914.1.6c7a5dcb6b",
158
+ "@open-mercato/shared": "0.6.8-develop.6916.1.f04fff3436",
159
159
  "react": ">=18.0.0",
160
160
  "react-dom": ">=18.0.0",
161
161
  "react-is": ">=18.0.0"
162
162
  },
163
163
  "devDependencies": {
164
164
  "@figma/code-connect": "^1.3.4",
165
- "@open-mercato/shared": "0.6.8-develop.6914.1.6c7a5dcb6b",
165
+ "@open-mercato/shared": "0.6.8-develop.6916.1.f04fff3436",
166
166
  "@testing-library/dom": "^10.4.1",
167
167
  "@testing-library/jest-dom": "^7.0.0",
168
168
  "@testing-library/react": "^16.3.1",
@@ -4,7 +4,7 @@ import { useRouter } from 'next/navigation'
4
4
  import { flexRender, type RowData, type SortingState, type ColumnVisibilityState as VisibilityState, type RowSelectionState } from '@tanstack/react-table'
5
5
  import { useLegacyTable, getCoreRowModel, getSortedRowModel, type LegacyColumnDef as ColumnDef, type LegacyColumn as TableColumn } from '@tanstack/react-table/legacy'
6
6
  import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
7
- import { RefreshCw, Loader2, SlidersHorizontal, MoreHorizontal, Circle, Filter, Columns3, ChevronUp, ChevronDown, ChevronsUpDown, Check, Inbox } from 'lucide-react'
7
+ import { RefreshCw, Loader2, SlidersHorizontal, MoreHorizontal, Circle, Filter, Columns3, ChevronUp, ChevronDown, ChevronsUpDown, Check, Inbox, Save } from 'lucide-react'
8
8
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../primitives/table'
9
9
  import { Button } from '../primitives/button'
10
10
  import { Checkbox } from '../primitives/checkbox'
@@ -46,6 +46,7 @@ import { readVersionedPreference, writeVersionedPreference, clearVersionedPrefer
46
46
  import { useT } from '@open-mercato/shared/lib/i18n/context'
47
47
  import { flash } from './FlashMessages'
48
48
  import { useConfirmDialog } from './confirm-dialog'
49
+ import { surfaceRecordConflict } from './conflicts'
49
50
  import type {
50
51
  PerspectiveDto,
51
52
  RolePerspectiveDto,
@@ -97,10 +98,13 @@ import {
97
98
  import { CSS } from '@dnd-kit/utilities'
98
99
  import { createLogger } from '@open-mercato/shared/lib/logger'
99
100
  import { clearAllPerspectiveState, PERSPECTIVE_COOKIE_PREFIX, PERSPECTIVE_STORAGE_PREFIX } from './perspectiveState'
101
+ import { diffPerspectiveSettings } from './perspectiveDirty'
102
+ import type { DataTableViewDirtyState, DataTableViewSettingKey } from './perspectiveDirty'
100
103
 
101
104
  // Re-exported so `@open-mercato/ui/backend/DataTable` stays the published import
102
105
  // path for the purge (BACKWARD_COMPATIBILITY: import paths are a contract surface).
103
106
  export { clearAllPerspectiveState }
107
+ export type { DataTableViewDirtyState, DataTableViewSettingKey }
104
108
 
105
109
  const logger = createLogger('ui').child({ component: 'DataTable' })
106
110
 
@@ -216,6 +220,61 @@ export type DataTablePerspectiveConfig = {
216
220
  }
217
221
  }
218
222
 
223
+ /** Input for the imperative `saveCurrentView` action. */
224
+ export type DataTableSaveViewInput = {
225
+ /**
226
+ * Name for the saved view. Optional when a personal view is active — the
227
+ * active view's own name is reused and the save updates it in place.
228
+ */
229
+ name?: string
230
+ /**
231
+ * Target view id. Defaults to the active personal view; pass `null` to force
232
+ * creating a new view (then `name` is required).
233
+ */
234
+ perspectiveId?: string | null
235
+ /** Marks the saved view as the user's default. Defaults to the target's current flag. */
236
+ isDefault?: boolean
237
+ }
238
+
239
+ /**
240
+ * Outcome of `saveCurrentView`. Returned rather than thrown so a host toolbar
241
+ * button can branch on `reason` (e.g. open the views sidebar to ask for a name)
242
+ * without wrapping every call in try/catch.
243
+ */
244
+ export type DataTableSaveViewResult =
245
+ | { ok: true; perspectiveId: string | null }
246
+ | {
247
+ ok: false
248
+ /**
249
+ * `not-ready` — the perspectives permission check has not resolved yet.
250
+ * Nothing was saved and nothing is wrong: retry once the table has
251
+ * settled. Distinct from `perspectives-disabled`, which is a definitive
252
+ * "this user may not use views" and should be surfaced as such.
253
+ */
254
+ reason: 'perspectives-disabled' | 'not-ready' | 'name-required' | 'failed'
255
+ error?: unknown
256
+ }
257
+
258
+ /**
259
+ * Imperative handle exposed through `DataTableProps.viewApiRef`, so a host app
260
+ * can build its own "Save view" affordance without patching this component.
261
+ */
262
+ export type DataTableViewApi = {
263
+ /** The live table settings, in the shape a perspective persists. */
264
+ getCurrentSettings: () => PerspectiveSettings
265
+ /** The current unsaved-changes state — the pull counterpart of `onColumnsDirtyChange`. */
266
+ getDirtyState: () => DataTableViewDirtyState
267
+ /** Persists the live settings into a saved view. */
268
+ saveCurrentView: (input?: DataTableSaveViewInput) => Promise<DataTableSaveViewResult>
269
+ /**
270
+ * Opens the built-in views sidebar (where a new view can be named and saved).
271
+ * No-op for a user without the `perspectives.use` feature — the sidebar closes
272
+ * again on the next render — so a host offering this action should gate it on
273
+ * the same permission rather than expecting an error.
274
+ */
275
+ openViewsSidebar: () => void
276
+ }
277
+
219
278
  export type BulkAction<T = Record<string, unknown>> = {
220
279
  id: string
221
280
  label: string
@@ -259,6 +318,26 @@ export type DataTableProps<T extends RowData> = {
259
318
  entityIds?: string[]
260
319
  exporter?: DataTableExportConfig | false
261
320
  perspective?: DataTablePerspectiveConfig
321
+ /**
322
+ * Notified whenever the unsaved-changes state of the current view changes.
323
+ * Read-only: it never alters the table's own behavior, it lets a host render
324
+ * its own indicator ("3 unsaved changes") next to a custom save affordance.
325
+ * Only fires for tables that wire `perspective`.
326
+ */
327
+ onColumnsDirtyChange?: (state: DataTableViewDirtyState) => void
328
+ /**
329
+ * Imperative handle for the view/perspective surface (`saveCurrentView`,
330
+ * `getDirtyState`, `getCurrentSettings`, `openViewsSidebar`). Pairs with
331
+ * `onColumnsDirtyChange` so a host can render a "Save view" button in its own
332
+ * toolbar instead of relying on the built-in one.
333
+ */
334
+ viewApiRef?: React.Ref<DataTableViewApi | null>
335
+ /**
336
+ * Renders the built-in "Save view" toolbar button next to the views switcher.
337
+ * Off by default — the perspectives sidebar stays the default save path, so
338
+ * existing call sites are unaffected. Requires `perspective`.
339
+ */
340
+ showSaveViewButton?: boolean
262
341
  embedded?: boolean
263
342
  onCustomFieldFilterFieldsetChange?: (fieldset: string | null, entityId?: string) => void
264
343
  customFieldFilterKeyExtras?: Array<string | number | boolean | null | undefined>
@@ -372,6 +451,8 @@ const EXPORT_LABELS: Record<DataTableExportFormat, string> = {
372
451
  }
373
452
  const EMPTY_FILTER_DEFS: FilterDef[] = []
374
453
  const EMPTY_FILTER_VALUES: FilterValues = Object.freeze({}) as FilterValues
454
+ /** Stand-in for the live view settings on tables that never opt into the view API. */
455
+ const EMPTY_VIEW_SETTINGS: PerspectiveSettings = Object.freeze({}) as PerspectiveSettings
375
456
 
376
457
  // Directional shadow utilities for sticky table cells. `border-collapse: collapse`
377
458
  // blocks `box-shadow` on `<td>`/`<th>`, so we paint the shadow as a pseudo-element
@@ -1146,6 +1227,9 @@ export function DataTable<T extends RowData>({
1146
1227
  entityIds,
1147
1228
  exporter,
1148
1229
  perspective,
1230
+ onColumnsDirtyChange,
1231
+ viewApiRef,
1232
+ showSaveViewButton = false,
1149
1233
  embedded = false,
1150
1234
  onCustomFieldFilterFieldsetChange,
1151
1235
  customFieldFilterKeyExtras,
@@ -1239,8 +1323,16 @@ export function DataTable<T extends RowData>({
1239
1323
  // hydration mismatch. Initial render uses only props-derived state (identical on both sides).
1240
1324
  const initialSnapshotRef = React.useRef<PerspectiveSnapshot | null>(null)
1241
1325
  const snapshotHydratedTableRef = React.useRef<string | null>(null)
1242
- const initialSettingsFromConfig = sanitizePerspectiveSettings(perspectiveConfig?.initialState?.initialSettings ?? null)
1243
- const mergedInitialSettings = initialSettingsFromConfig
1326
+ const initialSettingsSource = perspectiveConfig?.initialState?.initialSettings ?? null
1327
+ // Memoized on the host's own object: `sanitizePerspectiveSettings` returns a
1328
+ // fresh result on every call, so without this every effect keyed on the
1329
+ // sanitized settings would re-run after every render — including the one that
1330
+ // seeds the view baseline, which would then keep resetting an applied view's
1331
+ // baseline back to the server-supplied initial settings.
1332
+ const mergedInitialSettings = React.useMemo(
1333
+ () => sanitizePerspectiveSettings(initialSettingsSource),
1334
+ [initialSettingsSource],
1335
+ )
1244
1336
  const initialActiveId = perspectiveConfig?.initialState?.activePerspectiveId ?? null
1245
1337
  const [isPerspectiveOpen, setPerspectiveOpen] = React.useState(false)
1246
1338
  const [isAdvancedFilterOpen, setAdvancedFilterOpen] = React.useState(false)
@@ -1255,6 +1347,22 @@ export function DataTable<T extends RowData>({
1255
1347
  const [deletingIds, setDeletingIds] = React.useState<string[]>([])
1256
1348
  const [roleClearingIds, setRoleClearingIds] = React.useState<string[]>([])
1257
1349
  const [perspectiveApiMissing, setPerspectiveApiMissing] = React.useState(false)
1350
+ // Settings the current view was last applied from (mount-time snapshot restore,
1351
+ // perspective activation, "No view" clear, successful save). The public
1352
+ // dirty state is measured against this, so a table only reports unsaved
1353
+ // changes once the user actually changes something after that point.
1354
+ const [viewBaseline, setViewBaselineState] = React.useState<PerspectiveSettings>(() => mergedInitialSettings ?? {})
1355
+ const viewBaselineInitializedRef = React.useRef(Boolean(mergedInitialSettings))
1356
+ const setViewBaseline = React.useCallback((settings: PerspectiveSettings) => {
1357
+ const initialized = viewBaselineInitializedRef.current
1358
+ viewBaselineInitializedRef.current = true
1359
+ // Compared by value, not by identity: the callers hand over freshly
1360
+ // sanitized objects (a new one on every render), so storing them blindly
1361
+ // would let an effect keyed on those settings re-trigger itself forever.
1362
+ setViewBaselineState((previous) => (
1363
+ initialized && diffPerspectiveSettings(previous, settings).length === 0 ? previous : settings
1364
+ ))
1365
+ }, [])
1258
1366
 
1259
1367
  const perspectiveFeatureQuery = useQuery<{ use: boolean; roleDefaults: boolean }>({
1260
1368
  queryKey: ['feature-check', 'perspectives'],
@@ -1304,9 +1412,16 @@ export function DataTable<T extends RowData>({
1304
1412
  }
1305
1413
  }, [canUsePerspectives, isPerspectiveOpen])
1306
1414
 
1415
+ // Seeded once per table, like the localStorage hydration above. The server's
1416
+ // initial settings describe the state the table mounts in, not a state to
1417
+ // return to: re-running this after the user activates a view would overwrite
1418
+ // that view's baseline and report changes nobody made.
1419
+ const initialSettingsSeededTableRef = React.useRef<string | null>(null)
1307
1420
  React.useEffect(() => {
1308
1421
  if (!perspectiveTableId) return
1309
1422
  if (!mergedInitialSettings) return
1423
+ if (initialSettingsSeededTableRef.current === perspectiveTableId) return
1424
+ initialSettingsSeededTableRef.current = perspectiveTableId
1310
1425
  const snapshot: PerspectiveSnapshot = {
1311
1426
  perspectiveId: initialActiveId,
1312
1427
  settings: mergedInitialSettings,
@@ -1314,7 +1429,8 @@ export function DataTable<T extends RowData>({
1314
1429
  }
1315
1430
  writePerspectiveSnapshot(perspectiveTableId, snapshot)
1316
1431
  initialSnapshotRef.current = snapshot
1317
- }, [perspectiveTableId, mergedInitialSettings, initialActiveId])
1432
+ setViewBaseline(mergedInitialSettings)
1433
+ }, [perspectiveTableId, mergedInitialSettings, initialActiveId, setViewBaseline])
1318
1434
 
1319
1435
  const perspectiveQuery = useQuery<PerspectivesIndexResponse>({
1320
1436
  queryKey: ['table-perspectives', perspectiveTableId],
@@ -1777,6 +1893,15 @@ export function DataTable<T extends RowData>({
1777
1893
  },
1778
1894
  ) => {
1779
1895
  const normalized = sanitizePerspectiveSettings(settings) ?? {}
1896
+ // `preserveAdvancedFilter` leaves the host's filter state untouched, so the
1897
+ // applied settings' `filters` never become the live ones — keeping the live
1898
+ // payload here is what stops the mount-time restore from reporting a
1899
+ // filter change the user never made.
1900
+ setViewBaseline(
1901
+ options?.preserveAdvancedFilter
1902
+ ? { ...normalized, filters: getCurrentSettings().filters }
1903
+ : normalized,
1904
+ )
1780
1905
  if (normalized.columnOrder && normalized.columnOrder.length) {
1781
1906
  setColumnOrder(normalized.columnOrder)
1782
1907
  } else {
@@ -1855,7 +1980,7 @@ export function DataTable<T extends RowData>({
1855
1980
  initialSnapshotRef.current = null
1856
1981
  }
1857
1982
  }
1858
- }, [onFiltersApply, onSearchChange, onSortingChange, perspectiveTableId, table, advancedFilter])
1983
+ }, [onFiltersApply, onSearchChange, onSortingChange, perspectiveTableId, table, advancedFilter, getCurrentSettings, setViewBaseline])
1859
1984
 
1860
1985
  // Persist the current column widths into the local snapshot so they survive a
1861
1986
  // refresh even without saving a perspective (#1835). Widths are merged into the
@@ -2190,6 +2315,155 @@ export function DataTable<T extends RowData>({
2190
2315
  })
2191
2316
  }, [savePerspectiveMutation, activePersonalPerspectiveId])
2192
2317
 
2318
+ const defaultColumnOrderIds = React.useMemo(
2319
+ () => table.getAllLeafColumns().map((column) => column.id),
2320
+ [table, mergedColumns],
2321
+ )
2322
+ // Nothing consumes the dirty state unless the host asked for it, and computing
2323
+ // it is not free: `getCurrentSettings` serializes the advanced-filter tree and
2324
+ // the diff runs six `stableStringify` passes. Tables that never opt in — every
2325
+ // existing call site — must keep paying exactly what they paid before.
2326
+ const viewApiRequested = Boolean(onColumnsDirtyChange || viewApiRef || showSaveViewButton)
2327
+ const currentViewSettings = React.useMemo(
2328
+ () => (viewApiRequested ? getCurrentSettings() : EMPTY_VIEW_SETTINGS),
2329
+ [viewApiRequested, getCurrentSettings],
2330
+ )
2331
+
2332
+ const viewDirtyState = React.useMemo<DataTableViewDirtyState>(() => {
2333
+ // Before the baseline is established there is nothing to compare against, so
2334
+ // the view is reported clean rather than flashing a spurious change. The
2335
+ // active view id is still reported — a host rendering "Viewing: {name}" from
2336
+ // this state should not see a null while only the baseline is pending.
2337
+ if (!viewApiRequested || !perspectiveEnabled || !viewBaselineInitializedRef.current) {
2338
+ return {
2339
+ isDirty: false,
2340
+ changedKeys: [],
2341
+ changedCount: 0,
2342
+ activePerspectiveId,
2343
+ canSaveToActiveView: false,
2344
+ }
2345
+ }
2346
+ const changedKeys = diffPerspectiveSettings(viewBaseline, currentViewSettings, {
2347
+ defaultColumnOrder: defaultColumnOrderIds,
2348
+ })
2349
+ return {
2350
+ isDirty: changedKeys.length > 0,
2351
+ changedKeys,
2352
+ changedCount: changedKeys.length,
2353
+ activePerspectiveId,
2354
+ canSaveToActiveView: Boolean(activePersonalPerspectiveId),
2355
+ }
2356
+ }, [
2357
+ viewApiRequested,
2358
+ perspectiveEnabled,
2359
+ viewBaseline,
2360
+ currentViewSettings,
2361
+ defaultColumnOrderIds,
2362
+ activePerspectiveId,
2363
+ activePersonalPerspectiveId,
2364
+ ])
2365
+
2366
+ // A page can hand the table its own starting point — default sorting, a search
2367
+ // term hydrated from the URL — none of which is a user change. Whatever the
2368
+ // table settled on before anything was applied is the baseline; the snapshot
2369
+ // restore in `applyPerspectiveSettings` runs in a layout effect and claims the
2370
+ // baseline first when it has one, so this never overwrites a restored view.
2371
+ React.useEffect(() => {
2372
+ if (!viewApiRequested || !perspectiveEnabled || viewBaselineInitializedRef.current) return
2373
+ setViewBaseline(currentViewSettings)
2374
+ }, [viewApiRequested, perspectiveEnabled, currentViewSettings, setViewBaseline])
2375
+
2376
+ // Mirrored in a layout effect rather than during render: a render React throws
2377
+ // away must not leave these mirrors holding values that were never committed.
2378
+ // Layout timing keeps them current for the imperative handle below, which is
2379
+ // itself established in a layout effect declared after this one.
2380
+ const viewDirtyStateRef = React.useRef(viewDirtyState)
2381
+ const onColumnsDirtyChangeRef = React.useRef(onColumnsDirtyChange)
2382
+ React.useLayoutEffect(() => {
2383
+ viewDirtyStateRef.current = viewDirtyState
2384
+ onColumnsDirtyChangeRef.current = onColumnsDirtyChange
2385
+ }, [viewDirtyState, onColumnsDirtyChange])
2386
+ const lastDirtySignatureRef = React.useRef<string | null>(null)
2387
+ React.useEffect(() => {
2388
+ const notify = onColumnsDirtyChangeRef.current
2389
+ if (!notify || !perspectiveEnabled) return
2390
+ // Compared by value, not by object identity: a host that recreates the
2391
+ // callback (or receives a fresh state object each render) must not be able
2392
+ // to turn its own setState into a notification loop.
2393
+ const signature = JSON.stringify([
2394
+ viewDirtyState.isDirty,
2395
+ viewDirtyState.changedKeys,
2396
+ viewDirtyState.activePerspectiveId,
2397
+ viewDirtyState.canSaveToActiveView,
2398
+ ])
2399
+ if (lastDirtySignatureRef.current === signature) return
2400
+ lastDirtySignatureRef.current = signature
2401
+ notify(viewDirtyState)
2402
+ }, [viewDirtyState, perspectiveEnabled])
2403
+
2404
+ const saveCurrentView = React.useCallback(async (
2405
+ input?: DataTableSaveViewInput,
2406
+ ): Promise<DataTableSaveViewResult> => {
2407
+ if (!perspectiveTableId) return { ok: false, reason: 'perspectives-disabled' }
2408
+ // The permission check is a query: until it resolves, "may this user save a
2409
+ // view" is unknown rather than false. Reporting `perspectives-disabled` here
2410
+ // would have a host tell the user views are off when they are merely slow.
2411
+ if (perspectivePermissions === undefined) return { ok: false, reason: 'not-ready' }
2412
+ if (!canUsePerspectives) return { ok: false, reason: 'perspectives-disabled' }
2413
+ const targetId = input?.perspectiveId !== undefined ? input.perspectiveId : activePersonalPerspectiveId
2414
+ const existing = targetId
2415
+ ? perspectiveData?.perspectives.find((item) => item.id === targetId) ?? null
2416
+ : null
2417
+ const name = (input?.name ?? existing?.name ?? '').trim()
2418
+ // Creating a view needs a name, and this API deliberately does not invent
2419
+ // one — the host either supplies it or sends the user to the sidebar.
2420
+ if (!name) return { ok: false, reason: 'name-required' }
2421
+ try {
2422
+ const saved = await savePerspectiveMutation.mutateAsync({
2423
+ name,
2424
+ isDefault: input?.isDefault ?? existing?.isDefault ?? false,
2425
+ applyToRoles: [],
2426
+ setRoleDefault: false,
2427
+ perspectiveId: targetId ?? null,
2428
+ })
2429
+ return { ok: true, perspectiveId: saved?.perspective?.id ?? null }
2430
+ } catch (error) {
2431
+ return { ok: false, reason: 'failed', error }
2432
+ }
2433
+ }, [
2434
+ canUsePerspectives,
2435
+ perspectivePermissions,
2436
+ perspectiveTableId,
2437
+ activePersonalPerspectiveId,
2438
+ perspectiveData,
2439
+ savePerspectiveMutation,
2440
+ ])
2441
+
2442
+ React.useImperativeHandle(viewApiRef, () => ({
2443
+ getCurrentSettings: () => getCurrentSettings(),
2444
+ getDirtyState: () => viewDirtyStateRef.current,
2445
+ saveCurrentView,
2446
+ openViewsSidebar: () => setPerspectiveOpen(true),
2447
+ }), [getCurrentSettings, saveCurrentView])
2448
+
2449
+ const handleSaveViewClick = React.useCallback(async () => {
2450
+ const result = await saveCurrentView()
2451
+ if (result.ok) {
2452
+ flash(t('ui.dataTable.saveView.success', 'View saved'), 'success')
2453
+ return
2454
+ }
2455
+ if (result.reason === 'name-required') {
2456
+ // No personal view is active, so the save needs a name: hand the user the
2457
+ // existing sidebar flow rather than inventing one.
2458
+ setPerspectiveOpen(true)
2459
+ return
2460
+ }
2461
+ if (result.reason === 'failed') {
2462
+ if (surfaceRecordConflict(result.error, t)) return
2463
+ flash(t('ui.dataTable.saveView.error', 'Failed to save view'), 'error')
2464
+ }
2465
+ }, [saveCurrentView, t])
2466
+
2193
2467
  const handlePerspectiveDelete = React.useCallback(async (perspectiveId: string) => {
2194
2468
  await deletePerspectiveMutation.mutateAsync({ perspectiveId })
2195
2469
  }, [deletePerspectiveMutation])
@@ -2767,10 +3041,34 @@ export function DataTable<T extends RowData>({
2767
3041
  </div>
2768
3042
  )
2769
3043
  : null
2770
- const leadingItems = advancedFilterButton || perspectiveButton ? (
3044
+ const saveViewButton = showSaveViewButton && canUsePerspectives ? (
3045
+ <Button
3046
+ type="button"
3047
+ variant="outline"
3048
+ size="default"
3049
+ disabled={!viewDirtyState.isDirty || savePerspectiveMutation.isPending}
3050
+ onClick={() => { void handleSaveViewClick() }}
3051
+ title={viewDirtyState.isDirty
3052
+ ? t('ui.dataTable.saveView.title', 'Save the current view')
3053
+ : t('ui.dataTable.saveView.noChanges', 'No unsaved changes')}
3054
+ data-testid="save-view-trigger"
3055
+ >
3056
+ {savePerspectiveMutation.isPending
3057
+ ? <Loader2 className="h-4 w-4 animate-spin" />
3058
+ : <Save className="h-4 w-4" />}
3059
+ <span>{t('ui.dataTable.saveView.button', 'Save view')}</span>
3060
+ {viewDirtyState.changedCount > 0 ? (
3061
+ <span className="ml-1 inline-flex h-5 min-w-5 px-1.5 items-center justify-center rounded-full bg-muted-foreground/30 text-background text-xs">
3062
+ {viewDirtyState.changedCount}
3063
+ </span>
3064
+ ) : null}
3065
+ </Button>
3066
+ ) : null
3067
+ const leadingItems = advancedFilterButton || perspectiveButton || saveViewButton ? (
2771
3068
  <div className="flex items-center gap-2">
2772
3069
  {advancedFilterButton}
2773
3070
  {perspectiveButton}
3071
+ {saveViewButton}
2774
3072
  </div>
2775
3073
  ) : null
2776
3074
  const trailingItems = hasBulkButtons ? (
@@ -2869,6 +3167,11 @@ export function DataTable<T extends RowData>({
2869
3167
  advancedFilterRuleCount,
2870
3168
  isAdvancedFilterOpen,
2871
3169
  resolvedAdvancedFilterFields,
3170
+ showSaveViewButton,
3171
+ viewDirtyState,
3172
+ savePerspectiveMutation.isPending,
3173
+ handleSaveViewClick,
3174
+ t,
2872
3175
  ])
2873
3176
 
2874
3177
  const hasTitle = title != null