@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.
Files changed (85) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/backend/CrudForm.js +19 -6
  3. package/dist/backend/CrudForm.js.map +2 -2
  4. package/dist/backend/DataTable.js +62 -10
  5. package/dist/backend/DataTable.js.map +2 -2
  6. package/dist/backend/RowActions.js +54 -5
  7. package/dist/backend/RowActions.js.map +2 -2
  8. package/dist/backend/charts/KpiCard.js +2 -1
  9. package/dist/backend/charts/KpiCard.js.map +2 -2
  10. package/dist/backend/detail/AttachmentsSection.js +17 -5
  11. package/dist/backend/detail/AttachmentsSection.js.map +2 -2
  12. package/dist/backend/detail/NotesSection.js +38 -20
  13. package/dist/backend/detail/NotesSection.js.map +2 -2
  14. package/dist/backend/dev/DevRuntimeDiagnosticsBanner.js +11 -1
  15. package/dist/backend/dev/DevRuntimeDiagnosticsBanner.js.map +2 -2
  16. package/dist/backend/icons/lucideRegistry.generated.js +26 -0
  17. package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
  18. package/dist/backend/injection/eventBridge.js +33 -0
  19. package/dist/backend/injection/eventBridge.js.map +2 -2
  20. package/dist/backend/inputs/LookupSelect.js +20 -3
  21. package/dist/backend/inputs/LookupSelect.js.map +2 -2
  22. package/dist/backend/inputs/PhoneNumberField.js +1 -1
  23. package/dist/backend/inputs/PhoneNumberField.js.map +2 -2
  24. package/dist/backend/inputs/TagsInput.js +20 -2
  25. package/dist/backend/inputs/TagsInput.js.map +2 -2
  26. package/dist/backend/messages/EmailThreadsPanel.js +2 -1
  27. package/dist/backend/messages/EmailThreadsPanel.js.map +2 -2
  28. package/dist/backend/messages/MessageComposer.js.map +2 -2
  29. package/dist/backend/messages/index.js.map +2 -2
  30. package/dist/backend/messages/message-compose-form-groups.js +24 -0
  31. package/dist/backend/messages/message-compose-form-groups.js.map +2 -2
  32. package/dist/backend/messages/useMessageCompose.js +27 -0
  33. package/dist/backend/messages/useMessageCompose.js.map +2 -2
  34. package/dist/backend/messages/useMessageComposeOperations.js +1 -0
  35. package/dist/backend/messages/useMessageComposeOperations.js.map +2 -2
  36. package/dist/backend/notifications/NotificationItem.js +8 -1
  37. package/dist/backend/notifications/NotificationItem.js.map +2 -2
  38. package/dist/portal/PortalShell.js +2 -2
  39. package/dist/portal/PortalShell.js.map +2 -2
  40. package/dist/primitives/calendar.js +69 -14
  41. package/dist/primitives/calendar.js.map +2 -2
  42. package/package.json +4 -5
  43. package/src/backend/CrudForm.tsx +30 -8
  44. package/src/backend/DataTable.tsx +95 -20
  45. package/src/backend/RowActions.tsx +114 -6
  46. package/src/backend/__tests__/AttachmentsSection.upload.test.tsx +183 -0
  47. package/src/backend/__tests__/CrudForm.fieldInjection.test.tsx +139 -2
  48. package/src/backend/__tests__/CrudForm.hiddenGroups.test.tsx +1 -1
  49. package/src/backend/__tests__/DataTable.extensions.test.tsx +30 -0
  50. package/src/backend/__tests__/DataTable.perspectiveReconcile.test.tsx +320 -2
  51. package/src/backend/__tests__/DataTable.stickyResponsive.test.tsx +19 -0
  52. package/src/backend/__tests__/DevRuntimeDiagnosticsBanner.test.tsx +117 -0
  53. package/src/backend/__tests__/NotesSection.test.tsx +138 -0
  54. package/src/backend/__tests__/RowActions.test.tsx +202 -0
  55. package/src/backend/charts/KpiCard.tsx +1 -0
  56. package/src/backend/charts/__tests__/KpiCard.test.tsx +20 -0
  57. package/src/backend/detail/AttachmentsSection.tsx +17 -5
  58. package/src/backend/detail/NotesSection.tsx +122 -66
  59. package/src/backend/dev/DevRuntimeDiagnosticsBanner.tsx +24 -1
  60. package/src/backend/icons/lucideRegistry.generated.tsx +26 -0
  61. package/src/backend/injection/__tests__/eventBridge.test.tsx +47 -1
  62. package/src/backend/injection/eventBridge.ts +35 -0
  63. package/src/backend/inputs/LookupSelect.tsx +32 -3
  64. package/src/backend/inputs/PhoneNumberField.tsx +1 -1
  65. package/src/backend/inputs/TagsInput.tsx +24 -3
  66. package/src/backend/inputs/__tests__/LookupSelect.test.tsx +166 -1
  67. package/src/backend/inputs/__tests__/PhoneNumberField.typeahead.test.tsx +43 -0
  68. package/src/backend/inputs/__tests__/TagsInput.test.tsx +81 -0
  69. package/src/backend/messages/EmailThreadsPanel.tsx +21 -1
  70. package/src/backend/messages/MessageComposer.tsx +1 -0
  71. package/src/backend/messages/__tests__/EmailThreadsPanel.senderLabel.test.tsx +99 -0
  72. package/src/backend/messages/__tests__/MessageComposer.test.tsx +44 -0
  73. package/src/backend/messages/__tests__/useMessageCompose.test.tsx +69 -0
  74. package/src/backend/messages/__tests__/useMessageComposeSender.test.tsx +208 -0
  75. package/src/backend/messages/index.ts +1 -0
  76. package/src/backend/messages/message-compose-form-groups.tsx +41 -0
  77. package/src/backend/messages/message-composer.types.ts +26 -0
  78. package/src/backend/messages/useMessageCompose.ts +46 -0
  79. package/src/backend/messages/useMessageComposeOperations.ts +8 -0
  80. package/src/backend/notifications/NotificationItem.tsx +7 -0
  81. package/src/backend/notifications/__tests__/NotificationItem.customRenderer.test.tsx +139 -0
  82. package/src/portal/PortalShell.tsx +7 -4
  83. package/src/portal/__tests__/PortalShell.test.tsx +29 -1
  84. package/src/primitives/__tests__/calendar.test.tsx +174 -1
  85. 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,