@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,474 @@
1
+ /** @jest-environment jsdom */
2
+ import * as React from 'react'
3
+ import { DataTable, type DataTableViewApi, type DataTableViewDirtyState } from '../DataTable'
4
+ import type { ColumnDef } from '@tanstack/react-table'
5
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
6
+ import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
7
+ import { render, fireEvent, waitFor, screen, act } from '@testing-library/react'
8
+ import type { PerspectiveSettings, PerspectivesIndexResponse } from '@open-mercato/shared/modules/perspectives/types'
9
+
10
+ jest.mock('next/navigation', () => ({
11
+ useRouter: () => ({ push: jest.fn(), replace: jest.fn(), prefetch: jest.fn() }),
12
+ }))
13
+
14
+ jest.mock('../injection/useInjectionDataWidgets', () => ({
15
+ useInjectionDataWidgets: () => ({ widgets: [], isLoading: false }),
16
+ }))
17
+
18
+ jest.mock('../injection/useGuardedMutation', () => ({
19
+ useGuardedMutation: () => ({
20
+ runMutation: async ({ operation }: { operation: () => Promise<unknown> }) => operation(),
21
+ retryLastMutation: async () => false,
22
+ }),
23
+ }))
24
+
25
+ const mockFlash = jest.fn()
26
+ jest.mock('../FlashMessages', () => ({ flash: (...args: unknown[]) => mockFlash(...args) }))
27
+
28
+ const savedPayloads: Array<Record<string, unknown>> = []
29
+ // Flipped by the one case that needs the perspectives permission check to stay
30
+ // in flight; every other case answers it from seeded query data.
31
+ let holdFeatureCheck = false
32
+ const mockApiCall = jest.fn(async (input: unknown, init?: { method?: string; body?: string }) => {
33
+ const url = String(input)
34
+ const method = (init?.method ?? 'GET').toUpperCase()
35
+ if (holdFeatureCheck && url.includes('/api/auth/feature-check')) {
36
+ await new Promise(() => {})
37
+ }
38
+ if (url.includes('/api/perspectives/') && method === 'POST') {
39
+ const payload = JSON.parse(init?.body ?? '{}') as Record<string, unknown>
40
+ savedPayloads.push(payload)
41
+ return {
42
+ ok: true,
43
+ status: 200,
44
+ result: {
45
+ perspective: {
46
+ id: 'persp-new',
47
+ name: payload.name,
48
+ tableId: 'test-table',
49
+ settings: payload.settings ?? {},
50
+ isDefault: false,
51
+ createdAt: 'now',
52
+ updatedAt: '2026-08-06T00:00:00.000Z',
53
+ },
54
+ rolePerspectives: [],
55
+ clearedRoleIds: [],
56
+ },
57
+ response: { ok: true, status: 200 } as Response,
58
+ cacheStatus: null as const,
59
+ }
60
+ }
61
+ return { ok: true, status: 200, result: undefined, response: { ok: true, status: 200 } as Response, cacheStatus: null as const }
62
+ })
63
+ jest.mock('../utils/apiCall', () => ({
64
+ apiCall: (...args: unknown[]) => mockApiCall(...(args as [unknown, { method?: string; body?: string }?])),
65
+ withScopedApiRequestHeaders: async (_headers: Record<string, string>, run: () => Promise<unknown>) => run(),
66
+ }))
67
+
68
+ // The sidebar itself is not under test; the stub reports whether the component
69
+ // asked for it to be opened, and exposes one activation button per view so a
70
+ // test can drive `onActivatePerspective` without going through Radix.
71
+ jest.mock('../PerspectiveSidebar', () => ({
72
+ PerspectiveSidebar: (props: {
73
+ open: boolean
74
+ perspectives?: Array<{ id: string }>
75
+ rolePerspectives?: Array<{ id: string }>
76
+ onActivatePerspective?: (item: unknown, source: 'personal' | 'role') => void
77
+ }) => (
78
+ <div data-testid="perspective-sidebar" data-open={String(props.open)}>
79
+ {(props.perspectives ?? []).map((item) => (
80
+ <button
81
+ key={item.id}
82
+ type="button"
83
+ data-testid={`activate-view-${item.id}`}
84
+ onClick={() => props.onActivatePerspective?.(item, 'personal')}
85
+ />
86
+ ))}
87
+ {(props.rolePerspectives ?? []).map((item) => (
88
+ <button
89
+ key={item.id}
90
+ type="button"
91
+ data-testid={`activate-role-view-${item.id}`}
92
+ onClick={() => props.onActivatePerspective?.(item, 'role')}
93
+ />
94
+ ))}
95
+ </div>
96
+ ),
97
+ }))
98
+
99
+ type Row = { id: string; name: string }
100
+
101
+ const SAVED_VIEW = {
102
+ id: 'persp-1',
103
+ name: 'My view',
104
+ tableId: 'test-table',
105
+ settings: { searchValue: 'acme' },
106
+ isDefault: false,
107
+ createdAt: 'now',
108
+ updatedAt: '2026-08-06T00:00:00.000Z',
109
+ }
110
+
111
+ const ROLE_VIEW = {
112
+ id: 'role-1',
113
+ name: 'Team view',
114
+ tableId: 'test-table',
115
+ settings: { searchValue: 'acme' },
116
+ isDefault: false,
117
+ roleId: 'role-sales',
118
+ roleName: 'Sales',
119
+ createdAt: 'now',
120
+ updatedAt: '2026-08-06T00:00:00.000Z',
121
+ } as unknown as PerspectivesIndexResponse['rolePerspectives'][number]
122
+
123
+ // DataTable auto-activates the first saved view on load, so tests that assert on
124
+ // a pristine "No view" table must start from an empty list.
125
+ const buildIndexResponse = (
126
+ perspectives: PerspectivesIndexResponse['perspectives'],
127
+ rolePerspectives: PerspectivesIndexResponse['rolePerspectives'] = [],
128
+ ): PerspectivesIndexResponse => ({
129
+ tableId: 'test-table',
130
+ perspectives,
131
+ defaultPerspectiveId: null,
132
+ rolePerspectives,
133
+ manageableRolePerspectives: [],
134
+ roles: [],
135
+ canApplyToRoles: false,
136
+ })
137
+
138
+ const columns: ColumnDef<Row>[] = [
139
+ { accessorKey: 'name', header: 'Name' },
140
+ { accessorKey: 'id', header: 'Id' },
141
+ ]
142
+
143
+ type HarnessProps = {
144
+ apiRef?: React.MutableRefObject<DataTableViewApi | null>
145
+ onDirty?: (state: DataTableViewDirtyState) => void
146
+ showSaveViewButton?: boolean
147
+ withPerspective?: boolean
148
+ initialSearchValue?: string
149
+ savedViews?: PerspectivesIndexResponse['perspectives']
150
+ roleViews?: PerspectivesIndexResponse['rolePerspectives']
151
+ initialSettings?: PerspectiveSettings
152
+ /** `'granted'` seeds the permission check, `'denied'` seeds a refusal, `'pending'` leaves it in flight. */
153
+ perspectivesFeature?: 'granted' | 'denied' | 'pending'
154
+ }
155
+
156
+ function renderTable({
157
+ apiRef,
158
+ onDirty,
159
+ showSaveViewButton,
160
+ withPerspective = true,
161
+ initialSearchValue = '',
162
+ savedViews = [],
163
+ roleViews = [],
164
+ initialSettings,
165
+ perspectivesFeature = 'granted',
166
+ }: HarnessProps) {
167
+ const queryClient = new QueryClient({
168
+ defaultOptions: {
169
+ queries: { staleTime: Infinity, gcTime: Infinity, retry: false },
170
+ mutations: { retry: false },
171
+ },
172
+ })
173
+ holdFeatureCheck = perspectivesFeature === 'pending'
174
+ if (perspectivesFeature === 'granted') {
175
+ queryClient.setQueryData(['feature-check', 'perspectives'], { use: true, roleDefaults: true })
176
+ } else if (perspectivesFeature === 'denied') {
177
+ queryClient.setQueryData(['feature-check', 'perspectives'], { use: false, roleDefaults: false })
178
+ }
179
+ queryClient.setQueryData(['table-perspectives', 'test-table'], buildIndexResponse(savedViews, roleViews))
180
+
181
+ function Harness({ searchValue }: { searchValue: string }) {
182
+ return (
183
+ <QueryClientProvider client={queryClient}>
184
+ <I18nProvider locale="en" dict={{}}>
185
+ <DataTable<Row>
186
+ columns={columns}
187
+ data={[]}
188
+ searchValue={searchValue}
189
+ onSearchChange={() => {}}
190
+ perspective={withPerspective
191
+ ? {
192
+ tableId: 'test-table',
193
+ // Rebuilt on every render on purpose: this is what a server
194
+ // component hands down, and what the page re-creates whenever
195
+ // the table re-renders.
196
+ ...(initialSettings ? { initialState: { initialSettings: { ...initialSettings } } } : {}),
197
+ }
198
+ : undefined}
199
+ viewApiRef={apiRef}
200
+ onColumnsDirtyChange={onDirty}
201
+ showSaveViewButton={showSaveViewButton}
202
+ />
203
+ </I18nProvider>
204
+ </QueryClientProvider>
205
+ )
206
+ }
207
+
208
+ const utils = render(<Harness searchValue={initialSearchValue} />)
209
+ return {
210
+ ...utils,
211
+ setSearchValue: (value: string) => utils.rerender(<Harness searchValue={value} />),
212
+ }
213
+ }
214
+
215
+ describe('DataTable public save-view API', () => {
216
+ beforeEach(() => {
217
+ savedPayloads.length = 0
218
+ holdFeatureCheck = false
219
+ mockFlash.mockClear()
220
+ mockApiCall.mockClear()
221
+ // DataTable persists the applied view to localStorage + a cookie, and jsdom
222
+ // shares both across tests in a file — clear them so every case starts from
223
+ // a table that has never had a view applied.
224
+ window.localStorage.clear()
225
+ for (const entry of document.cookie.split(';')) {
226
+ const name = entry.split('=')[0]?.trim()
227
+ if (name) document.cookie = `${name}=; Max-Age=0; Path=/`
228
+ }
229
+ })
230
+
231
+ it('reports a clean view first and then the changed setting groups', async () => {
232
+ const states: DataTableViewDirtyState[] = []
233
+ const { setSearchValue } = renderTable({ onDirty: (state) => { states.push(state) } })
234
+
235
+ await waitFor(() => expect(states.length).toBeGreaterThan(0))
236
+ expect(states[0].isDirty).toBe(false)
237
+ expect(states[0].changedKeys).toEqual([])
238
+
239
+ setSearchValue('acme')
240
+
241
+ await waitFor(() => expect(states[states.length - 1].isDirty).toBe(true))
242
+ const latest = states[states.length - 1]
243
+ expect(latest.changedKeys).toEqual(['searchValue'])
244
+ expect(latest.changedCount).toBe(1)
245
+ expect(latest.canSaveToActiveView).toBe(false)
246
+ })
247
+
248
+ it('does not treat a host-supplied starting state as an unsaved change', async () => {
249
+ const states: DataTableViewDirtyState[] = []
250
+ // The page arrives with a search term already applied (hydrated from the URL,
251
+ // say). That is the starting point, not something the user just changed.
252
+ const apiRef = React.createRef<DataTableViewApi | null>() as React.MutableRefObject<DataTableViewApi | null>
253
+ const { setSearchValue } = renderTable({ apiRef, onDirty: (state) => { states.push(state) }, initialSearchValue: 'acme' })
254
+
255
+ await waitFor(() => expect(apiRef.current).not.toBeNull())
256
+ await waitFor(() => expect(states.length).toBeGreaterThan(0))
257
+ expect(states.every((state) => !state.isDirty)).toBe(true)
258
+
259
+ setSearchValue('other')
260
+ await waitFor(() => expect(apiRef.current?.getDirtyState().isDirty).toBe(true))
261
+ })
262
+
263
+ it('settles a table restored from server-provided initial settings instead of re-rendering forever', async () => {
264
+ // `sanitizePerspectiveSettings` returns a fresh object on every render, so a
265
+ // baseline update that does not compare by value re-triggers its own effect
266
+ // and React tears the tree down with "Maximum update depth exceeded" —
267
+ // taking the whole table (resize handles included) with it.
268
+ const states: DataTableViewDirtyState[] = []
269
+ const apiRef = React.createRef<DataTableViewApi | null>() as React.MutableRefObject<DataTableViewApi | null>
270
+ renderTable({
271
+ apiRef,
272
+ onDirty: (state) => { states.push(state) },
273
+ initialSettings: { columnSizing: { name: 320 } },
274
+ })
275
+
276
+ await waitFor(() => expect(apiRef.current).not.toBeNull())
277
+ await waitFor(() => expect(states.length).toBeGreaterThan(0))
278
+ expect(apiRef.current?.getDirtyState().isDirty).toBe(false)
279
+
280
+ const settledCount = states.length
281
+ await act(async () => { await new Promise((resolve) => setTimeout(resolve, 50)) })
282
+ expect(states.length).toBe(settledCount)
283
+ })
284
+
285
+ it('stays clean after activating a saved view when the server also supplied initial settings', async () => {
286
+ // The SSR "eliminating flicker" path: the host forwards `initialState` from
287
+ // `fetchInitialPerspectiveState`. Those settings describe the mount state,
288
+ // so re-seeding the baseline from them after a view is applied would report
289
+ // changes the user never made — permanently, on every later render.
290
+ const apiRef = React.createRef<DataTableViewApi | null>() as React.MutableRefObject<DataTableViewApi | null>
291
+ renderTable({
292
+ apiRef,
293
+ savedViews: [SAVED_VIEW],
294
+ initialSearchValue: 'acme',
295
+ initialSettings: { columnSizing: { name: 320 } },
296
+ })
297
+ await waitFor(() => expect(apiRef.current).not.toBeNull())
298
+
299
+ act(() => { apiRef.current!.openViewsSidebar() })
300
+ const activate = await screen.findByTestId('activate-view-persp-1')
301
+ await act(async () => { fireEvent.click(activate) })
302
+
303
+ await waitFor(() => expect(apiRef.current?.getDirtyState().activePerspectiveId).toBe('persp-1'))
304
+ expect(apiRef.current?.getDirtyState().changedKeys).toEqual([])
305
+ // The regression re-appeared one render later, so let the tree settle first.
306
+ await act(async () => { await new Promise((resolve) => setTimeout(resolve, 50)) })
307
+ expect(apiRef.current?.getDirtyState().changedKeys).toEqual([])
308
+ expect(apiRef.current?.getDirtyState().isDirty).toBe(false)
309
+ })
310
+
311
+ it('reports a role perspective as active but not saveable in place', async () => {
312
+ const apiRef = React.createRef<DataTableViewApi | null>() as React.MutableRefObject<DataTableViewApi | null>
313
+ const { setSearchValue } = renderTable({ apiRef, roleViews: [ROLE_VIEW], initialSearchValue: 'acme' })
314
+ await waitFor(() => expect(apiRef.current).not.toBeNull())
315
+
316
+ act(() => { apiRef.current!.openViewsSidebar() })
317
+ const activate = await screen.findByTestId('activate-role-view-role-1')
318
+ await act(async () => { fireEvent.click(activate) })
319
+
320
+ await waitFor(() => expect(apiRef.current?.getDirtyState().activePerspectiveId).toBe('role-1'))
321
+ // A role perspective is not the user's to overwrite: saving needs a name,
322
+ // which creates a personal view instead.
323
+ expect(apiRef.current?.getDirtyState().canSaveToActiveView).toBe(false)
324
+
325
+ setSearchValue('other')
326
+ await waitFor(() => expect(apiRef.current?.getDirtyState().isDirty).toBe(true))
327
+ expect(apiRef.current?.getDirtyState().canSaveToActiveView).toBe(false)
328
+ const result = await act(async () => apiRef.current!.saveCurrentView())
329
+ expect(result).toEqual({ ok: false, reason: 'name-required' })
330
+ })
331
+
332
+ it('goes clean again after clearing back to "No view"', async () => {
333
+ const apiRef = React.createRef<DataTableViewApi | null>() as React.MutableRefObject<DataTableViewApi | null>
334
+ // A view carrying only table-owned state, so clearing it actually returns
335
+ // the live settings to empty — the harness owns `searchValue` and would
336
+ // otherwise keep a search term the clear cannot reach.
337
+ renderTable({ apiRef, savedViews: [{ ...SAVED_VIEW, settings: { columnSizing: { name: 320 } } }] })
338
+ await waitFor(() => expect(apiRef.current?.getDirtyState().canSaveToActiveView).toBe(true))
339
+
340
+ // "No view" lives in the views switcher popover, not in the sidebar.
341
+ const trigger = screen.getByTestId('data-table-open-views-sidebar').parentElement!.querySelector('button:last-of-type')!
342
+ await act(async () => { fireEvent.click(trigger) })
343
+ const clear = await screen.findByText('— No view —')
344
+ await act(async () => { fireEvent.click(clear) })
345
+
346
+ await waitFor(() => expect(apiRef.current?.getDirtyState().activePerspectiveId).toBeNull())
347
+ expect(apiRef.current?.getDirtyState().isDirty).toBe(false)
348
+ expect(apiRef.current?.getDirtyState().canSaveToActiveView).toBe(false)
349
+ })
350
+
351
+ it('reports not-ready while the permission check is still in flight', async () => {
352
+ const apiRef = React.createRef<DataTableViewApi | null>() as React.MutableRefObject<DataTableViewApi | null>
353
+ renderTable({ apiRef, perspectivesFeature: 'pending' })
354
+ await waitFor(() => expect(apiRef.current).not.toBeNull())
355
+
356
+ // "Not yet known" must not be reported as "views are disabled" — a host
357
+ // button would otherwise tell the user a permission they hold is missing.
358
+ const result = await act(async () => apiRef.current!.saveCurrentView({ name: 'Wide view' }))
359
+ expect(result).toEqual({ ok: false, reason: 'not-ready' })
360
+ expect(savedPayloads).toHaveLength(0)
361
+ })
362
+
363
+ it('reports perspectives-disabled once the permission check refuses', async () => {
364
+ const apiRef = React.createRef<DataTableViewApi | null>() as React.MutableRefObject<DataTableViewApi | null>
365
+ renderTable({ apiRef, perspectivesFeature: 'denied' })
366
+ await waitFor(() => expect(apiRef.current).not.toBeNull())
367
+
368
+ const result = await act(async () => apiRef.current!.saveCurrentView({ name: 'Wide view' }))
369
+ expect(result).toEqual({ ok: false, reason: 'perspectives-disabled' })
370
+ expect(savedPayloads).toHaveLength(0)
371
+ })
372
+
373
+ it('does not notify tables that do not wire perspectives', async () => {
374
+ const onDirty = jest.fn()
375
+ renderTable({ onDirty, withPerspective: false })
376
+ await waitFor(() => expect(screen.queryByTestId('perspective-sidebar')).toBeNull())
377
+ expect(onDirty).not.toHaveBeenCalled()
378
+ })
379
+
380
+ it('exposes the live settings and dirty state through the imperative handle', async () => {
381
+ const apiRef = React.createRef<DataTableViewApi | null>() as React.MutableRefObject<DataTableViewApi | null>
382
+ const { setSearchValue } = renderTable({ apiRef })
383
+
384
+ await waitFor(() => expect(apiRef.current).not.toBeNull())
385
+ expect(apiRef.current?.getDirtyState().isDirty).toBe(false)
386
+
387
+ setSearchValue('acme')
388
+
389
+ await waitFor(() => expect(apiRef.current?.getDirtyState().isDirty).toBe(true))
390
+ expect(apiRef.current?.getCurrentSettings().searchValue).toBe('acme')
391
+ })
392
+
393
+ it('refuses to save without a name when no personal view is active', async () => {
394
+ const apiRef = React.createRef<DataTableViewApi | null>() as React.MutableRefObject<DataTableViewApi | null>
395
+ renderTable({ apiRef })
396
+ await waitFor(() => expect(apiRef.current).not.toBeNull())
397
+
398
+ const result = await act(async () => apiRef.current!.saveCurrentView())
399
+ expect(result).toEqual({ ok: false, reason: 'name-required' })
400
+ expect(savedPayloads).toHaveLength(0)
401
+ })
402
+
403
+ it('persists the live settings when a name is supplied', async () => {
404
+ const apiRef = React.createRef<DataTableViewApi | null>() as React.MutableRefObject<DataTableViewApi | null>
405
+ const { setSearchValue } = renderTable({ apiRef })
406
+ await waitFor(() => expect(apiRef.current).not.toBeNull())
407
+ setSearchValue('acme')
408
+ await waitFor(() => expect(apiRef.current?.getDirtyState().isDirty).toBe(true))
409
+
410
+ const result = await act(async () => apiRef.current!.saveCurrentView({ name: 'Wide view' }))
411
+
412
+ expect(result).toEqual({ ok: true, perspectiveId: 'persp-new' })
413
+ expect(savedPayloads).toHaveLength(1)
414
+ expect(savedPayloads[0].name).toBe('Wide view')
415
+ expect((savedPayloads[0].settings as { searchValue?: string }).searchValue).toBe('acme')
416
+ // A completed save re-baselines the view, so nothing stays flagged as unsaved.
417
+ await waitFor(() => expect(apiRef.current?.getDirtyState().isDirty).toBe(false))
418
+ })
419
+
420
+ it('updates the active view in place when no name is supplied', async () => {
421
+ const apiRef = React.createRef<DataTableViewApi | null>() as React.MutableRefObject<DataTableViewApi | null>
422
+ // The saved view is auto-activated on load, so its own settings are the baseline.
423
+ const { setSearchValue } = renderTable({ apiRef, savedViews: [SAVED_VIEW], initialSearchValue: 'acme' })
424
+ await waitFor(() => expect(apiRef.current?.getDirtyState().canSaveToActiveView).toBe(true))
425
+
426
+ setSearchValue('other')
427
+ await waitFor(() => expect(apiRef.current?.getDirtyState().isDirty).toBe(true))
428
+
429
+ const result = await act(async () => apiRef.current!.saveCurrentView())
430
+
431
+ expect(result).toEqual({ ok: true, perspectiveId: 'persp-new' })
432
+ expect(savedPayloads).toHaveLength(1)
433
+ expect(savedPayloads[0].perspectiveId).toBe('persp-1')
434
+ expect(savedPayloads[0].name).toBe('My view')
435
+ expect((savedPayloads[0].settings as { searchValue?: string }).searchValue).toBe('other')
436
+ })
437
+
438
+ it('opens the views sidebar on demand', async () => {
439
+ const apiRef = React.createRef<DataTableViewApi | null>() as React.MutableRefObject<DataTableViewApi | null>
440
+ renderTable({ apiRef })
441
+ await waitFor(() => expect(apiRef.current).not.toBeNull())
442
+ expect(screen.getByTestId('perspective-sidebar').getAttribute('data-open')).toBe('false')
443
+
444
+ act(() => { apiRef.current!.openViewsSidebar() })
445
+
446
+ await waitFor(() => expect(screen.getByTestId('perspective-sidebar').getAttribute('data-open')).toBe('true'))
447
+ })
448
+
449
+ it('keeps the built-in save button opt-in and disabled until something changes', async () => {
450
+ const { unmount } = renderTable({})
451
+ await waitFor(() => expect(screen.getByTestId('perspective-sidebar')).toBeTruthy())
452
+ expect(screen.queryByTestId('save-view-trigger')).toBeNull()
453
+ unmount()
454
+
455
+ const { setSearchValue } = renderTable({ showSaveViewButton: true })
456
+ const trigger = await screen.findByTestId('save-view-trigger')
457
+ expect(trigger).toBeDisabled()
458
+
459
+ setSearchValue('acme')
460
+ await waitFor(() => expect(screen.getByTestId('save-view-trigger')).not.toBeDisabled())
461
+ })
462
+
463
+ it('sends an unnamed save to the sidebar instead of inventing a name', async () => {
464
+ const { setSearchValue } = renderTable({ showSaveViewButton: true })
465
+ const trigger = await screen.findByTestId('save-view-trigger')
466
+ setSearchValue('acme')
467
+ await waitFor(() => expect(screen.getByTestId('save-view-trigger')).not.toBeDisabled())
468
+
469
+ await act(async () => { fireEvent.click(trigger) })
470
+
471
+ await waitFor(() => expect(screen.getByTestId('perspective-sidebar').getAttribute('data-open')).toBe('true'))
472
+ expect(savedPayloads).toHaveLength(0)
473
+ })
474
+ })
@@ -0,0 +1,86 @@
1
+ import { diffPerspectiveSettings } from '../perspectiveDirty'
2
+
3
+ describe('diffPerspectiveSettings', () => {
4
+ it('reports no changes for identical settings', () => {
5
+ const settings = {
6
+ columnOrder: ['name', 'email'],
7
+ columnVisibility: { email: false },
8
+ columnSizing: { name: 200 },
9
+ sorting: [{ id: 'name', desc: true }],
10
+ filters: { status: 'active' },
11
+ searchValue: 'acme',
12
+ }
13
+ expect(diffPerspectiveSettings(settings, { ...settings })).toEqual([])
14
+ })
15
+
16
+ it('treats a materialized default column order as unchanged', () => {
17
+ expect(
18
+ diffPerspectiveSettings({}, { columnOrder: ['name', 'email'] }, { defaultColumnOrder: ['name', 'email'] }),
19
+ ).toEqual([])
20
+ })
21
+
22
+ it('detects a reordered column list', () => {
23
+ expect(
24
+ diffPerspectiveSettings(
25
+ { columnOrder: ['name', 'email'] },
26
+ { columnOrder: ['email', 'name'] },
27
+ ),
28
+ ).toEqual(['columnOrder'])
29
+ })
30
+
31
+ it('treats an explicit true visibility entry as equal to an absent one', () => {
32
+ expect(
33
+ diffPerspectiveSettings({ columnVisibility: { email: false } }, { columnVisibility: { email: false, name: true } }),
34
+ ).toEqual([])
35
+ })
36
+
37
+ it('detects a hidden column', () => {
38
+ expect(diffPerspectiveSettings({}, { columnVisibility: { email: false } })).toEqual(['columnVisibility'])
39
+ })
40
+
41
+ it('detects resized columns and changed sorting', () => {
42
+ expect(
43
+ diffPerspectiveSettings(
44
+ { columnSizing: { name: 200 }, sorting: [{ id: 'name', desc: false }] },
45
+ { columnSizing: { name: 320 }, sorting: [{ id: 'name', desc: true }] },
46
+ ),
47
+ ).toEqual(['columnSizing', 'sorting'])
48
+ })
49
+
50
+ it('treats an omitted desc flag as ascending', () => {
51
+ expect(
52
+ diffPerspectiveSettings({ sorting: [{ id: 'name' }] }, { sorting: [{ id: 'name', desc: false }] }),
53
+ ).toEqual([])
54
+ })
55
+
56
+ it('compares filters by value, not by key order', () => {
57
+ expect(
58
+ diffPerspectiveSettings(
59
+ { filters: { status: 'active', owner: 'me' } },
60
+ { filters: { owner: 'me', status: 'active' } },
61
+ ),
62
+ ).toEqual([])
63
+ expect(
64
+ diffPerspectiveSettings({ filters: { status: 'active' } }, { filters: { status: 'archived' } }),
65
+ ).toEqual(['filters'])
66
+ })
67
+
68
+ it('compares nested filter trees by value', () => {
69
+ const tree = { v: 2, root: { id: 'root', kind: 'group', combinator: 'and', children: [{ id: 'r1', field: 'name' }] } }
70
+ expect(diffPerspectiveSettings({ filters: tree }, { filters: JSON.parse(JSON.stringify(tree)) })).toEqual([])
71
+ })
72
+
73
+ it('ignores search whitespace but detects a real search change', () => {
74
+ expect(diffPerspectiveSettings({ searchValue: 'acme' }, { searchValue: ' acme ' })).toEqual([])
75
+ expect(diffPerspectiveSettings({ searchValue: 'acme' }, { searchValue: 'other' })).toEqual(['searchValue'])
76
+ })
77
+
78
+ it('ignores pageSize, which the live settings never carry', () => {
79
+ expect(diffPerspectiveSettings({ pageSize: 50 }, {})).toEqual([])
80
+ })
81
+
82
+ it('treats null and undefined settings as empty', () => {
83
+ expect(diffPerspectiveSettings(null, undefined)).toEqual([])
84
+ expect(diffPerspectiveSettings(null, { searchValue: 'acme' })).toEqual(['searchValue'])
85
+ })
86
+ })