@open-mercato/ui 0.6.7 → 0.6.8-develop.6874.1.982d6097d8

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 (214) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +4 -2
  3. package/agentic/standalone-guide.md +1 -1
  4. package/build.mjs +2 -54
  5. package/dist/ai/AiChat.js +26 -7
  6. package/dist/ai/AiChat.js.map +2 -2
  7. package/dist/ai/LoopDisabledBanner.js +1 -1
  8. package/dist/ai/LoopDisabledBanner.js.map +2 -2
  9. package/dist/ai/parts/ConfirmationCard.js +4 -4
  10. package/dist/ai/parts/ConfirmationCard.js.map +2 -2
  11. package/dist/ai/parts/FieldDiffCard.js +2 -2
  12. package/dist/ai/parts/FieldDiffCard.js.map +2 -2
  13. package/dist/ai/parts/MutationResultCard.js +3 -3
  14. package/dist/ai/parts/MutationResultCard.js.map +2 -2
  15. package/dist/ai/ui-parts/pending-phase3-placeholder.js +1 -1
  16. package/dist/ai/ui-parts/pending-phase3-placeholder.js.map +2 -2
  17. package/dist/backend/AppShell.js +43 -4
  18. package/dist/backend/AppShell.js.map +2 -2
  19. package/dist/backend/AuthSessionGuard.js +14 -0
  20. package/dist/backend/AuthSessionGuard.js.map +2 -2
  21. package/dist/backend/BackendChromeProvider.js +6 -1
  22. package/dist/backend/BackendChromeProvider.js.map +2 -2
  23. package/dist/backend/CrudForm.js +6 -4
  24. package/dist/backend/CrudForm.js.map +2 -2
  25. package/dist/backend/DataTable.js +20 -18
  26. package/dist/backend/DataTable.js.map +2 -2
  27. package/dist/backend/JsonBuilder.js +3 -3
  28. package/dist/backend/JsonBuilder.js.map +2 -2
  29. package/dist/backend/NextStepCallout.js +4 -4
  30. package/dist/backend/NextStepCallout.js.map +1 -1
  31. package/dist/backend/PerspectiveSidebar.js +1 -1
  32. package/dist/backend/PerspectiveSidebar.js.map +2 -2
  33. package/dist/backend/ProfileDropdown.js +2 -1
  34. package/dist/backend/ProfileDropdown.js.map +2 -2
  35. package/dist/backend/RowActions.js +2 -2
  36. package/dist/backend/RowActions.js.map +2 -2
  37. package/dist/backend/confirm-dialog/ConfirmDialog.js +13 -1
  38. package/dist/backend/confirm-dialog/ConfirmDialog.js.map +2 -2
  39. package/dist/backend/crud/useGroupOrder.js +14 -21
  40. package/dist/backend/crud/useGroupOrder.js.map +2 -2
  41. package/dist/backend/custom-fields/FieldDefinitionsManager.js +1 -1
  42. package/dist/backend/custom-fields/FieldDefinitionsManager.js.map +2 -2
  43. package/dist/backend/dashboard/DashboardScreen.js +3 -3
  44. package/dist/backend/dashboard/DashboardScreen.js.map +2 -2
  45. package/dist/backend/detail/AddressTiles.js +4 -5
  46. package/dist/backend/detail/AddressTiles.js.map +2 -2
  47. package/dist/backend/detail/AttachmentDeleteDialog.js +1 -1
  48. package/dist/backend/detail/AttachmentDeleteDialog.js.map +2 -2
  49. package/dist/backend/detail/AttachmentMetadataDialog.js.map +1 -1
  50. package/dist/backend/detail/AttachmentsSection.js +1 -1
  51. package/dist/backend/detail/AttachmentsSection.js.map +2 -2
  52. package/dist/backend/detail/NotesSection.js +1 -1
  53. package/dist/backend/detail/NotesSection.js.map +2 -2
  54. package/dist/backend/detail/TagsSection.js +1 -1
  55. package/dist/backend/detail/TagsSection.js.map +2 -2
  56. package/dist/backend/filters/AdvancedFilterPanel.js +1 -1
  57. package/dist/backend/filters/AdvancedFilterPanel.js.map +2 -2
  58. package/dist/backend/forms/FormHeader.js +1 -2
  59. package/dist/backend/forms/FormHeader.js.map +2 -2
  60. package/dist/backend/icons/lucideRegistry.generated.js +1 -25
  61. package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
  62. package/dist/backend/icons/lucideRegistry.js +3 -3
  63. package/dist/backend/icons/lucideRegistry.js.map +2 -2
  64. package/dist/backend/icons/lucideRegistryRuntime.js +28 -0
  65. package/dist/backend/icons/lucideRegistryRuntime.js.map +7 -0
  66. package/dist/backend/injection/spotIds.js +23 -18
  67. package/dist/backend/injection/spotIds.js.map +2 -2
  68. package/dist/backend/inputs/ComboboxInput.js.map +1 -1
  69. package/dist/backend/messages/EmailThreadsPanel.js +1 -1
  70. package/dist/backend/messages/EmailThreadsPanel.js.map +2 -2
  71. package/dist/backend/perspectiveState.js +34 -0
  72. package/dist/backend/perspectiveState.js.map +7 -0
  73. package/dist/backend/schedule/ScheduleAgenda.js +3 -3
  74. package/dist/backend/schedule/ScheduleAgenda.js.map +1 -1
  75. package/dist/backend/schedule/ScheduleGrid.js +3 -3
  76. package/dist/backend/schedule/ScheduleGrid.js.map +1 -1
  77. package/dist/backend/schedule/ScheduleToolbar.js +87 -70
  78. package/dist/backend/schedule/ScheduleToolbar.js.map +2 -2
  79. package/dist/backend/sidebar/SidebarCustomizationEditor.js +30 -2
  80. package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
  81. package/dist/backend/sidebar/customization-helpers.js +30 -0
  82. package/dist/backend/sidebar/customization-helpers.js.map +2 -2
  83. package/dist/backend/utils/apiCall.js +21 -1
  84. package/dist/backend/utils/apiCall.js.map +2 -2
  85. package/dist/backend/utils/customFieldDefs.js.map +2 -2
  86. package/dist/backend/utils/customFieldForms.js +10 -9
  87. package/dist/backend/utils/customFieldForms.js.map +2 -2
  88. package/dist/backend/utils/fetchAllPages.js +57 -0
  89. package/dist/backend/utils/fetchAllPages.js.map +7 -0
  90. package/dist/backend/utils/nav.js +5 -2
  91. package/dist/backend/utils/nav.js.map +2 -2
  92. package/dist/backend/version-history/VersionHistoryPanel.js +1 -1
  93. package/dist/backend/version-history/VersionHistoryPanel.js.map +2 -2
  94. package/dist/frontend/LanguageSwitcher.js +2 -1
  95. package/dist/frontend/LanguageSwitcher.js.map +2 -2
  96. package/dist/portal/PortalContext.js +1 -1
  97. package/dist/portal/PortalContext.js.map +2 -2
  98. package/dist/portal/hooks/portalBridgeStatus.js +25 -0
  99. package/dist/portal/hooks/portalBridgeStatus.js.map +7 -0
  100. package/dist/portal/hooks/usePortalEventBridge.js +6 -0
  101. package/dist/portal/hooks/usePortalEventBridge.js.map +2 -2
  102. package/dist/portal/hooks/usePortalNotifications.js +42 -4
  103. package/dist/portal/hooks/usePortalNotifications.js.map +2 -2
  104. package/dist/portal/utils/nav.js +2 -3
  105. package/dist/portal/utils/nav.js.map +2 -2
  106. package/dist/primitives/ErrorNotice.js +1 -1
  107. package/dist/primitives/ErrorNotice.js.map +2 -2
  108. package/dist/primitives/Notice.js +1 -1
  109. package/dist/primitives/Notice.js.map +1 -1
  110. package/dist/primitives/button.js +9 -4
  111. package/dist/primitives/button.js.map +2 -2
  112. package/dist/theme/ThemeProvider.js +1 -1
  113. package/dist/theme/ThemeProvider.js.map +2 -2
  114. package/dist/theme/index.js +3 -0
  115. package/dist/theme/index.js.map +2 -2
  116. package/dist/theme/theme-init-script.js +17 -0
  117. package/dist/theme/theme-init-script.js.map +7 -0
  118. package/figma/alert.figma.tsx +35 -0
  119. package/figma/badge.figma.tsx +33 -0
  120. package/figma/button.figma.tsx +33 -0
  121. package/figma/checkbox.figma.tsx +28 -0
  122. package/figma/drawer.figma.tsx +40 -0
  123. package/figma/input-variants.figma.tsx +58 -0
  124. package/figma/radio.figma.tsx +16 -0
  125. package/figma/select.figma.tsx +24 -0
  126. package/figma/status-badge.figma.tsx +24 -0
  127. package/figma/switch.figma.tsx +28 -0
  128. package/figma/tabs.figma.tsx +22 -0
  129. package/figma/tag.figma.tsx +27 -0
  130. package/figma.config.json +6 -0
  131. package/jest.config.cjs +2 -1
  132. package/package.json +21 -18
  133. package/scripts/lucideRegistrySource.cjs +133 -0
  134. package/src/ai/AiChat.tsx +36 -7
  135. package/src/ai/LoopDisabledBanner.tsx +1 -1
  136. package/src/ai/__tests__/AiChat.test.tsx +31 -0
  137. package/src/ai/parts/ConfirmationCard.tsx +4 -4
  138. package/src/ai/parts/FieldDiffCard.tsx +2 -2
  139. package/src/ai/parts/MutationResultCard.tsx +3 -3
  140. package/src/ai/ui-parts/pending-phase3-placeholder.tsx +2 -2
  141. package/src/backend/AppShell.tsx +55 -4
  142. package/src/backend/AuthSessionGuard.tsx +29 -0
  143. package/src/backend/BackendChromeProvider.tsx +19 -1
  144. package/src/backend/CrudForm.tsx +18 -4
  145. package/src/backend/DataTable.tsx +36 -19
  146. package/src/backend/JsonBuilder.tsx +3 -3
  147. package/src/backend/NextStepCallout.tsx +4 -4
  148. package/src/backend/PerspectiveSidebar.tsx +1 -1
  149. package/src/backend/ProfileDropdown.tsx +1 -0
  150. package/src/backend/RowActions.tsx +2 -2
  151. package/src/backend/__tests__/AppShell.test.tsx +65 -7
  152. package/src/backend/__tests__/AuthSessionGuard.test.tsx +52 -0
  153. package/src/backend/__tests__/BackendChromeProvider.test.tsx +98 -2
  154. package/src/backend/__tests__/CrudForm.fieldsetBinding.test.tsx +158 -0
  155. package/src/backend/__tests__/CrudForm.groupInjectionColumn.test.tsx +98 -0
  156. package/src/backend/__tests__/DataTable.metaHiddenLateColumns.test.tsx +105 -0
  157. package/src/backend/__tests__/DataTable.perspectiveStorage.test.ts +62 -1
  158. package/src/backend/__tests__/nav-utils.test.ts +139 -0
  159. package/src/backend/confirm-dialog/ConfirmDialog.tsx +16 -1
  160. package/src/backend/confirm-dialog/__tests__/ConfirmDialog.test.tsx +68 -1
  161. package/src/backend/crud/__tests__/useGroupOrder.test.ts +39 -1
  162. package/src/backend/crud/useGroupOrder.ts +30 -25
  163. package/src/backend/custom-fields/FieldDefinitionsManager.tsx +1 -1
  164. package/src/backend/dashboard/DashboardScreen.tsx +3 -3
  165. package/src/backend/detail/AddressTiles.tsx +3 -6
  166. package/src/backend/detail/AttachmentDeleteDialog.tsx +1 -1
  167. package/src/backend/detail/AttachmentMetadataDialog.tsx +1 -1
  168. package/src/backend/detail/AttachmentsSection.tsx +1 -1
  169. package/src/backend/detail/NotesSection.tsx +1 -1
  170. package/src/backend/detail/TagsSection.tsx +1 -1
  171. package/src/backend/detail/__tests__/AttachmentAssignmentsEditor.test.tsx +21 -0
  172. package/src/backend/filters/AdvancedFilterPanel.tsx +1 -1
  173. package/src/backend/forms/FormHeader.tsx +1 -2
  174. package/src/backend/icons/__tests__/lucideRegistryGenerator.test.ts +177 -0
  175. package/src/backend/icons/lucideRegistry.generated.tsx +3 -37
  176. package/src/backend/icons/lucideRegistry.ts +2 -2
  177. package/src/backend/icons/lucideRegistryRuntime.tsx +36 -0
  178. package/src/backend/injection/__tests__/spotIds.extension-points.test.ts +38 -0
  179. package/src/backend/injection/spotIds.ts +23 -18
  180. package/src/backend/inputs/ComboboxInput.tsx +1 -1
  181. package/src/backend/messages/EmailThreadsPanel.tsx +1 -1
  182. package/src/backend/perspectiveState.ts +44 -0
  183. package/src/backend/schedule/ScheduleAgenda.tsx +3 -3
  184. package/src/backend/schedule/ScheduleGrid.tsx +3 -3
  185. package/src/backend/schedule/ScheduleToolbar.tsx +89 -68
  186. package/src/backend/sidebar/SidebarCustomizationEditor.tsx +42 -3
  187. package/src/backend/sidebar/__tests__/group-visibility.test.ts +131 -0
  188. package/src/backend/sidebar/customization-helpers.ts +47 -0
  189. package/src/backend/utils/__tests__/apiCall.test.ts +61 -0
  190. package/src/backend/utils/__tests__/customFieldForms.optionsPaging.test.ts +95 -0
  191. package/src/backend/utils/__tests__/fetchAllPages.test.ts +103 -0
  192. package/src/backend/utils/apiCall.ts +29 -1
  193. package/src/backend/utils/customFieldDefs.ts +1 -0
  194. package/src/backend/utils/customFieldForms.ts +15 -11
  195. package/src/backend/utils/fetchAllPages.ts +93 -0
  196. package/src/backend/utils/nav.ts +22 -2
  197. package/src/backend/version-history/VersionHistoryPanel.tsx +1 -1
  198. package/src/frontend/LanguageSwitcher.tsx +1 -0
  199. package/src/portal/PortalContext.tsx +1 -1
  200. package/src/portal/hooks/__tests__/usePortalEventBridge.test.tsx +66 -0
  201. package/src/portal/hooks/__tests__/usePortalNotifications.test.tsx +155 -0
  202. package/src/portal/hooks/portalBridgeStatus.ts +29 -0
  203. package/src/portal/hooks/usePortalEventBridge.ts +6 -0
  204. package/src/portal/hooks/usePortalNotifications.ts +55 -12
  205. package/src/portal/utils/__tests__/nav.test.ts +2 -2
  206. package/src/portal/utils/nav.ts +3 -4
  207. package/src/primitives/ErrorNotice.tsx +1 -1
  208. package/src/primitives/Notice.tsx +5 -5
  209. package/src/primitives/__tests__/button.test.tsx +70 -0
  210. package/src/primitives/button.tsx +10 -4
  211. package/src/theme/ThemeProvider.tsx +1 -2
  212. package/src/theme/__tests__/theme-init-script.test.ts +70 -0
  213. package/src/theme/index.ts +1 -0
  214. package/src/theme/theme-init-script.ts +23 -0
@@ -0,0 +1,98 @@
1
+ /** @jest-environment jsdom */
2
+ jest.setTimeout(15000)
3
+
4
+ // Regression guard for #4400: an injected group widget placed in column 1 must
5
+ // render as a full-width row in the main stack — CrudForm must NOT switch to
6
+ // the narrow 7fr/3fr secondary-column layout unless a widget explicitly asks
7
+ // for column 2. The hook is mocked so the test drives the injected widgets
8
+ // directly, following the CrudForm.fieldInjection.test.tsx pattern.
9
+ let injectedGroupWidgets: unknown[] = []
10
+
11
+ jest.mock('next/navigation', () => ({
12
+ useRouter: () => ({ push: () => {} }),
13
+ usePathname: () => '/',
14
+ useSearchParams: () => new URLSearchParams(),
15
+ }))
16
+ jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
17
+ jest.mock('../injection/InjectionSpot', () => ({
18
+ __esModule: true,
19
+ InjectionSpot: () => null,
20
+ useInjectionWidgets: () => ({ widgets: injectedGroupWidgets, loading: false, error: null }),
21
+ useInjectionSpotEvents: () => ({ triggerEvent: jest.fn(async () => ({ ok: true, data: {} })) }),
22
+ }))
23
+ jest.mock('../injection/useInjectionDataWidgets', () => ({
24
+ __esModule: true,
25
+ useInjectionDataWidgets: () => ({ widgets: [], isLoading: false, error: null }),
26
+ }))
27
+
28
+ import * as React from 'react'
29
+ import { waitFor } from '@testing-library/react'
30
+ import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
31
+ import { CrudForm, type CrudField, type CrudFormGroup } from '../CrudForm'
32
+
33
+ const fields: CrudField[] = [
34
+ { id: 'name', label: 'Name', type: 'text' },
35
+ ]
36
+
37
+ const groups: CrudFormGroup[] = [
38
+ { id: 'contact', label: 'Contact', fields: ['name'] },
39
+ ]
40
+
41
+ function makeGroupWidget(column: 1 | 2) {
42
+ return {
43
+ widgetId: 'customer_accounts.injection.company-users',
44
+ placement: { kind: 'group', column, groupLabel: 'Portal users', priority: 200 },
45
+ module: {
46
+ metadata: { title: 'Portal users', description: '' },
47
+ Widget: () => <div data-testid="portal-users-widget">portal users</div>,
48
+ },
49
+ }
50
+ }
51
+
52
+ function renderForm() {
53
+ return renderWithProviders(
54
+ React.createElement(CrudForm as never, {
55
+ title: 'Company',
56
+ entityId: 'customers:company',
57
+ fields,
58
+ groups,
59
+ onSubmit: () => {},
60
+ }),
61
+ )
62
+ }
63
+
64
+ describe('CrudForm injected group column placement (#4400)', () => {
65
+ afterEach(() => {
66
+ injectedGroupWidgets = []
67
+ })
68
+
69
+ it('renders a column-1 group widget as a full-width row without the secondary column', async () => {
70
+ injectedGroupWidgets = [makeGroupWidget(1)]
71
+
72
+ const { container } = renderForm()
73
+
74
+ await waitFor(() => {
75
+ expect(container.querySelector('[data-testid="portal-users-widget"]')).toBeTruthy()
76
+ })
77
+
78
+ expect(container.querySelector('[data-crud-injection-region]')).toBeNull()
79
+ const twoColumnGrid = Array.from(container.querySelectorAll('div')).find((node) =>
80
+ node.className.includes('7fr_3fr'),
81
+ )
82
+ expect(twoColumnGrid).toBeUndefined()
83
+ })
84
+
85
+ it('still supports an explicit column-2 group widget via the secondary column (sanity)', async () => {
86
+ injectedGroupWidgets = [makeGroupWidget(2)]
87
+
88
+ const { container } = renderForm()
89
+
90
+ await waitFor(() => {
91
+ expect(container.querySelector('[data-testid="portal-users-widget"]')).toBeTruthy()
92
+ })
93
+
94
+ const secondaryRegion = container.querySelector('[data-crud-injection-region]')
95
+ expect(secondaryRegion).toBeTruthy()
96
+ expect(secondaryRegion?.querySelector('[data-testid="portal-users-widget"]')).toBeTruthy()
97
+ })
98
+ })
@@ -0,0 +1,105 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import * as React from 'react'
5
+ import { render, screen, waitFor } from '@testing-library/react'
6
+ import type { ColumnDef } from '@tanstack/react-table'
7
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
8
+ import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
9
+ import { DataTable } from '../DataTable'
10
+
11
+ jest.mock('next/navigation', () => ({
12
+ useRouter: () => ({ push: jest.fn(), replace: jest.fn(), prefetch: jest.fn(), refresh: jest.fn() }),
13
+ }))
14
+
15
+ jest.mock('../injection/useInjectionDataWidgets', () => ({
16
+ useInjectionDataWidgets: () => ({ widgets: [], isLoading: false }),
17
+ }))
18
+
19
+ jest.mock('@open-mercato/shared/modules/widgets/injection-loader', () => ({
20
+ getInjectionRegistryVersion: () => 0,
21
+ subscribeToInjectionRegistryChanges: () => () => {},
22
+ loadInjectionWidgetsForSpot: jest.fn(async () => []),
23
+ loadInjectionDataWidgetsForSpot: jest.fn(async () => []),
24
+ }))
25
+
26
+ type Row = { id: string; name: string }
27
+
28
+ const ROWS: Row[] = [{ id: '1', name: 'Row one' }]
29
+
30
+ const BASE_COLUMNS: ColumnDef<Row>[] = [
31
+ { accessorKey: 'name', header: 'Name' },
32
+ ]
33
+
34
+ const COLUMNS_WITH_CUSTOM_FIELDS: ColumnDef<Row>[] = [
35
+ ...BASE_COLUMNS,
36
+ { accessorKey: 'cf_archived_note', header: 'Archived note', meta: { hidden: true } },
37
+ { accessorKey: 'cf_active_note', header: 'Active note' },
38
+ ]
39
+
40
+ function Harness({ columns, initialSettings }: { columns: ColumnDef<Row>[]; initialSettings?: unknown }) {
41
+ const queryClient = React.useMemo(
42
+ () => new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }),
43
+ [],
44
+ )
45
+ return (
46
+ <QueryClientProvider client={queryClient}>
47
+ <I18nProvider locale="en" dict={{}}>
48
+ <DataTable
49
+ columns={columns as never}
50
+ data={ROWS as never}
51
+ {...(initialSettings
52
+ ? ({ perspective: { tableId: 'meta-hidden-late-columns', initialState: { initialSettings } } } as never)
53
+ : {})}
54
+ />
55
+ </I18nProvider>
56
+ </QueryClientProvider>
57
+ )
58
+ }
59
+
60
+ describe('DataTable — meta.hidden on columns that arrive after the first render', () => {
61
+ it('hides a meta.hidden column that only appears on a later render', async () => {
62
+ // The regression: the auto-hide pass used to be latched by a single boolean ref, so it
63
+ // ran on the first render — when no `cf_*` column existed yet — found nothing to hide,
64
+ // and never ran again. Custom fields declared `listVisible: false` stayed visible for
65
+ // the whole session after a hard page load, while a client-side navigation (definitions
66
+ // already in the query cache) rendered them correctly.
67
+ const { rerender } = render(<Harness columns={BASE_COLUMNS} />)
68
+ await waitFor(() => expect(screen.getByText('Name')).toBeTruthy())
69
+
70
+ rerender(<Harness columns={COLUMNS_WITH_CUSTOM_FIELDS} />)
71
+
72
+ await waitFor(() => expect(screen.queryByText('Archived note')).toBeNull())
73
+ })
74
+
75
+ it('leaves a late column without meta.hidden visible', async () => {
76
+ // Pins the other half of the contract, so the fix can never degrade into
77
+ // "columns that arrive late are dropped".
78
+ const { rerender } = render(<Harness columns={BASE_COLUMNS} />)
79
+ await waitFor(() => expect(screen.getByText('Name')).toBeTruthy())
80
+
81
+ rerender(<Harness columns={COLUMNS_WITH_CUSTOM_FIELDS} />)
82
+
83
+ await waitFor(() => expect(screen.getByText('Active note')).toBeTruthy())
84
+ })
85
+
86
+ it('hides a meta.hidden column that is present from the very first render', async () => {
87
+ // The behaviour the original latch was written for, which must survive the fix.
88
+ render(<Harness columns={COLUMNS_WITH_CUSTOM_FIELDS} />)
89
+
90
+ await waitFor(() => expect(screen.getByText('Active note')).toBeTruthy())
91
+ expect(screen.queryByText('Archived note')).toBeNull()
92
+ })
93
+
94
+ it('lets a stored perspective override the meta.hidden defaults', async () => {
95
+ // A saved perspective seeds columnVisibility at mount and wins outright.
96
+ render(
97
+ <Harness
98
+ columns={COLUMNS_WITH_CUSTOM_FIELDS}
99
+ initialSettings={{ columnVisibility: { cf_archived_note: true } }}
100
+ />,
101
+ )
102
+
103
+ await waitFor(() => expect(screen.getByText('Archived note')).toBeTruthy())
104
+ })
105
+ })
@@ -1,7 +1,8 @@
1
1
  /** @jest-environment jsdom */
2
- import { readPerspectiveSnapshot, writePerspectiveSnapshot } from '../DataTable'
2
+ import { clearAllPerspectiveState, readPerspectiveSnapshot, writePerspectiveSnapshot } from '../DataTable'
3
3
 
4
4
  const PREFIX = 'om_table_perspective_snapshot'
5
+ const COOKIE_PREFIX = 'om_table_perspective'
5
6
 
6
7
  describe('DataTable perspective snapshot storage (versioned envelope)', () => {
7
8
  beforeEach(() => {
@@ -52,3 +53,63 @@ describe('DataTable perspective snapshot storage (versioned envelope)', () => {
52
53
  expect(localStorage.getItem(`${PREFIX}:tbl5`)).toBeNull()
53
54
  })
54
55
  })
56
+
57
+ describe('clearAllPerspectiveState (tenant-isolation at the auth identity boundary, #4185)', () => {
58
+ beforeEach(() => {
59
+ localStorage.clear()
60
+ for (const cookie of document.cookie ? document.cookie.split(';') : []) {
61
+ const name = cookie.split('=')[0]?.trim()
62
+ if (name) document.cookie = `${name}=; Path=/; Max-Age=0`
63
+ }
64
+ })
65
+
66
+ it('removes every perspective snapshot across all tables', () => {
67
+ writePerspectiveSnapshot('customers-companies', {
68
+ perspectiveId: null,
69
+ settings: { columnSizing: { name: 210, status: 60 } },
70
+ updatedAt: 1,
71
+ })
72
+ writePerspectiveSnapshot('customers-people', {
73
+ perspectiveId: null,
74
+ settings: { columnSizing: { name: 300 } },
75
+ updatedAt: 2,
76
+ })
77
+
78
+ clearAllPerspectiveState()
79
+
80
+ expect(localStorage.getItem(`${PREFIX}:customers-companies`)).toBeNull()
81
+ expect(localStorage.getItem(`${PREFIX}:customers-people`)).toBeNull()
82
+ expect(readPerspectiveSnapshot('customers-companies')).toBeNull()
83
+ expect(readPerspectiveSnapshot('customers-people')).toBeNull()
84
+ })
85
+
86
+ it('leaves unrelated localStorage keys untouched', () => {
87
+ writePerspectiveSnapshot('customers-companies', {
88
+ perspectiveId: null,
89
+ settings: { columnSizing: { name: 210 } },
90
+ updatedAt: 1,
91
+ })
92
+ localStorage.setItem('om_login_tenant', 'acme')
93
+ localStorage.setItem('om:auth:identity', '123')
94
+
95
+ clearAllPerspectiveState()
96
+
97
+ expect(localStorage.getItem(`${PREFIX}:customers-companies`)).toBeNull()
98
+ expect(localStorage.getItem('om_login_tenant')).toBe('acme')
99
+ expect(localStorage.getItem('om:auth:identity')).toBe('123')
100
+ })
101
+
102
+ it('expires the active-view perspective cookies', () => {
103
+ document.cookie = `${COOKIE_PREFIX}:customers-companies=view-1; Path=/`
104
+ expect(document.cookie).toContain(`${COOKIE_PREFIX}:customers-companies=view-1`)
105
+
106
+ clearAllPerspectiveState()
107
+
108
+ expect(document.cookie).not.toContain(`${COOKIE_PREFIX}:customers-companies`)
109
+ })
110
+
111
+ it('is a no-op when nothing is stored', () => {
112
+ expect(() => clearAllPerspectiveState()).not.toThrow()
113
+ expect(localStorage.length).toBe(0)
114
+ })
115
+ })
@@ -202,4 +202,143 @@ describe('settings navigation helpers', () => {
202
202
  )
203
203
  expect(allSettingsHrefs).not.toContain('/backend/config/system-status')
204
204
  })
205
+
206
+ it('orders settings sections by the untranslated group id, not the rendered label', () => {
207
+ const polishEntry = (
208
+ group: string,
209
+ groupKey: string,
210
+ href: string,
211
+ ): AdminNavItem => ({
212
+ group,
213
+ groupId: groupKey,
214
+ groupKey,
215
+ groupDefaultName: group,
216
+ title: href,
217
+ defaultTitle: href,
218
+ href,
219
+ enabled: true,
220
+ order: 10,
221
+ pageContext: 'settings',
222
+ })
223
+
224
+ const entries: AdminNavItem[] = [
225
+ polishEntry('System', 'settings.sections.system', '/backend/config/system-status'),
226
+ polishEntry('Konfiguracja modułów', 'settings.sections.moduleConfigs', '/backend/config/wms'),
227
+ polishEntry('Autoryzacja', 'settings.sections.auth', '/backend/users'),
228
+ ]
229
+
230
+ const sections = buildSettingsSections(entries, {
231
+ 'settings.sections.system': 1,
232
+ 'settings.sections.auth': 2,
233
+ 'settings.sections.moduleConfigs': 5,
234
+ })
235
+
236
+ expect(sections.map((section) => section.id)).toEqual([
237
+ 'settings.sections.system',
238
+ 'settings.sections.auth',
239
+ 'settings.sections.moduleConfigs',
240
+ ])
241
+ expect(sections.map((section) => section.order)).toEqual([1, 2, 5])
242
+ })
243
+
244
+ it('keeps the section label and labelKey of the rendered group', () => {
245
+ const entries: AdminNavItem[] = [
246
+ {
247
+ group: 'Konfiguracja modułów',
248
+ groupId: 'settings.sections.moduleConfigs',
249
+ groupKey: 'settings.sections.moduleConfigs',
250
+ groupDefaultName: 'Konfiguracja modułów',
251
+ title: 'WMS',
252
+ defaultTitle: 'WMS',
253
+ href: '/backend/config/wms',
254
+ enabled: true,
255
+ pageContext: 'settings',
256
+ },
257
+ ]
258
+
259
+ const [section] = buildSettingsSections(entries, { 'settings.sections.moduleConfigs': 5 })
260
+
261
+ expect(section.label).toBe('Konfiguracja modułów')
262
+ expect(section.labelKey).toBe('settings.sections.moduleConfigs')
263
+ })
264
+
265
+ it('still honors legacy sectionOrder maps keyed by the English label slug', () => {
266
+ const entries: AdminNavItem[] = [
267
+ {
268
+ group: 'Module Configs',
269
+ groupId: 'settings.sections.moduleConfigs',
270
+ groupKey: 'settings.sections.moduleConfigs',
271
+ groupDefaultName: 'Module Configs',
272
+ title: 'WMS',
273
+ defaultTitle: 'WMS',
274
+ href: '/backend/config/wms',
275
+ enabled: true,
276
+ pageContext: 'settings',
277
+ },
278
+ ]
279
+
280
+ const [section] = buildSettingsSections(entries, { 'module-configs': 5 })
281
+
282
+ expect(section.order).toBe(5)
283
+ })
284
+
285
+ it('falls back to the catch-all weight for groups the order map does not name', () => {
286
+ const entries: AdminNavItem[] = [
287
+ {
288
+ group: 'Bezpieczeństwo',
289
+ groupId: 'settings.sections.security',
290
+ groupKey: 'settings.sections.security',
291
+ groupDefaultName: 'Bezpieczeństwo',
292
+ title: 'Audit Logs',
293
+ defaultTitle: 'Audit Logs',
294
+ href: '/backend/audit-logs',
295
+ enabled: true,
296
+ pageContext: 'settings',
297
+ },
298
+ ]
299
+
300
+ const [section] = buildSettingsSections(entries, { 'settings.sections.system': 1 })
301
+
302
+ expect(section.order).toBe(999)
303
+ })
304
+
305
+ // A partially translated locale renders `translate(groupKey, group)` as the localized label for
306
+ // pages whose key is covered and as the raw English fallback for the rest, so one group can reach
307
+ // buildSettingsSections under two different labels.
308
+ it('groups entries that share a group id even when labels differ', () => {
309
+ const entries: AdminNavItem[] = [
310
+ {
311
+ group: 'Konfiguracja modułów',
312
+ groupId: 'settings.sections.moduleConfigs',
313
+ groupKey: 'settings.sections.moduleConfigs',
314
+ groupDefaultName: 'Konfiguracja modułów',
315
+ title: 'WMS',
316
+ defaultTitle: 'WMS',
317
+ href: '/backend/config/wms',
318
+ enabled: true,
319
+ order: 20,
320
+ pageContext: 'settings',
321
+ },
322
+ {
323
+ group: 'Module Configs',
324
+ groupId: 'settings.sections.moduleConfigs',
325
+ groupKey: 'settings.sections.moduleConfigs',
326
+ groupDefaultName: 'Module Configs',
327
+ title: 'Sales',
328
+ defaultTitle: 'Sales',
329
+ href: '/backend/config/sales',
330
+ enabled: true,
331
+ order: 10,
332
+ pageContext: 'settings',
333
+ },
334
+ ]
335
+
336
+ const sections = buildSettingsSections(entries, { 'settings.sections.moduleConfigs': 5 })
337
+
338
+ expect(sections).toHaveLength(1)
339
+ expect(sections[0].items.map((item) => item.href)).toEqual([
340
+ '/backend/config/sales',
341
+ '/backend/config/wms',
342
+ ])
343
+ })
205
344
  })
@@ -136,6 +136,21 @@ export function ConfirmDialog({
136
136
  return () => dialog.removeEventListener("cancel", handleCancel);
137
137
  }, [loading, requestClose]);
138
138
 
139
+ React.useEffect(() => {
140
+ if (!open) return;
141
+
142
+ const handleEscape = (event: KeyboardEvent) => {
143
+ if (event.key !== "Escape") return;
144
+ if (!(event.target instanceof Node) || !dialogRef.current?.contains(event.target)) return;
145
+ event.preventDefault();
146
+ event.stopPropagation();
147
+ if (!loading) requestClose();
148
+ };
149
+
150
+ window.addEventListener("keydown", handleEscape, true);
151
+ return () => window.removeEventListener("keydown", handleEscape, true);
152
+ }, [loading, open, requestClose]);
153
+
139
154
  // Handle backdrop click
140
155
  const handleBackdropClick = (e: React.MouseEvent<HTMLDialogElement>) => {
141
156
  // Only close if clicking directly on the dialog (backdrop), not its children
@@ -262,7 +277,7 @@ export function ConfirmDialog({
262
277
  {resolvedConfirmText !== false && (
263
278
  <Button
264
279
  ref={confirmButtonRef}
265
- variant={variant === "destructive" ? "destructive" : "default"}
280
+ variant={variant === "destructive" ? "destructive-solid" : "default"}
266
281
  onClick={handleConfirm}
267
282
  disabled={loading}
268
283
  className="w-full sm:w-auto"
@@ -2,7 +2,7 @@
2
2
  * @jest-environment jsdom
3
3
  */
4
4
  import * as React from 'react'
5
- import { screen } from '@testing-library/react'
5
+ import { fireEvent, screen, within } from '@testing-library/react'
6
6
  import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
7
7
  import { ConfirmDialog } from '../ConfirmDialog'
8
8
 
@@ -60,4 +60,71 @@ describe('ConfirmDialog', () => {
60
60
  expect(container.contains(dialog)).toBe(false)
61
61
  expect(dialog.parentElement).toBe(document.body)
62
62
  })
63
+
64
+ it('renders the destructive confirmation as the solid variant', () => {
65
+ renderWithProviders(
66
+ <ConfirmDialog
67
+ open
68
+ variant="destructive"
69
+ onOpenChange={() => undefined}
70
+ onConfirm={() => undefined}
71
+ title="Delete customer?"
72
+ confirmText="Delete"
73
+ cancelText="Cancel"
74
+ />,
75
+ )
76
+
77
+ const classNames = screen.getByRole('button', { name: 'Delete' }).className.split(/\s+/)
78
+ expect(classNames).toContain('bg-destructive')
79
+ expect(classNames).toContain('text-white')
80
+ })
81
+
82
+ it('keeps the default confirmation on the primary variant', () => {
83
+ renderWithProviders(
84
+ <ConfirmDialog
85
+ open
86
+ onOpenChange={() => undefined}
87
+ onConfirm={() => undefined}
88
+ title="Publish article?"
89
+ confirmText="Publish"
90
+ cancelText="Cancel"
91
+ />,
92
+ )
93
+
94
+ const classNames = screen.getByRole('button', { name: 'Publish' }).className.split(/\s+/)
95
+ expect(classNames).toContain('bg-primary')
96
+ expect(classNames).not.toContain('bg-destructive')
97
+ })
98
+
99
+ it('closes only the confirmation that owns an Escape event', () => {
100
+ const firstOpenChange = jest.fn()
101
+ const secondOpenChange = jest.fn()
102
+
103
+ renderWithProviders(
104
+ <>
105
+ <ConfirmDialog
106
+ open
107
+ onOpenChange={firstOpenChange}
108
+ onConfirm={() => undefined}
109
+ title="First confirmation"
110
+ confirmText="Confirm first"
111
+ cancelText="Cancel first"
112
+ />
113
+ <ConfirmDialog
114
+ open
115
+ onOpenChange={secondOpenChange}
116
+ onConfirm={() => undefined}
117
+ title="Second confirmation"
118
+ confirmText="Confirm second"
119
+ cancelText="Cancel second"
120
+ />
121
+ </>,
122
+ )
123
+
124
+ const secondDialog = screen.getByRole('alertdialog', { name: 'Second confirmation' })
125
+ fireEvent.keyDown(within(secondDialog).getByRole('button', { name: 'Cancel second' }), { key: 'Escape' })
126
+
127
+ expect(firstOpenChange).not.toHaveBeenCalled()
128
+ expect(secondOpenChange).toHaveBeenCalledWith(false)
129
+ })
63
130
  })
@@ -1,5 +1,6 @@
1
1
  /** @jest-environment jsdom */
2
- import { act, renderHook } from '@testing-library/react'
2
+ import * as React from 'react'
3
+ import { act, render, renderHook, screen } from '@testing-library/react'
3
4
  import { useGroupOrder } from '../useGroupOrder'
4
5
 
5
6
  describe('useGroupOrder', () => {
@@ -36,6 +37,16 @@ describe('useGroupOrder', () => {
36
37
  expect(result.current.orderedIds).toEqual(['a', 'd', 'b', 'c'])
37
38
  })
38
39
 
40
+ it('composes two reorder() calls made within a single commit', () => {
41
+ const { result } = renderHook(() => useGroupOrder('people', ['a', 'b', 'c']))
42
+ act(() => {
43
+ result.current.reorder(0, 2)
44
+ result.current.reorder(0, 1)
45
+ })
46
+ expect(result.current.orderedIds).toEqual(['c', 'b', 'a'])
47
+ expect(JSON.parse(localStorage.getItem('om:group-order:people')!)).toEqual(['c', 'b', 'a'])
48
+ })
49
+
39
50
  it('updates ordering when defaults change to include a new id', () => {
40
51
  const { result, rerender } = renderHook(
41
52
  ({ ids }) => useGroupOrder('p', ids),
@@ -60,4 +71,31 @@ describe('useGroupOrder', () => {
60
71
  expect(spy).not.toHaveBeenCalledWith('om:group-order:p', expect.anything())
61
72
  spy.mockRestore()
62
73
  })
74
+
75
+ it('keeps a stable orderedIds identity when defaults are recreated with equal content', () => {
76
+ const { result, rerender } = renderHook(
77
+ ({ ids }) => useGroupOrder('p', ids),
78
+ { initialProps: { ids: ['a', 'b'] } },
79
+ )
80
+ const first = result.current.orderedIds
81
+ rerender({ ids: ['a', 'b'] })
82
+ expect(result.current.orderedIds).toBe(first)
83
+ })
84
+
85
+ it('does not loop when the host recreates defaults with different content on every render (#4386)', () => {
86
+ localStorage.setItem('om:group-order:unstable', JSON.stringify(['b', 'a']))
87
+ let renders = 0
88
+ function UnstableHost() {
89
+ renders += 1
90
+ if (renders > 50) throw new Error('render loop detected')
91
+ const defaults = renders % 2 === 1 ? ['a', 'b'] : ['a', 'b', `extra-${renders}`]
92
+ const { orderedIds } = useGroupOrder('unstable', defaults)
93
+ return React.createElement('div', { 'data-testid': 'order' }, orderedIds.join(','))
94
+ }
95
+
96
+ render(React.createElement(UnstableHost))
97
+
98
+ expect(renders).toBeLessThanOrEqual(6)
99
+ expect(screen.getByTestId('order').textContent).toContain('b,a')
100
+ })
63
101
  })
@@ -31,41 +31,46 @@ function arraysEqual(a: string[], b: string[]): boolean {
31
31
  /**
32
32
  * Returns the group IDs in the user's preferred order.
33
33
  * Falls back to the default order when no preference is stored.
34
+ *
35
+ * State holds only the saved preference; the visible order is derived during
36
+ * render. Syncing derived order back into state via an effect looped forever
37
+ * when a host recreated `defaultGroupIds` with different content on every
38
+ * render (#4386), so no effect writes state from `defaultGroupIds` here.
39
+ *
40
+ * `stableIdsRef` is mutated during render, which React's render-purity rule
41
+ * normally forbids. It is safe here because the guard swaps the reference only
42
+ * when the *content* differs: a concurrent render that React discards can leave
43
+ * behind a different array identity, but never a content-stale value, and the
44
+ * render that commits always leaves `stableIdsRef.current` content-equal to its
45
+ * own `mergedIds`. `reorder` advances the same ref from its event handler so
46
+ * that several reorders within one commit compose instead of overwriting each
47
+ * other; a discarded state update leaves the ref ahead of `savedOrder` only
48
+ * until the next render's content guard pulls it back (#4691).
34
49
  */
35
50
  export function useGroupOrder(pageType: string, defaultGroupIds: string[]) {
36
- const [orderedIds, setOrderedIds] = React.useState<string[]>(defaultGroupIds)
37
- const mounted = React.useRef(false)
51
+ const [savedOrder, setSavedOrder] = React.useState<string[] | null>(null)
38
52
 
39
53
  React.useEffect(() => {
40
- mounted.current = true
41
54
  const saved = readJsonFromLocalStorage<string[] | null>(getStorageKey(pageType), null)
42
- if (Array.isArray(saved)) {
43
- setOrderedIds(mergeOrder(saved, defaultGroupIds))
44
- }
45
- // Intentionally only runs on mount (per pageType); defaultGroupIds changes are
46
- // handled by the sync effect below.
47
- // eslint-disable-next-line react-hooks/exhaustive-deps
55
+ setSavedOrder(Array.isArray(saved) ? saved : null)
48
56
  }, [pageType])
49
57
 
50
- // Sync when defaultGroupIds changes (e.g. new groups added dynamically)
51
- React.useEffect(() => {
52
- setOrderedIds((prev) => {
53
- const merged = mergeOrder(prev, defaultGroupIds)
54
- return arraysEqual(prev, merged) ? prev : merged
55
- })
56
- }, [defaultGroupIds])
58
+ const mergedIds = savedOrder ? mergeOrder(savedOrder, defaultGroupIds) : defaultGroupIds
59
+
60
+ const stableIdsRef = React.useRef(mergedIds)
61
+ if (!arraysEqual(stableIdsRef.current, mergedIds)) {
62
+ stableIdsRef.current = mergedIds
63
+ }
64
+ const orderedIds = stableIdsRef.current
57
65
 
58
66
  const reorder = React.useCallback(
59
67
  (fromIndex: number, toIndex: number) => {
60
- setOrderedIds((prev) => {
61
- const next = [...prev]
62
- const [moved] = next.splice(fromIndex, 1)
63
- next.splice(toIndex, 0, moved)
64
- if (mounted.current) {
65
- writeJsonToLocalStorage(getStorageKey(pageType), next)
66
- }
67
- return next
68
- })
68
+ const next = [...stableIdsRef.current]
69
+ const [moved] = next.splice(fromIndex, 1)
70
+ next.splice(toIndex, 0, moved)
71
+ stableIdsRef.current = next
72
+ writeJsonToLocalStorage(getStorageKey(pageType), next)
73
+ setSavedOrder(next)
69
74
  },
70
75
  [pageType],
71
76
  )
@@ -355,7 +355,7 @@ export const FieldDefinitionsManager = React.forwardRef<FieldDefinitionsManagerH
355
355
  return (
356
356
  <div className="flex flex-col gap-3 sm:gap-4">
357
357
  {statusError ? (
358
- <Alert variant="destructive">
358
+ <Alert status="error">
359
359
  <AlertTitle>{t('entities.customFields.errors.title', 'Something went wrong')}</AlertTitle>
360
360
  <AlertDescription>{statusError}</AlertDescription>
361
361
  </Alert>