@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,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
+ })