@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,136 @@
|
|
|
1
|
+
import type { PerspectiveSettings } from '@open-mercato/shared/modules/perspectives/types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Setting groups the unsaved-changes comparison tracks.
|
|
5
|
+
*
|
|
6
|
+
* `pageSize` is deliberately absent: `DataTable` never emits it from
|
|
7
|
+
* `getCurrentSettings()`, so comparing it would report a permanent
|
|
8
|
+
* false-positive for every saved view that carries one.
|
|
9
|
+
*/
|
|
10
|
+
export type DataTableViewSettingKey =
|
|
11
|
+
| 'columnOrder'
|
|
12
|
+
| 'columnVisibility'
|
|
13
|
+
| 'columnSizing'
|
|
14
|
+
| 'sorting'
|
|
15
|
+
| 'filters'
|
|
16
|
+
| 'searchValue'
|
|
17
|
+
|
|
18
|
+
/** Read-only view state handed to `DataTableProps.onColumnsDirtyChange`. */
|
|
19
|
+
export type DataTableViewDirtyState = {
|
|
20
|
+
/** True when the live table settings differ from the last applied/saved view. */
|
|
21
|
+
isDirty: boolean
|
|
22
|
+
/** Which setting groups differ, in a stable order. */
|
|
23
|
+
changedKeys: DataTableViewSettingKey[]
|
|
24
|
+
/** `changedKeys.length` — ready to render as a badge counter. */
|
|
25
|
+
changedCount: number
|
|
26
|
+
/** The currently applied perspective (personal or role), if any. */
|
|
27
|
+
activePerspectiveId: string | null
|
|
28
|
+
/**
|
|
29
|
+
* True when a save can target the active personal view without asking for a
|
|
30
|
+
* name. Role perspectives and "No view" both yield false — those need the
|
|
31
|
+
* views sidebar (or an explicit `name`) to save.
|
|
32
|
+
*/
|
|
33
|
+
canSaveToActiveView: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function stableStringify(value: unknown): string {
|
|
37
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value ?? null)
|
|
38
|
+
if (Array.isArray(value)) return `[${value.map((entry) => stableStringify(entry)).join(',')}]`
|
|
39
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
40
|
+
.filter(([, entryValue]) => entryValue !== undefined)
|
|
41
|
+
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
|
42
|
+
return `{${entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`).join(',')}}`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function normalizeOrder(order: string[] | undefined, fallback: string[]): string[] {
|
|
46
|
+
return Array.isArray(order) && order.length > 0 ? order : fallback
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* `true` entries are equivalent to an absent entry: the column chooser deletes a
|
|
51
|
+
* column's record when it is turned back on, so `{ name: true }` and `{}` both
|
|
52
|
+
* mean "name is visible".
|
|
53
|
+
*/
|
|
54
|
+
function normalizeVisibility(visibility: Record<string, boolean> | undefined): Record<string, boolean> {
|
|
55
|
+
if (!visibility) return {}
|
|
56
|
+
const result: Record<string, boolean> = {}
|
|
57
|
+
for (const [key, value] of Object.entries(visibility)) {
|
|
58
|
+
if (value === false) result[key] = false
|
|
59
|
+
}
|
|
60
|
+
return result
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function normalizeSizing(sizing: Record<string, number> | undefined): Record<string, number> {
|
|
64
|
+
if (!sizing) return {}
|
|
65
|
+
const result: Record<string, number> = {}
|
|
66
|
+
for (const [key, value] of Object.entries(sizing)) {
|
|
67
|
+
if (typeof value === 'number' && Number.isFinite(value)) result[key] = Math.round(value)
|
|
68
|
+
}
|
|
69
|
+
return result
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function normalizeSorting(sorting: PerspectiveSettings['sorting']): Array<{ id: string; desc: boolean }> {
|
|
73
|
+
if (!Array.isArray(sorting)) return []
|
|
74
|
+
return sorting.map((entry) => ({ id: String(entry?.id ?? ''), desc: entry?.desc === true }))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function normalizeSearch(searchValue: string | undefined): string {
|
|
78
|
+
return typeof searchValue === 'string' ? searchValue.trim() : ''
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type DiffPerspectiveSettingsOptions = {
|
|
82
|
+
/**
|
|
83
|
+
* Column ids in their natural (unordered) sequence. Used as the baseline for
|
|
84
|
+
* `columnOrder` when neither side stored one, so a table that merely
|
|
85
|
+
* materialized its default order is not reported as dirty.
|
|
86
|
+
*/
|
|
87
|
+
defaultColumnOrder?: string[]
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Compare the live table settings against the settings of the last applied or
|
|
92
|
+
* saved view and return the setting groups that differ.
|
|
93
|
+
*
|
|
94
|
+
* Both sides are expected to be sanitized (`sanitizePerspectiveSettings`), and
|
|
95
|
+
* the comparison is intentionally value-based rather than reference-based so a
|
|
96
|
+
* re-render alone never flips the result.
|
|
97
|
+
*/
|
|
98
|
+
export function diffPerspectiveSettings(
|
|
99
|
+
base: PerspectiveSettings | null | undefined,
|
|
100
|
+
current: PerspectiveSettings | null | undefined,
|
|
101
|
+
options?: DiffPerspectiveSettingsOptions,
|
|
102
|
+
): DataTableViewSettingKey[] {
|
|
103
|
+
const baseSettings = base ?? {}
|
|
104
|
+
const currentSettings = current ?? {}
|
|
105
|
+
const defaultColumnOrder = options?.defaultColumnOrder ?? []
|
|
106
|
+
const changed: DataTableViewSettingKey[] = []
|
|
107
|
+
|
|
108
|
+
const baseOrder = normalizeOrder(baseSettings.columnOrder, defaultColumnOrder)
|
|
109
|
+
const currentOrder = normalizeOrder(currentSettings.columnOrder, defaultColumnOrder)
|
|
110
|
+
if (stableStringify(baseOrder) !== stableStringify(currentOrder)) changed.push('columnOrder')
|
|
111
|
+
|
|
112
|
+
if (
|
|
113
|
+
stableStringify(normalizeVisibility(baseSettings.columnVisibility))
|
|
114
|
+
!== stableStringify(normalizeVisibility(currentSettings.columnVisibility))
|
|
115
|
+
) changed.push('columnVisibility')
|
|
116
|
+
|
|
117
|
+
if (
|
|
118
|
+
stableStringify(normalizeSizing(baseSettings.columnSizing))
|
|
119
|
+
!== stableStringify(normalizeSizing(currentSettings.columnSizing))
|
|
120
|
+
) changed.push('columnSizing')
|
|
121
|
+
|
|
122
|
+
if (
|
|
123
|
+
stableStringify(normalizeSorting(baseSettings.sorting))
|
|
124
|
+
!== stableStringify(normalizeSorting(currentSettings.sorting))
|
|
125
|
+
) changed.push('sorting')
|
|
126
|
+
|
|
127
|
+
if (stableStringify(baseSettings.filters ?? null) !== stableStringify(currentSettings.filters ?? null)) {
|
|
128
|
+
changed.push('filters')
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (normalizeSearch(baseSettings.searchValue) !== normalizeSearch(currentSettings.searchValue)) {
|
|
132
|
+
changed.push('searchValue')
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return changed
|
|
136
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Characterization tests for `buildAdminNav`'s ordering contract.
|
|
3
|
+
*
|
|
4
|
+
* These lock in behavior that already existed — `buildAdminNav` is not modified by #4845. They
|
|
5
|
+
* exist because the nav payload's serialization boundary (`auth/lib/backendChrome.tsx`) now mirrors
|
|
6
|
+
* this precedence (`priority ?? order`, title tiebreak); if it changes here, the two sorts drift and
|
|
7
|
+
* the payload's `order` field stops matching the sequence it ships items in.
|
|
8
|
+
*
|
|
9
|
+
* The regression coverage for #4845 itself lives in
|
|
10
|
+
* `packages/shared/src/modules/__tests__/route-overrides.test.ts` (the override alias normalization)
|
|
11
|
+
* and `packages/core/src/modules/auth/api/__tests__/admin-nav.test.ts` (the serialized field).
|
|
12
|
+
*/
|
|
13
|
+
import { buildAdminNav } from '../nav'
|
|
14
|
+
|
|
15
|
+
type NavModule = Parameters<typeof buildAdminNav>[0][number]
|
|
16
|
+
|
|
17
|
+
const passthroughTranslate = (_key: string | undefined, fallback: string) => fallback
|
|
18
|
+
|
|
19
|
+
function buildNav(modules: NavModule[]) {
|
|
20
|
+
return buildAdminNav(modules, { auth: { roles: [] } }, [], passthroughTranslate, {
|
|
21
|
+
checkFeatures: async (features) => features,
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('buildAdminNav item ordering', () => {
|
|
26
|
+
it('sorts same-group items by declared order regardless of module registration order', async () => {
|
|
27
|
+
const entries = await buildNav([
|
|
28
|
+
{
|
|
29
|
+
id: 'later_page',
|
|
30
|
+
backendRoutes: [
|
|
31
|
+
{ pattern: '/backend/later/page-b', title: 'Beta page', group: 'Shared', groupKey: 'shared.nav.group', order: 71 },
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: 'earlier_page',
|
|
36
|
+
backendRoutes: [
|
|
37
|
+
{ pattern: '/backend/earlier/page-a', title: 'Alpha page', group: 'Shared', groupKey: 'shared.nav.group', order: 70 },
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
] as NavModule[])
|
|
41
|
+
|
|
42
|
+
expect(entries.map((entry) => entry.href)).toEqual([
|
|
43
|
+
'/backend/earlier/page-a',
|
|
44
|
+
'/backend/later/page-b',
|
|
45
|
+
])
|
|
46
|
+
expect(entries.map((entry) => entry.order)).toEqual([70, 71])
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('sorts nested children by declared order and keeps the parent order intact', async () => {
|
|
50
|
+
const entries = await buildNav([
|
|
51
|
+
{
|
|
52
|
+
id: 'wms',
|
|
53
|
+
backendRoutes: [
|
|
54
|
+
{ pattern: '/backend/wms', title: 'Warehouse', group: 'WMS', groupKey: 'wms.nav.group', order: 95 },
|
|
55
|
+
{ pattern: '/backend/wms/zones', title: 'Zones', group: 'WMS', groupKey: 'wms.nav.group', order: 120 },
|
|
56
|
+
{ pattern: '/backend/wms/inventory', title: 'Inventory', group: 'WMS', groupKey: 'wms.nav.group', order: 100 },
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
] as NavModule[])
|
|
60
|
+
|
|
61
|
+
expect(entries).toHaveLength(1)
|
|
62
|
+
expect(entries[0]?.order).toBe(95)
|
|
63
|
+
expect(entries[0]?.children?.map((child) => child.href)).toEqual([
|
|
64
|
+
'/backend/wms/inventory',
|
|
65
|
+
'/backend/wms/zones',
|
|
66
|
+
])
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('ranks an explicit priority ahead of the declared order', async () => {
|
|
70
|
+
const entries = await buildNav([
|
|
71
|
+
{
|
|
72
|
+
id: 'reports',
|
|
73
|
+
backendRoutes: [
|
|
74
|
+
{ pattern: '/backend/reports/monthly', title: 'Monthly', group: 'Reports', groupKey: 'reports.nav.group', order: 10 },
|
|
75
|
+
{ pattern: '/backend/reports/daily', title: 'Daily', group: 'Reports', groupKey: 'reports.nav.group', order: 20, priority: 1 },
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
] as NavModule[])
|
|
79
|
+
|
|
80
|
+
expect(entries.map((entry) => entry.href)).toEqual([
|
|
81
|
+
'/backend/reports/daily',
|
|
82
|
+
'/backend/reports/monthly',
|
|
83
|
+
])
|
|
84
|
+
})
|
|
85
|
+
})
|