@open-mercato/ui 0.8.1-develop.7296.1.2111d779db → 0.9.0
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/dist/backend/CrudForm.js +19 -6
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/DataTable.js +62 -10
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/RowActions.js +54 -5
- package/dist/backend/RowActions.js.map +2 -2
- package/dist/backend/charts/KpiCard.js +2 -1
- package/dist/backend/charts/KpiCard.js.map +2 -2
- package/dist/backend/detail/AttachmentsSection.js +17 -5
- package/dist/backend/detail/AttachmentsSection.js.map +2 -2
- package/dist/backend/detail/NotesSection.js +38 -20
- package/dist/backend/detail/NotesSection.js.map +2 -2
- package/dist/backend/dev/DevRuntimeDiagnosticsBanner.js +11 -1
- package/dist/backend/dev/DevRuntimeDiagnosticsBanner.js.map +2 -2
- package/dist/backend/icons/lucideRegistry.generated.js +26 -0
- package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
- package/dist/backend/injection/eventBridge.js +33 -0
- package/dist/backend/injection/eventBridge.js.map +2 -2
- package/dist/backend/inputs/LookupSelect.js +20 -3
- package/dist/backend/inputs/LookupSelect.js.map +2 -2
- package/dist/backend/inputs/PhoneNumberField.js +1 -1
- package/dist/backend/inputs/PhoneNumberField.js.map +2 -2
- package/dist/backend/inputs/TagsInput.js +20 -2
- package/dist/backend/inputs/TagsInput.js.map +2 -2
- package/dist/backend/messages/EmailThreadsPanel.js +2 -1
- package/dist/backend/messages/EmailThreadsPanel.js.map +2 -2
- package/dist/backend/messages/MessageComposer.js.map +2 -2
- package/dist/backend/messages/index.js.map +2 -2
- package/dist/backend/messages/message-compose-form-groups.js +24 -0
- package/dist/backend/messages/message-compose-form-groups.js.map +2 -2
- package/dist/backend/messages/useMessageCompose.js +27 -0
- package/dist/backend/messages/useMessageCompose.js.map +2 -2
- package/dist/backend/messages/useMessageComposeOperations.js +1 -0
- package/dist/backend/messages/useMessageComposeOperations.js.map +2 -2
- package/dist/backend/notifications/NotificationItem.js +8 -1
- package/dist/backend/notifications/NotificationItem.js.map +2 -2
- package/dist/portal/PortalShell.js +2 -2
- package/dist/portal/PortalShell.js.map +2 -2
- package/dist/primitives/calendar.js +69 -14
- package/dist/primitives/calendar.js.map +2 -2
- package/package.json +4 -5
- package/src/backend/CrudForm.tsx +30 -8
- package/src/backend/DataTable.tsx +95 -20
- package/src/backend/RowActions.tsx +114 -6
- package/src/backend/__tests__/AttachmentsSection.upload.test.tsx +183 -0
- package/src/backend/__tests__/CrudForm.fieldInjection.test.tsx +139 -2
- package/src/backend/__tests__/CrudForm.hiddenGroups.test.tsx +1 -1
- package/src/backend/__tests__/DataTable.extensions.test.tsx +30 -0
- package/src/backend/__tests__/DataTable.perspectiveReconcile.test.tsx +320 -2
- package/src/backend/__tests__/DataTable.stickyResponsive.test.tsx +19 -0
- package/src/backend/__tests__/DevRuntimeDiagnosticsBanner.test.tsx +117 -0
- package/src/backend/__tests__/NotesSection.test.tsx +138 -0
- package/src/backend/__tests__/RowActions.test.tsx +202 -0
- package/src/backend/charts/KpiCard.tsx +1 -0
- package/src/backend/charts/__tests__/KpiCard.test.tsx +20 -0
- package/src/backend/detail/AttachmentsSection.tsx +17 -5
- package/src/backend/detail/NotesSection.tsx +122 -66
- package/src/backend/dev/DevRuntimeDiagnosticsBanner.tsx +24 -1
- package/src/backend/icons/lucideRegistry.generated.tsx +26 -0
- package/src/backend/injection/__tests__/eventBridge.test.tsx +47 -1
- package/src/backend/injection/eventBridge.ts +35 -0
- package/src/backend/inputs/LookupSelect.tsx +32 -3
- package/src/backend/inputs/PhoneNumberField.tsx +1 -1
- package/src/backend/inputs/TagsInput.tsx +24 -3
- package/src/backend/inputs/__tests__/LookupSelect.test.tsx +166 -1
- package/src/backend/inputs/__tests__/PhoneNumberField.typeahead.test.tsx +43 -0
- package/src/backend/inputs/__tests__/TagsInput.test.tsx +81 -0
- package/src/backend/messages/EmailThreadsPanel.tsx +21 -1
- package/src/backend/messages/MessageComposer.tsx +1 -0
- package/src/backend/messages/__tests__/EmailThreadsPanel.senderLabel.test.tsx +99 -0
- package/src/backend/messages/__tests__/MessageComposer.test.tsx +44 -0
- package/src/backend/messages/__tests__/useMessageCompose.test.tsx +69 -0
- package/src/backend/messages/__tests__/useMessageComposeSender.test.tsx +208 -0
- package/src/backend/messages/index.ts +1 -0
- package/src/backend/messages/message-compose-form-groups.tsx +41 -0
- package/src/backend/messages/message-composer.types.ts +26 -0
- package/src/backend/messages/useMessageCompose.ts +46 -0
- package/src/backend/messages/useMessageComposeOperations.ts +8 -0
- package/src/backend/notifications/NotificationItem.tsx +7 -0
- package/src/backend/notifications/__tests__/NotificationItem.customRenderer.test.tsx +139 -0
- package/src/portal/PortalShell.tsx +7 -4
- package/src/portal/__tests__/PortalShell.test.tsx +29 -1
- package/src/primitives/__tests__/calendar.test.tsx +174 -1
- package/src/primitives/calendar.tsx +168 -96
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
/** @jest-environment jsdom */
|
|
2
2
|
import * as React from 'react'
|
|
3
3
|
import { DataTable, writePerspectiveSnapshot, readPerspectiveSnapshot } from '../DataTable'
|
|
4
|
+
import type { DataTableViewApi } from '../DataTable'
|
|
4
5
|
import type { ColumnDef } from '@tanstack/react-table'
|
|
5
6
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
6
7
|
import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
|
|
7
|
-
import { render, act } from '@testing-library/react'
|
|
8
|
+
import { render, act, fireEvent, screen } from '@testing-library/react'
|
|
8
9
|
import type { PerspectivesIndexResponse } from '@open-mercato/shared/modules/perspectives/types'
|
|
9
|
-
import { createEmptyTree } from '@open-mercato/shared/lib/query/advanced-filter-tree'
|
|
10
|
+
import { createEmptyTree, serializeTreeForPersist } from '@open-mercato/shared/lib/query/advanced-filter-tree'
|
|
11
|
+
import type { AdvancedFilterTree } from '@open-mercato/shared/lib/query/advanced-filter-tree'
|
|
10
12
|
|
|
11
13
|
jest.mock('next/navigation', () => ({
|
|
12
14
|
useRouter: () => ({ push: jest.fn(), replace: jest.fn(), prefetch: jest.fn() }),
|
|
@@ -121,6 +123,90 @@ function renderTable(response: PerspectivesIndexResponse, options?: { withAdvanc
|
|
|
121
123
|
return { ...utils, searchChanges, appliedTrees, queryClient }
|
|
122
124
|
}
|
|
123
125
|
|
|
126
|
+
function buildFilterTree(value: string): AdvancedFilterTree {
|
|
127
|
+
return {
|
|
128
|
+
root: {
|
|
129
|
+
id: 'filter-root',
|
|
130
|
+
type: 'group',
|
|
131
|
+
combinator: 'and',
|
|
132
|
+
children: [
|
|
133
|
+
{ id: 'filter-rule', type: 'rule', field: 'name', operator: 'contains', value },
|
|
134
|
+
],
|
|
135
|
+
},
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function renderAdvancedFilterTable(
|
|
140
|
+
response: PerspectivesIndexResponse,
|
|
141
|
+
options: { initialTree?: AdvancedFilterTree; strictMode?: boolean } = {},
|
|
142
|
+
) {
|
|
143
|
+
const appliedTrees: AdvancedFilterTree[] = []
|
|
144
|
+
const searchChanges: string[] = []
|
|
145
|
+
const queryClient = new QueryClient({
|
|
146
|
+
defaultOptions: {
|
|
147
|
+
queries: { staleTime: Infinity, gcTime: Infinity, retry: false },
|
|
148
|
+
mutations: { retry: false },
|
|
149
|
+
},
|
|
150
|
+
})
|
|
151
|
+
queryClient.setQueryData(['feature-check', 'perspectives'], { use: true, roleDefaults: true })
|
|
152
|
+
queryClient.setQueryData(['table-perspectives', TABLE_ID], response)
|
|
153
|
+
const initialTree = options.initialTree ?? createEmptyTree()
|
|
154
|
+
const viewApiRef = React.createRef<DataTableViewApi>()
|
|
155
|
+
let currentTree = initialTree
|
|
156
|
+
let replaceHostTree: ((tree: AdvancedFilterTree) => void) | undefined
|
|
157
|
+
|
|
158
|
+
function Host() {
|
|
159
|
+
const [tree, setTree] = React.useState(initialTree)
|
|
160
|
+
const [searchValue, setSearchValue] = React.useState('')
|
|
161
|
+
currentTree = tree
|
|
162
|
+
replaceHostTree = setTree
|
|
163
|
+
return (
|
|
164
|
+
<DataTable<Row>
|
|
165
|
+
columns={columns}
|
|
166
|
+
data={[]}
|
|
167
|
+
searchValue={searchValue}
|
|
168
|
+
onSearchChange={(value) => {
|
|
169
|
+
searchChanges.push(value)
|
|
170
|
+
setSearchValue(value)
|
|
171
|
+
}}
|
|
172
|
+
perspective={{ tableId: TABLE_ID }}
|
|
173
|
+
viewApiRef={viewApiRef}
|
|
174
|
+
advancedFilter={{
|
|
175
|
+
fields: [{ key: 'name', label: 'Name', type: 'text' }],
|
|
176
|
+
value: tree,
|
|
177
|
+
onChange: setTree,
|
|
178
|
+
onApply: () => {},
|
|
179
|
+
onClear: () => setTree(createEmptyTree()),
|
|
180
|
+
onApplyTree: (restoredTree) => {
|
|
181
|
+
appliedTrees.push(restoredTree)
|
|
182
|
+
setTree(restoredTree)
|
|
183
|
+
},
|
|
184
|
+
}}
|
|
185
|
+
/>
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const table = <Host />
|
|
190
|
+
const utils = render(
|
|
191
|
+
<QueryClientProvider client={queryClient}>
|
|
192
|
+
<I18nProvider locale="en" dict={{}}>
|
|
193
|
+
{options.strictMode ? <React.StrictMode>{table}</React.StrictMode> : table}
|
|
194
|
+
</I18nProvider>
|
|
195
|
+
</QueryClientProvider>,
|
|
196
|
+
)
|
|
197
|
+
return {
|
|
198
|
+
...utils,
|
|
199
|
+
appliedTrees,
|
|
200
|
+
searchChanges,
|
|
201
|
+
queryClient,
|
|
202
|
+
viewApiRef,
|
|
203
|
+
getTree: () => currentTree,
|
|
204
|
+
clear: () => {
|
|
205
|
+
act(() => { replaceHostTree?.(createEmptyTree()) })
|
|
206
|
+
},
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
124
210
|
describe('DataTable localStorage snapshot vs. server perspective reconciliation (#5113)', () => {
|
|
125
211
|
beforeEach(() => {
|
|
126
212
|
localStorage.clear()
|
|
@@ -309,4 +395,236 @@ describe('DataTable localStorage snapshot vs. server perspective reconciliation
|
|
|
309
395
|
expect(searchChanges).toHaveLength(callsAfterReconcile)
|
|
310
396
|
expect(searchChanges).not.toContain('refetched')
|
|
311
397
|
})
|
|
398
|
+
|
|
399
|
+
it.each([false, true])('restores the saved advanced filter on a bare revisit (StrictMode: %s)', (strictMode) => {
|
|
400
|
+
const savedTree = buildFilterTree('Alice')
|
|
401
|
+
const filters = { ...serializeTreeForPersist(savedTree) }
|
|
402
|
+
writePerspectiveSnapshot(TABLE_ID, {
|
|
403
|
+
perspectiveId: 'persp-1',
|
|
404
|
+
settings: { filters },
|
|
405
|
+
updatedAt: SERVER_UPDATED_AT_MS + 60_000,
|
|
406
|
+
})
|
|
407
|
+
const perspective = { ...buildPerspective('persp-1', ''), settings: { filters } }
|
|
408
|
+
|
|
409
|
+
const rendered = renderAdvancedFilterTable(buildIndexResponse([perspective]), { strictMode })
|
|
410
|
+
|
|
411
|
+
expect(rendered.getTree()).toEqual(savedTree)
|
|
412
|
+
expect(rendered.appliedTrees).toEqual([savedTree])
|
|
413
|
+
expect(rendered.viewApiRef.current?.getDirtyState().activePerspectiveId).toBe('persp-1')
|
|
414
|
+
expect(readPerspectiveSnapshot(TABLE_ID)?.settings.filters).toEqual(filters)
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
it('keeps a populated URL-derived tree ahead of the saved perspective filter', () => {
|
|
418
|
+
const savedTree = buildFilterTree('Alice')
|
|
419
|
+
const urlTree = buildFilterTree('Bob')
|
|
420
|
+
const filters = { ...serializeTreeForPersist(savedTree) }
|
|
421
|
+
writePerspectiveSnapshot(TABLE_ID, {
|
|
422
|
+
perspectiveId: 'persp-1',
|
|
423
|
+
settings: { filters },
|
|
424
|
+
updatedAt: SERVER_UPDATED_AT_MS + 60_000,
|
|
425
|
+
})
|
|
426
|
+
const perspective = { ...buildPerspective('persp-1', ''), settings: { filters } }
|
|
427
|
+
|
|
428
|
+
const rendered = renderAdvancedFilterTable(buildIndexResponse([perspective]), { initialTree: urlTree })
|
|
429
|
+
|
|
430
|
+
expect(rendered.getTree()).toEqual(urlTree)
|
|
431
|
+
expect(rendered.appliedTrees).toHaveLength(0)
|
|
432
|
+
expect(readPerspectiveSnapshot(TABLE_ID)?.settings.filters).toEqual(filters)
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
it('keeps a deliberately cleared advanced filter empty after remount', () => {
|
|
436
|
+
const savedTree = buildFilterTree('Alice')
|
|
437
|
+
const filters = { ...serializeTreeForPersist(savedTree) }
|
|
438
|
+
writePerspectiveSnapshot(TABLE_ID, {
|
|
439
|
+
perspectiveId: 'persp-1',
|
|
440
|
+
settings: { filters, columnSizing: { name: 240 } },
|
|
441
|
+
updatedAt: SERVER_UPDATED_AT_MS + 60_000,
|
|
442
|
+
})
|
|
443
|
+
const perspective = { ...buildPerspective('persp-1', ''), settings: { filters } }
|
|
444
|
+
const response = buildIndexResponse([perspective])
|
|
445
|
+
const rendered = renderAdvancedFilterTable(response)
|
|
446
|
+
expect(rendered.getTree()).toEqual(savedTree)
|
|
447
|
+
|
|
448
|
+
rendered.clear()
|
|
449
|
+
|
|
450
|
+
expect(readPerspectiveSnapshot(TABLE_ID)?.settings.filters).toBeUndefined()
|
|
451
|
+
expect(readPerspectiveSnapshot(TABLE_ID)?.settings.columnSizing).toEqual({ name: 240 })
|
|
452
|
+
expect(readPerspectiveSnapshot(TABLE_ID)?.perspectiveId).toBe('persp-1')
|
|
453
|
+
rendered.unmount()
|
|
454
|
+
|
|
455
|
+
const revisited = renderAdvancedFilterTable(response)
|
|
456
|
+
expect(revisited.getTree().root.children).toHaveLength(0)
|
|
457
|
+
expect(revisited.appliedTrees).toHaveLength(0)
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
it('does not resurrect a cleared snapshot filter when reconciling a newer server view', () => {
|
|
461
|
+
const filters = { ...serializeTreeForPersist(buildFilterTree('Alice')) }
|
|
462
|
+
writePerspectiveSnapshot(TABLE_ID, {
|
|
463
|
+
perspectiveId: 'persp-1',
|
|
464
|
+
settings: { searchValue: 'stale' },
|
|
465
|
+
updatedAt: SERVER_UPDATED_AT_MS - 60_000,
|
|
466
|
+
})
|
|
467
|
+
const perspective = { ...buildPerspective('persp-1', 'fresh'), settings: { searchValue: 'fresh', filters } }
|
|
468
|
+
|
|
469
|
+
const rendered = renderAdvancedFilterTable(buildIndexResponse([perspective]))
|
|
470
|
+
|
|
471
|
+
expect(rendered.searchChanges[rendered.searchChanges.length - 1]).toBe('fresh')
|
|
472
|
+
expect(rendered.getTree().root.children).toHaveLength(0)
|
|
473
|
+
expect(rendered.appliedTrees).toHaveLength(0)
|
|
474
|
+
expect(readPerspectiveSnapshot(TABLE_ID)?.settings.filters).toBeUndefined()
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
it('retains the restored filter snapshot when synchronous reconciliation runs before the host rerenders', () => {
|
|
478
|
+
const savedTree = buildFilterTree('Alice')
|
|
479
|
+
const filters = { ...serializeTreeForPersist(savedTree) }
|
|
480
|
+
writePerspectiveSnapshot(TABLE_ID, {
|
|
481
|
+
perspectiveId: 'persp-1',
|
|
482
|
+
settings: { searchValue: 'stale', filters },
|
|
483
|
+
updatedAt: SERVER_UPDATED_AT_MS - 60_000,
|
|
484
|
+
})
|
|
485
|
+
const perspective = { ...buildPerspective('persp-1', 'fresh'), settings: { searchValue: 'fresh', filters } }
|
|
486
|
+
|
|
487
|
+
const rendered = renderAdvancedFilterTable(buildIndexResponse([perspective]))
|
|
488
|
+
|
|
489
|
+
expect(rendered.searchChanges[rendered.searchChanges.length - 1]).toBe('fresh')
|
|
490
|
+
expect(rendered.getTree()).toEqual(savedTree)
|
|
491
|
+
expect(readPerspectiveSnapshot(TABLE_ID)?.settings.filters).toEqual(filters)
|
|
492
|
+
expect(rendered.viewApiRef.current?.getDirtyState().changedKeys).not.toContain('filters')
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
it('preserves the newly selected filter after switching through an unfiltered view and revisiting', () => {
|
|
496
|
+
const firstTree = buildFilterTree('Alice')
|
|
497
|
+
const nextTree = buildFilterTree('Bob')
|
|
498
|
+
const firstFilters = { ...serializeTreeForPersist(firstTree) }
|
|
499
|
+
const nextFilters = { ...serializeTreeForPersist(nextTree) }
|
|
500
|
+
writePerspectiveSnapshot(TABLE_ID, {
|
|
501
|
+
perspectiveId: 'persp-1',
|
|
502
|
+
settings: { filters: firstFilters },
|
|
503
|
+
updatedAt: SERVER_UPDATED_AT_MS + 60_000,
|
|
504
|
+
})
|
|
505
|
+
const response = buildIndexResponse([
|
|
506
|
+
{ ...buildPerspective('persp-1', ''), settings: { filters: firstFilters } },
|
|
507
|
+
buildPerspective('persp-empty', ''),
|
|
508
|
+
{ ...buildPerspective('persp-2', ''), settings: { filters: nextFilters } },
|
|
509
|
+
])
|
|
510
|
+
const rendered = renderAdvancedFilterTable(response)
|
|
511
|
+
|
|
512
|
+
fireEvent.click(screen.getByRole('button', { name: 'persp-1' }))
|
|
513
|
+
fireEvent.click(screen.getByRole('button', { name: 'persp-empty' }))
|
|
514
|
+
expect(rendered.getTree().root.children).toHaveLength(0)
|
|
515
|
+
|
|
516
|
+
fireEvent.click(screen.getByRole('button', { name: 'persp-empty' }))
|
|
517
|
+
fireEvent.click(screen.getByRole('button', { name: 'persp-2' }))
|
|
518
|
+
expect(rendered.getTree()).toEqual(nextTree)
|
|
519
|
+
expect(readPerspectiveSnapshot(TABLE_ID)?.settings.filters).toEqual(nextFilters)
|
|
520
|
+
expect(readPerspectiveSnapshot(TABLE_ID)?.perspectiveId).toBe('persp-2')
|
|
521
|
+
rendered.unmount()
|
|
522
|
+
|
|
523
|
+
const revisited = renderAdvancedFilterTable(response)
|
|
524
|
+
expect(revisited.getTree()).toEqual(nextTree)
|
|
525
|
+
expect(revisited.viewApiRef.current?.getDirtyState().activePerspectiveId).toBe('persp-2')
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
it('clears a filter restored from an orphaned snapshot when no replacement view exists', () => {
|
|
529
|
+
const filters = { ...serializeTreeForPersist(buildFilterTree('Alice')) }
|
|
530
|
+
writePerspectiveSnapshot(TABLE_ID, {
|
|
531
|
+
perspectiveId: 'deleted-1',
|
|
532
|
+
settings: { searchValue: 'orphaned', filters },
|
|
533
|
+
updatedAt: SERVER_UPDATED_AT_MS - 60_000,
|
|
534
|
+
})
|
|
535
|
+
|
|
536
|
+
const rendered = renderAdvancedFilterTable(buildIndexResponse([]))
|
|
537
|
+
|
|
538
|
+
expect(rendered.getTree().root.children).toHaveLength(0)
|
|
539
|
+
expect(rendered.viewApiRef.current?.getDirtyState().activePerspectiveId).toBeNull()
|
|
540
|
+
expect(readPerspectiveSnapshot(TABLE_ID)).toBeNull()
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
it('replaces an orphaned snapshot filter with the unfiltered replacement view', () => {
|
|
544
|
+
const filters = { ...serializeTreeForPersist(buildFilterTree('Alice')) }
|
|
545
|
+
writePerspectiveSnapshot(TABLE_ID, {
|
|
546
|
+
perspectiveId: 'deleted-1',
|
|
547
|
+
settings: { searchValue: 'orphaned', filters },
|
|
548
|
+
updatedAt: SERVER_UPDATED_AT_MS - 60_000,
|
|
549
|
+
})
|
|
550
|
+
const response = buildIndexResponse(
|
|
551
|
+
[buildPerspective('persp-2', 'replacement')],
|
|
552
|
+
{ defaultPerspectiveId: 'persp-2' },
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
const rendered = renderAdvancedFilterTable(response)
|
|
556
|
+
|
|
557
|
+
expect(rendered.getTree().root.children).toHaveLength(0)
|
|
558
|
+
expect(rendered.viewApiRef.current?.getDirtyState().activePerspectiveId).toBe('persp-2')
|
|
559
|
+
expect(readPerspectiveSnapshot(TABLE_ID)?.perspectiveId).toBe('persp-2')
|
|
560
|
+
expect(readPerspectiveSnapshot(TABLE_ID)?.settings.filters).toBeUndefined()
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
it('replaces a restored snapshot filter with the newer server filter before the host edits it', () => {
|
|
564
|
+
const savedTree = buildFilterTree('Alice')
|
|
565
|
+
const newerTree = buildFilterTree('Bob')
|
|
566
|
+
const savedFilters = { ...serializeTreeForPersist(savedTree) }
|
|
567
|
+
const newerFilters = { ...serializeTreeForPersist(newerTree) }
|
|
568
|
+
writePerspectiveSnapshot(TABLE_ID, {
|
|
569
|
+
perspectiveId: 'persp-1',
|
|
570
|
+
settings: { filters: savedFilters },
|
|
571
|
+
updatedAt: SERVER_UPDATED_AT_MS - 60_000,
|
|
572
|
+
})
|
|
573
|
+
const perspective = { ...buildPerspective('persp-1', ''), settings: { filters: newerFilters } }
|
|
574
|
+
|
|
575
|
+
const rendered = renderAdvancedFilterTable(buildIndexResponse([perspective]))
|
|
576
|
+
|
|
577
|
+
expect(rendered.getTree()).toEqual(newerTree)
|
|
578
|
+
expect(rendered.appliedTrees).toEqual([savedTree, newerTree])
|
|
579
|
+
expect(rendered.viewApiRef.current?.getDirtyState().changedKeys).not.toContain('filters')
|
|
580
|
+
expect(readPerspectiveSnapshot(TABLE_ID)?.settings.filters).toEqual(newerFilters)
|
|
581
|
+
})
|
|
582
|
+
|
|
583
|
+
it('preserves the URL filter while retaining the newer server filter for a bare revisit', () => {
|
|
584
|
+
const savedFilters = { ...serializeTreeForPersist(buildFilterTree('Alice')) }
|
|
585
|
+
const newerTree = buildFilterTree('Bob')
|
|
586
|
+
const newerFilters = { ...serializeTreeForPersist(newerTree) }
|
|
587
|
+
const urlTree = buildFilterTree('Charlie')
|
|
588
|
+
writePerspectiveSnapshot(TABLE_ID, {
|
|
589
|
+
perspectiveId: 'persp-1',
|
|
590
|
+
settings: { filters: savedFilters },
|
|
591
|
+
updatedAt: SERVER_UPDATED_AT_MS - 60_000,
|
|
592
|
+
})
|
|
593
|
+
const perspective = { ...buildPerspective('persp-1', ''), settings: { filters: newerFilters } }
|
|
594
|
+
const response = buildIndexResponse([perspective])
|
|
595
|
+
|
|
596
|
+
const rendered = renderAdvancedFilterTable(response, { initialTree: urlTree })
|
|
597
|
+
|
|
598
|
+
expect(rendered.getTree()).toEqual(urlTree)
|
|
599
|
+
expect(rendered.appliedTrees).toHaveLength(0)
|
|
600
|
+
expect(readPerspectiveSnapshot(TABLE_ID)?.settings.filters).toEqual(newerFilters)
|
|
601
|
+
rendered.unmount()
|
|
602
|
+
|
|
603
|
+
const revisited = renderAdvancedFilterTable(response)
|
|
604
|
+
expect(revisited.getTree()).toEqual(newerTree)
|
|
605
|
+
expect(revisited.viewApiRef.current?.getDirtyState().activePerspectiveId).toBe('persp-1')
|
|
606
|
+
})
|
|
607
|
+
|
|
608
|
+
it('retains the newer server filter when a URL filter overrides a snapshot without filters', () => {
|
|
609
|
+
const newerTree = buildFilterTree('Bob')
|
|
610
|
+
const newerFilters = { ...serializeTreeForPersist(newerTree) }
|
|
611
|
+
const urlTree = buildFilterTree('Charlie')
|
|
612
|
+
writePerspectiveSnapshot(TABLE_ID, {
|
|
613
|
+
perspectiveId: 'persp-1',
|
|
614
|
+
settings: {},
|
|
615
|
+
updatedAt: SERVER_UPDATED_AT_MS - 60_000,
|
|
616
|
+
})
|
|
617
|
+
const perspective = { ...buildPerspective('persp-1', ''), settings: { filters: newerFilters } }
|
|
618
|
+
const response = buildIndexResponse([perspective])
|
|
619
|
+
|
|
620
|
+
const rendered = renderAdvancedFilterTable(response, { initialTree: urlTree })
|
|
621
|
+
|
|
622
|
+
expect(rendered.getTree()).toEqual(urlTree)
|
|
623
|
+
expect(rendered.appliedTrees).toHaveLength(0)
|
|
624
|
+
expect(readPerspectiveSnapshot(TABLE_ID)?.settings.filters).toEqual(newerFilters)
|
|
625
|
+
rendered.unmount()
|
|
626
|
+
|
|
627
|
+
const revisited = renderAdvancedFilterTable(response)
|
|
628
|
+
expect(revisited.getTree()).toEqual(newerTree)
|
|
629
|
+
})
|
|
312
630
|
})
|
|
@@ -74,6 +74,25 @@ describe('DataTable sticky columns are viewport-gated', () => {
|
|
|
74
74
|
}
|
|
75
75
|
})
|
|
76
76
|
|
|
77
|
+
// Pinned cells carry z-10/z-20 while the app top bar sits at `z-sticky`
|
|
78
|
+
// (10). Without an isolated stacking context around the table, the pinned
|
|
79
|
+
// actions column painted over the top bar while the page scrolled (#7063).
|
|
80
|
+
it('confines pinned cell z-index to the table stacking context', () => {
|
|
81
|
+
const { container, queryClient } = renderStickyTable()
|
|
82
|
+
try {
|
|
83
|
+
const table = container.querySelector('table')
|
|
84
|
+
const scrollWrapper = table?.parentElement ?? null
|
|
85
|
+
expect(tokensOf(scrollWrapper)).toContain('isolate')
|
|
86
|
+
const pinned = container.querySelectorAll('[class*="md:sticky"]')
|
|
87
|
+
expect(pinned.length).toBeGreaterThan(0)
|
|
88
|
+
pinned.forEach((cell) => {
|
|
89
|
+
expect(scrollWrapper?.contains(cell)).toBe(true)
|
|
90
|
+
})
|
|
91
|
+
} finally {
|
|
92
|
+
queryClient.clear()
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
|
|
77
96
|
it('pins the actions column only from md up', () => {
|
|
78
97
|
const { container, queryClient } = renderStickyTable()
|
|
79
98
|
try {
|
|
@@ -3,6 +3,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra
|
|
|
3
3
|
|
|
4
4
|
import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
|
|
5
5
|
import { DevRuntimeDiagnosticsBanner } from '../dev/DevRuntimeDiagnosticsBanner'
|
|
6
|
+
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '../../primitives/dialog'
|
|
6
7
|
import {
|
|
7
8
|
DEV_RUNTIME_BANNER_META_NAME,
|
|
8
9
|
DEV_RUNTIME_LOGS_URL_META_NAME,
|
|
@@ -376,6 +377,122 @@ describe('DevRuntimeDiagnosticsBanner', () => {
|
|
|
376
377
|
expect(actionRow?.contains(dismiss)).toBe(false)
|
|
377
378
|
})
|
|
378
379
|
|
|
380
|
+
describe('above an open modal dialog', () => {
|
|
381
|
+
function renderBannerOverDialog(onOpenChange: jest.Mock, { modal = true }: { modal?: boolean } = {}) {
|
|
382
|
+
return render(
|
|
383
|
+
<I18nProvider locale="en" dict={{}}>
|
|
384
|
+
<Dialog open modal={modal} onOpenChange={onOpenChange}>
|
|
385
|
+
<DialogContent>
|
|
386
|
+
<DialogTitle>Add line item</DialogTitle>
|
|
387
|
+
<DialogDescription>Line item form</DialogDescription>
|
|
388
|
+
</DialogContent>
|
|
389
|
+
</Dialog>
|
|
390
|
+
<button type="button" data-testid="page-content">page</button>
|
|
391
|
+
<DevRuntimeDiagnosticsBanner />
|
|
392
|
+
</I18nProvider>,
|
|
393
|
+
)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Radix registers its outside-press listener on the next tick after mount.
|
|
397
|
+
async function waitForOutsideDismissListener() {
|
|
398
|
+
await act(async () => {
|
|
399
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
400
|
+
})
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function pressAndClick(element: Element) {
|
|
404
|
+
fireEvent.pointerDown(element)
|
|
405
|
+
fireEvent.mouseDown(element)
|
|
406
|
+
fireEvent.focusIn(element)
|
|
407
|
+
fireEvent.pointerUp(element)
|
|
408
|
+
fireEvent.mouseUp(element)
|
|
409
|
+
fireEvent.click(element)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
it('closes the dialog on a genuine outside press (control)', async () => {
|
|
413
|
+
enableBanner()
|
|
414
|
+
mockStatusResponses(createStatus())
|
|
415
|
+
const onOpenChange = jest.fn()
|
|
416
|
+
renderBannerOverDialog(onOpenChange)
|
|
417
|
+
await screen.findByTestId('dev-runtime-diagnostics-banner')
|
|
418
|
+
await waitForOutsideDismissListener()
|
|
419
|
+
|
|
420
|
+
pressAndClick(screen.getByTestId('page-content'))
|
|
421
|
+
|
|
422
|
+
expect(onOpenChange).toHaveBeenCalledWith(false)
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
it('dismisses only the banner when its close button is pressed', async () => {
|
|
426
|
+
enableBanner()
|
|
427
|
+
mockStatusResponses(createStatus())
|
|
428
|
+
const onOpenChange = jest.fn()
|
|
429
|
+
renderBannerOverDialog(onOpenChange)
|
|
430
|
+
await screen.findByTestId('dev-runtime-diagnostics-banner')
|
|
431
|
+
await waitForOutsideDismissListener()
|
|
432
|
+
|
|
433
|
+
pressAndClick(screen.getByRole('button', { name: 'Dismiss', hidden: true }))
|
|
434
|
+
|
|
435
|
+
expect(screen.queryByTestId('dev-runtime-diagnostics-banner')).toBeNull()
|
|
436
|
+
expect(onOpenChange).not.toHaveBeenCalled()
|
|
437
|
+
expect(screen.getByRole('dialog')).toBeTruthy()
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
it('keeps the dialog open when the banner actions are used', async () => {
|
|
441
|
+
enableBanner()
|
|
442
|
+
mockStatusResponses(createStatus())
|
|
443
|
+
const onOpenChange = jest.fn()
|
|
444
|
+
renderBannerOverDialog(onOpenChange)
|
|
445
|
+
await screen.findByTestId('dev-runtime-diagnostics-banner')
|
|
446
|
+
await waitForOutsideDismissListener()
|
|
447
|
+
|
|
448
|
+
pressAndClick(screen.getByRole('button', { name: 'Show details', hidden: true }))
|
|
449
|
+
|
|
450
|
+
expect(screen.getByText('Error code')).toBeTruthy()
|
|
451
|
+
expect(onOpenChange).not.toHaveBeenCalled()
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
it('keeps the dialog open through the portaled migrate confirmation', async () => {
|
|
455
|
+
enableBanner()
|
|
456
|
+
mockStatusResponses(createStatus())
|
|
457
|
+
const onOpenChange = jest.fn()
|
|
458
|
+
renderBannerOverDialog(onOpenChange)
|
|
459
|
+
await screen.findByTestId('dev-runtime-diagnostics-banner')
|
|
460
|
+
await waitForOutsideDismissListener()
|
|
461
|
+
|
|
462
|
+
pressAndClick(screen.getByRole('button', { name: /Run migrations/, hidden: true }))
|
|
463
|
+
const confirmDialog = (await screen.findByText(/not automatically reversible/i)).closest('dialog')
|
|
464
|
+
expect(confirmDialog?.hasAttribute('open')).toBe(true)
|
|
465
|
+
pressAndClick(screen.getByRole('button', { name: /^Cancel$/, hidden: true }))
|
|
466
|
+
|
|
467
|
+
await waitFor(() => expect(confirmDialog?.hasAttribute('open')).toBe(false))
|
|
468
|
+
expect(onOpenChange).not.toHaveBeenCalled()
|
|
469
|
+
expect(actionCalls()).toHaveLength(0)
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
it('keeps a non-modal layer open when focus moves into the banner', async () => {
|
|
473
|
+
enableBanner()
|
|
474
|
+
mockStatusResponses(createStatus())
|
|
475
|
+
const onOpenChange = jest.fn()
|
|
476
|
+
renderBannerOverDialog(onOpenChange, { modal: false })
|
|
477
|
+
await screen.findByTestId('dev-runtime-diagnostics-banner')
|
|
478
|
+
await waitForOutsideDismissListener()
|
|
479
|
+
|
|
480
|
+
pressAndClick(screen.getByRole('button', { name: 'Show details' }))
|
|
481
|
+
|
|
482
|
+
expect(screen.getByText('Error code')).toBeTruthy()
|
|
483
|
+
expect(onOpenChange).not.toHaveBeenCalled()
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
it('stays clickable while the modal disables pointer events on the body', async () => {
|
|
487
|
+
enableBanner()
|
|
488
|
+
mockStatusResponses(createStatus())
|
|
489
|
+
renderBannerOverDialog(jest.fn())
|
|
490
|
+
|
|
491
|
+
const banner = await screen.findByTestId('dev-runtime-diagnostics-banner')
|
|
492
|
+
expect(banner.className).toContain('pointer-events-auto')
|
|
493
|
+
})
|
|
494
|
+
})
|
|
495
|
+
|
|
379
496
|
// The app shell's sidebar toggle and the toast stack own fixed slots at the
|
|
380
497
|
// top of the viewport, so a top-anchored banner is overlapped by them.
|
|
381
498
|
it('floats at the bottom above app chrome instead of sitting in page flow', async () => {
|
|
@@ -260,6 +260,144 @@ describe('NotesSection', () => {
|
|
|
260
260
|
expect(container.querySelectorAll('svg.lucide-palette').length).toBe(0)
|
|
261
261
|
})
|
|
262
262
|
|
|
263
|
+
describe('host options', () => {
|
|
264
|
+
const twoNotesAdapter = (): NotesDataAdapter => ({
|
|
265
|
+
list: jest.fn(async () => [
|
|
266
|
+
{
|
|
267
|
+
id: 'note-own',
|
|
268
|
+
body: 'Own note',
|
|
269
|
+
createdAt: '2026-04-10T08:00:00.000Z',
|
|
270
|
+
authorUserId: 'user-1',
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
id: 'note-other',
|
|
274
|
+
body: 'Other note',
|
|
275
|
+
createdAt: '2026-04-09T08:00:00.000Z',
|
|
276
|
+
authorUserId: 'user-2',
|
|
277
|
+
authorName: 'Grace Hopper',
|
|
278
|
+
},
|
|
279
|
+
]),
|
|
280
|
+
create: jest.fn(async () => ({ id: 'note-new' })),
|
|
281
|
+
update: jest.fn(async () => undefined),
|
|
282
|
+
delete: jest.fn(async () => undefined),
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
const noteCard = (text: string) => {
|
|
286
|
+
const card = screen.getByText(text).closest('.group')
|
|
287
|
+
if (!(card instanceof HTMLElement)) throw new Error(`card for ${text} not found`)
|
|
288
|
+
return card
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
it('renders edit, appearance and delete controls on every note by default', async () => {
|
|
292
|
+
renderWithProviders(<NotesSection {...baseProps(twoNotesAdapter())} />)
|
|
293
|
+
|
|
294
|
+
await screen.findByText('Other note')
|
|
295
|
+
for (const text of ['Own note', 'Other note']) {
|
|
296
|
+
const card = noteCard(text)
|
|
297
|
+
expect(card.querySelector('svg.lucide-pencil')).not.toBeNull()
|
|
298
|
+
expect(card.querySelector('svg.lucide-palette')).not.toBeNull()
|
|
299
|
+
expect(card.querySelector('svg.lucide-trash2, svg.lucide-trash-2')).not.toBeNull()
|
|
300
|
+
expect(card.querySelector('[role="button"]')).not.toBeNull()
|
|
301
|
+
}
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it('hides per-note edit controls and click-to-edit when canEditNote returns false', async () => {
|
|
305
|
+
const { container } = renderWithProviders(
|
|
306
|
+
<NotesSection
|
|
307
|
+
{...baseProps(twoNotesAdapter())}
|
|
308
|
+
disableMarkdown
|
|
309
|
+
canEditNote={(note) => note.authorUserId === 'user-1'}
|
|
310
|
+
/>,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
await screen.findByText('Other note')
|
|
314
|
+
const own = noteCard('Own note')
|
|
315
|
+
const other = noteCard('Other note')
|
|
316
|
+
expect(own.querySelector('svg.lucide-pencil')).not.toBeNull()
|
|
317
|
+
expect(own.querySelector('svg.lucide-palette')).not.toBeNull()
|
|
318
|
+
expect(other.querySelector('svg.lucide-pencil')).toBeNull()
|
|
319
|
+
expect(other.querySelector('svg.lucide-palette')).toBeNull()
|
|
320
|
+
expect(other.querySelector('svg.lucide-trash2, svg.lucide-trash-2')).not.toBeNull()
|
|
321
|
+
expect(other.querySelector('[role="button"]')).toBeNull()
|
|
322
|
+
|
|
323
|
+
fireEvent.click(screen.getByText('Other note'))
|
|
324
|
+
expect(container.querySelector('textarea')).toBeNull()
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it('hides the per-note delete control when canDeleteNote returns false', async () => {
|
|
328
|
+
renderWithProviders(
|
|
329
|
+
<NotesSection
|
|
330
|
+
{...baseProps(twoNotesAdapter())}
|
|
331
|
+
canDeleteNote={(note) => note.authorUserId === 'user-1'}
|
|
332
|
+
/>,
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
await screen.findByText('Other note')
|
|
336
|
+
expect(noteCard('Own note').querySelector('svg.lucide-trash2, svg.lucide-trash-2')).not.toBeNull()
|
|
337
|
+
const other = noteCard('Other note')
|
|
338
|
+
expect(other.querySelector('svg.lucide-trash2, svg.lucide-trash-2')).toBeNull()
|
|
339
|
+
expect(other.querySelector('svg.lucide-pencil')).not.toBeNull()
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
it('hides the composer, add actions and every mutation control in readOnly mode', async () => {
|
|
343
|
+
const onActionChange = jest.fn()
|
|
344
|
+
const { container } = renderWithProviders(
|
|
345
|
+
<NotesSection {...baseProps(twoNotesAdapter())} readOnly onActionChange={onActionChange} />,
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
await screen.findByText('Other note')
|
|
349
|
+
expect(screen.getByText('Own note')).toBeInTheDocument()
|
|
350
|
+
expect(screen.queryByRole('button', { name: 'Add note' })).toBeNull()
|
|
351
|
+
expect(container.querySelector('svg.lucide-pencil')).toBeNull()
|
|
352
|
+
expect(container.querySelector('svg.lucide-palette')).toBeNull()
|
|
353
|
+
expect(container.querySelector('svg.lucide-trash2, svg.lucide-trash-2')).toBeNull()
|
|
354
|
+
expect(container.querySelector('[role="button"]')).toBeNull()
|
|
355
|
+
expect(container.querySelector('form')).toBeNull()
|
|
356
|
+
expect(onActionChange).not.toHaveBeenCalledWith(expect.objectContaining({ label: 'Add note' }))
|
|
357
|
+
|
|
358
|
+
fireEvent.click(screen.getByText('Own note'))
|
|
359
|
+
expect(container.querySelector('textarea')).toBeNull()
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
it('renders the empty state without an add action in readOnly mode', async () => {
|
|
363
|
+
const dataAdapter: NotesDataAdapter = { ...createDataAdapter(), list: jest.fn(async () => []) }
|
|
364
|
+
renderWithProviders(<NotesSection {...baseProps(dataAdapter)} readOnly />)
|
|
365
|
+
|
|
366
|
+
expect(await screen.findByText('No notes yet')).toBeInTheDocument()
|
|
367
|
+
expect(screen.queryByRole('button', { name: 'Add note' })).toBeNull()
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
it('renders the generic note context as a link or plain text', async () => {
|
|
371
|
+
const dataAdapter: NotesDataAdapter = {
|
|
372
|
+
...createDataAdapter(),
|
|
373
|
+
list: jest.fn(async () => [
|
|
374
|
+
{
|
|
375
|
+
id: 'note-linked',
|
|
376
|
+
body: 'Linked note',
|
|
377
|
+
createdAt: '2026-04-10T08:00:00.000Z',
|
|
378
|
+
contextLabel: 'Order #1001',
|
|
379
|
+
contextHref: '/backend/sales/orders/order-1001',
|
|
380
|
+
},
|
|
381
|
+
{
|
|
382
|
+
id: 'note-plain',
|
|
383
|
+
body: 'Plain note',
|
|
384
|
+
createdAt: '2026-04-09T08:00:00.000Z',
|
|
385
|
+
contextLabel: 'Warehouse A',
|
|
386
|
+
},
|
|
387
|
+
]),
|
|
388
|
+
}
|
|
389
|
+
renderWithProviders(<NotesSection {...baseProps(dataAdapter)} />)
|
|
390
|
+
|
|
391
|
+
await screen.findByText('Plain note')
|
|
392
|
+
expect(screen.getByRole('link', { name: 'Order #1001' })).toHaveAttribute(
|
|
393
|
+
'href',
|
|
394
|
+
'/backend/sales/orders/order-1001',
|
|
395
|
+
)
|
|
396
|
+
const plain = screen.getByText('Warehouse A')
|
|
397
|
+
expect(plain.closest('a')).toBeNull()
|
|
398
|
+
})
|
|
399
|
+
})
|
|
400
|
+
|
|
263
401
|
it('surfaces the unified conflict bar when a write fails with a 409', async () => {
|
|
264
402
|
const conflict = {
|
|
265
403
|
status: 409,
|