@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +1 -0
- package/dist/backend/DataTable.js +167 -8
- package/dist/backend/DataTable.js.map +3 -3
- package/dist/backend/perspectiveDirty.js +55 -0
- package/dist/backend/perspectiveDirty.js.map +7 -0
- package/package.json +3 -3
- package/src/backend/DataTable.tsx +309 -6
- package/src/backend/__tests__/DataTable.saveViewApi.test.tsx +474 -0
- package/src/backend/__tests__/perspectiveDirty.test.ts +86 -0
- package/src/backend/perspectiveDirty.ts +136 -0
- package/src/backend/utils/__tests__/nav.itemOrder.test.ts +85 -0
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
|
1243
|
-
|
|
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
|
-
|
|
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
|
|
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
|