@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
@@ -59,6 +59,7 @@ import type {
59
59
  InjectionRowActionDefinition,
60
60
  } from '@open-mercato/shared/modules/widgets/injection'
61
61
  import { ComponentReplacementHandles } from '@open-mercato/shared/modules/widgets/component-registry'
62
+ import { dataTableExtensionSpotId, extensionSpotChildId } from '@open-mercato/shared/modules/widgets/extension-points'
62
63
  import { insertByInjectionPlacement } from '@open-mercato/shared/modules/widgets/injection-position'
63
64
  import { useVirtualizer } from '@tanstack/react-virtual'
64
65
  import type {
@@ -94,6 +95,11 @@ import {
94
95
  } from '@dnd-kit/sortable'
95
96
  import { CSS } from '@dnd-kit/utilities'
96
97
  import { createLogger } from '@open-mercato/shared/lib/logger'
98
+ import { clearAllPerspectiveState, PERSPECTIVE_COOKIE_PREFIX, PERSPECTIVE_STORAGE_PREFIX } from './perspectiveState'
99
+
100
+ // Re-exported so `@open-mercato/ui/backend/DataTable` stays the published import
101
+ // path for the purge (BACKWARD_COMPATIBILITY: import paths are a contract surface).
102
+ export { clearAllPerspectiveState }
97
103
 
98
104
  const logger = createLogger('ui').child({ component: 'DataTable' })
99
105
 
@@ -469,8 +475,6 @@ function resolveExportSections(config: DataTableExportConfig | null | undefined)
469
475
  return sections
470
476
  }
471
477
 
472
- const PERSPECTIVE_COOKIE_PREFIX = 'om_table_perspective'
473
- const PERSPECTIVE_STORAGE_PREFIX = 'om_table_perspective_snapshot'
474
478
 
475
479
  // Bounds for user-driven column resizing (#1835). Widths outside this range are
476
480
  // clamped so a persisted/dragged value can never collapse a column to nothing or
@@ -1352,8 +1356,8 @@ export function DataTable<T>({
1352
1356
  }, [injectionSpotId, perspective?.tableId, extensionTableIdProp])
1353
1357
  const resolvedInjectionSpotId =
1354
1358
  injectionSpotId
1355
- ?? (perspective?.tableId ? `data-table:${perspective.tableId}` : null)
1356
- ?? (extensionTableIdProp ? `data-table:${extensionTableIdProp}` : null)
1359
+ ?? (perspective?.tableId ? dataTableExtensionSpotId(perspective.tableId) : null)
1360
+ ?? (extensionTableIdProp ? dataTableExtensionSpotId(extensionTableIdProp) : null)
1357
1361
  const resolvedReplacementHandle = replacementHandle ?? ComponentReplacementHandles.dataTable(extensionTableId ?? 'unknown')
1358
1362
  const baseInjectionContext = React.useMemo(
1359
1363
  () => {
@@ -1376,32 +1380,32 @@ export function DataTable<T>({
1376
1380
  [injectionContext, perspective?.tableId, extensionTableId, title]
1377
1381
  )
1378
1382
  const headerInjectionSpotId = React.useMemo(
1379
- () => (resolvedInjectionSpotId ? `${resolvedInjectionSpotId}:header` : null),
1383
+ () => (resolvedInjectionSpotId ? extensionSpotChildId(resolvedInjectionSpotId, 'header') : null),
1380
1384
  [resolvedInjectionSpotId]
1381
1385
  )
1382
1386
  const toolbarInjectionSpotId = React.useMemo(
1383
- () => (resolvedInjectionSpotId ? `${resolvedInjectionSpotId}:toolbar` : null),
1387
+ () => (resolvedInjectionSpotId ? extensionSpotChildId(resolvedInjectionSpotId, 'toolbar') : null),
1384
1388
  [resolvedInjectionSpotId]
1385
1389
  )
1386
1390
  const searchTrailingInjectionSpotId = React.useMemo(
1387
- () => (resolvedInjectionSpotId ? `${resolvedInjectionSpotId}:search-trailing` : null),
1391
+ () => (resolvedInjectionSpotId ? extensionSpotChildId(resolvedInjectionSpotId, 'search-trailing') : null),
1388
1392
  [resolvedInjectionSpotId]
1389
1393
  )
1390
1394
  const footerInjectionSpotId = React.useMemo(
1391
- () => (resolvedInjectionSpotId ? `${resolvedInjectionSpotId}:footer` : null),
1395
+ () => (resolvedInjectionSpotId ? extensionSpotChildId(resolvedInjectionSpotId, 'footer') : null),
1392
1396
  [resolvedInjectionSpotId]
1393
1397
  )
1394
1398
  const { widgets: columnWidgets } = useInjectionDataWidgets(
1395
- extensionTableId ? `data-table:${extensionTableId}:columns` : '__disabled__:columns',
1399
+ extensionTableId ? dataTableExtensionSpotId(extensionTableId, 'columns') : '__disabled__:columns',
1396
1400
  )
1397
1401
  const { widgets: rowActionWidgets } = useInjectionDataWidgets(
1398
- extensionTableId ? `data-table:${extensionTableId}:row-actions` : '__disabled__:row-actions',
1402
+ extensionTableId ? dataTableExtensionSpotId(extensionTableId, 'row-actions') : '__disabled__:row-actions',
1399
1403
  )
1400
1404
  const { widgets: bulkActionWidgets } = useInjectionDataWidgets(
1401
- extensionTableId ? `data-table:${extensionTableId}:bulk-actions` : '__disabled__:bulk-actions',
1405
+ extensionTableId ? dataTableExtensionSpotId(extensionTableId, 'bulk-actions') : '__disabled__:bulk-actions',
1402
1406
  )
1403
1407
  const { widgets: filterWidgets } = useInjectionDataWidgets(
1404
- extensionTableId ? `data-table:${extensionTableId}:filters` : '__disabled__:filters',
1408
+ extensionTableId ? dataTableExtensionSpotId(extensionTableId, 'filters') : '__disabled__:filters',
1405
1409
  )
1406
1410
  const injectedColumnDefs = React.useMemo<{ def: ColumnDef<T, unknown>; placement: InjectionColumnDefinition['placement'] }[]>(() => {
1407
1411
  const entries: InjectionColumnDefinition[] = []
@@ -1695,18 +1699,31 @@ export function DataTable<T>({
1695
1699
  })
1696
1700
  }, [table, mergedColumns])
1697
1701
 
1698
- const initialVisibilityApplied = React.useRef(Boolean(mergedInitialSettings?.columnVisibility))
1702
+ // A stored perspective seeds `columnVisibility` at mount and wins outright over the
1703
+ // `meta.hidden` defaults, so the auto-hide pass is skipped entirely in that case.
1704
+ const visibilitySeededByStoredSettings = React.useRef(Boolean(mergedInitialSettings?.columnVisibility))
1705
+ // Auto-hiding is a per-column default, applied once per column — not an enforcement.
1706
+ // It cannot be latched by a single has-run boolean: columns arrive in waves, because
1707
+ // custom-field columns are built from definitions fetched asynchronously. The first
1708
+ // render carries no `cf_*` column at all, so a run-once pass found nothing to hide and
1709
+ // still burned the latch, leaving fields declared `listVisible: false` visible for the
1710
+ // rest of the session after a hard page load (#4859).
1711
+ // It also cannot lean on `columnVisibility` as the record of what has been decided:
1712
+ // `handleColumnChooserToggle` *deletes* a column's entry when the user turns it back on,
1713
+ // so a re-shown column is indistinguishable from one never seen and would be hidden again
1714
+ // on the next wave. Hence an explicit per-column record of what this pass has applied.
1715
+ const autoHiddenColumnIds = React.useRef<Set<string>>(new Set())
1699
1716
  React.useEffect(() => {
1700
- if (initialVisibilityApplied.current) return
1717
+ if (visibilitySeededByStoredSettings.current) return
1701
1718
  const hidden: VisibilityState = {}
1702
1719
  table.getAllLeafColumns().forEach((column) => {
1703
1720
  const hiddenMeta = (column.columnDef as any)?.meta?.hidden
1704
- if (hiddenMeta) hidden[column.id] = false
1721
+ if (!hiddenMeta || autoHiddenColumnIds.current.has(column.id)) return
1722
+ hidden[column.id] = false
1723
+ autoHiddenColumnIds.current.add(column.id)
1705
1724
  })
1706
- if (Object.keys(hidden).length) {
1707
- setColumnVisibility((prev) => ({ ...hidden, ...prev }))
1708
- }
1709
- initialVisibilityApplied.current = true
1725
+ if (!Object.keys(hidden).length) return
1726
+ setColumnVisibility((prev) => ({ ...hidden, ...prev }))
1710
1727
  }, [table, mergedColumns])
1711
1728
 
1712
1729
  const getCurrentSettings = React.useCallback((): PerspectiveSettings => {
@@ -164,7 +164,7 @@ export function JsonBuilder({
164
164
  disabled={disabled}
165
165
  />
166
166
  {parseError && (
167
- <div className="text-xs text-red-600">Invalid JSON format</div>
167
+ <div className="text-xs text-status-error-text">Invalid JSON format</div>
168
168
  )}
169
169
  </div>
170
170
  ) : (
@@ -185,7 +185,7 @@ export function JsonBuilder({
185
185
  </div>
186
186
  )}
187
187
 
188
- {error && <div className="text-xs text-red-600">{error}</div>}
188
+ {error && <div className="text-xs text-status-error-text">{error}</div>}
189
189
  {ConfirmDialogElement}
190
190
  </div>
191
191
  )
@@ -363,7 +363,7 @@ function JsonNode({ data, onChange, onDelete, readOnly, label, isRoot, confirm }
363
363
  type="button"
364
364
  variant="ghost"
365
365
  size="xs"
366
- className="text-muted-foreground hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"
366
+ className="text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
367
367
  title="Remove item"
368
368
  onClick={onDelete}
369
369
  >
@@ -42,16 +42,16 @@ type NextStepCalloutProps = {
42
42
  const STEP_STYLES: Record<NonNullable<NextStepCalloutStep['state']>, string> = {
43
43
  pending: '',
44
44
  active: 'border-primary/40 bg-primary/10 text-primary',
45
- completed: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-200',
45
+ completed: 'border-status-success-border bg-status-success-bg text-status-success-text',
46
46
  }
47
47
 
48
48
  const STATUS_STYLES: Record<NextStepCalloutStatusTone, string> = {
49
49
  default: 'border-border bg-background/80 text-foreground',
50
50
  info: 'border-primary/20 bg-primary/5 text-primary',
51
- success: 'border-emerald-500/20 bg-emerald-500/5 text-emerald-200',
52
- warning: 'border-amber-500/20 bg-amber-500/5 text-amber-200',
51
+ success: 'border-status-success-border bg-status-success-bg text-status-success-text',
52
+ warning: 'border-status-warning-border bg-status-warning-bg text-status-warning-text',
53
53
  danger: 'border-destructive/20 bg-destructive/5 text-destructive',
54
- muted: 'border-zinc-500/20 bg-zinc-500/5 text-zinc-200',
54
+ muted: 'border-status-neutral-border bg-status-neutral-bg text-status-neutral-text',
55
55
  }
56
56
 
57
57
  export function NextStepCallout({
@@ -476,7 +476,7 @@ export function PerspectiveSidebar({
476
476
  ) : null}
477
477
 
478
478
  {apiWarning ? (
479
- <Alert variant="warning" className="text-xs">{apiWarning}</Alert>
479
+ <Alert status="warning" className="text-xs">{apiWarning}</Alert>
480
480
  ) : null}
481
481
  {error ? <div className="text-sm text-status-error-text">{error}</div> : null}
482
482
  </section>
@@ -29,6 +29,7 @@ const localeLabels: Record<Locale, string> = {
29
29
  de: 'Deutsch',
30
30
  es: 'Español',
31
31
  pl: 'Polski',
32
+ ko: '한국어',
32
33
  }
33
34
 
34
35
  export function ProfileDropdown({
@@ -123,7 +123,7 @@ export function RowActions({ items = [] }: { items?: RowActionItem[] }) {
123
123
  <a
124
124
  key={idx}
125
125
  href={it.href}
126
- className={`block w-full text-left px-2 py-1 text-sm rounded hover:bg-accent ${it.destructive ? 'text-red-600' : ''}`}
126
+ className={`block w-full text-left px-2 py-1 text-sm rounded hover:bg-accent ${it.destructive ? 'text-destructive' : ''}`}
127
127
  role="menuitem"
128
128
  onClick={(event) => {
129
129
  event.stopPropagation()
@@ -138,7 +138,7 @@ export function RowActions({ items = [] }: { items?: RowActionItem[] }) {
138
138
  type="button"
139
139
  variant="ghost"
140
140
  size="sm"
141
- className={`w-full justify-start rounded-none font-normal ${it.destructive ? 'text-red-600' : ''}`}
141
+ className={`w-full justify-start rounded-none font-normal ${it.destructive ? 'text-destructive' : ''}`}
142
142
  role="menuitem"
143
143
  onClick={(event) => {
144
144
  event.stopPropagation()
@@ -226,7 +226,11 @@ describe('AppShell', () => {
226
226
  expect(screen.getByTestId('injection-spot:backend:layout:footer')).toBeInTheDocument()
227
227
  })
228
228
 
229
- it('uses backend chrome brand logo when the selected organization has one', async () => {
229
+ it.each([
230
+ ['internal-file', '/api/attachments/file/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'],
231
+ ['internal-image-query', '/api/attachments/image/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/acme.svg?width=320&height=320'],
232
+ ['external-webp', 'https://example.com/acme-wide-logo.webp'],
233
+ ])('uses an aspect-ratio-preserving backend chrome brand logo when enabled for %s', async (variant, logoSrc) => {
230
234
  const previousFetch = global.fetch
231
235
  const previousWindowFetch = window.fetch
232
236
  const previousOriginalFetch = (window as Window & { __omOriginalFetch?: typeof fetch }).__omOriginalFetch
@@ -235,8 +239,9 @@ describe('AppShell', () => {
235
239
  brand: {
236
240
  name: 'Acme',
237
241
  logo: {
238
- src: '/api/attachments/image/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/acme.png?width=320',
242
+ src: logoSrc,
239
243
  alt: 'Acme logo',
244
+ preserveAspectRatio: true,
240
245
  },
241
246
  },
242
247
  groups,
@@ -257,7 +262,7 @@ describe('AppShell', () => {
257
262
  <AppShell
258
263
  email="demo@example.com"
259
264
  groups={[]}
260
- adminNavApi="/api/auth/admin/nav-brand-logo"
265
+ adminNavApi={`/api/auth/admin/nav-brand-logo-${variant}`}
261
266
  >
262
267
  <div>Child content</div>
263
268
  </AppShell>,
@@ -266,11 +271,10 @@ describe('AppShell', () => {
266
271
 
267
272
  await waitFor(() => {
268
273
  const logo = screen.getByAltText('Acme logo')
269
- expect(logo).toHaveAttribute(
270
- 'src',
271
- '/api/attachments/image/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/acme.png?width=320',
272
- )
274
+ expect(logo).toHaveAttribute('src', logoSrc)
273
275
  expect(logo).toHaveAttribute('data-unoptimized', 'true')
276
+ expect(logo).toHaveClass('object-contain')
277
+ expect(logo).not.toHaveClass('rounded-full')
274
278
  })
275
279
  expect(screen.getByText('Acme')).toBeInTheDocument()
276
280
  } finally {
@@ -280,6 +284,60 @@ describe('AppShell', () => {
280
284
  }
281
285
  })
282
286
 
287
+ it('uses the cropped icon treatment for backend chrome brand logos by default', async () => {
288
+ const previousFetch = global.fetch
289
+ const previousWindowFetch = window.fetch
290
+ const previousOriginalFetch = (window as Window & { __omOriginalFetch?: typeof fetch }).__omOriginalFetch
291
+ const logoSrc = 'https://example.com/acme-wide-logo.webp'
292
+ const fetchMock = jest.fn().mockResolvedValue(
293
+ new Response(JSON.stringify({
294
+ brand: {
295
+ name: 'Acme',
296
+ logo: {
297
+ src: logoSrc,
298
+ alt: 'Acme logo',
299
+ },
300
+ },
301
+ groups,
302
+ settingsSections: [],
303
+ settingsPathPrefixes: [],
304
+ profileSections: [],
305
+ profilePathPrefixes: [],
306
+ grantedFeatures: [],
307
+ roles: [],
308
+ }), { status: 200, headers: { 'content-type': 'application/json' } }),
309
+ ) as typeof fetch
310
+ global.fetch = fetchMock
311
+ window.fetch = fetchMock
312
+ ;(window as Window & { __omOriginalFetch?: typeof fetch }).__omOriginalFetch = fetchMock
313
+
314
+ try {
315
+ renderWithProviders(
316
+ <AppShell
317
+ email="demo@example.com"
318
+ groups={[]}
319
+ adminNavApi="/api/auth/admin/nav-brand-logo-cropped"
320
+ >
321
+ <div>Child content</div>
322
+ </AppShell>,
323
+ { dict },
324
+ )
325
+
326
+ await waitFor(() => {
327
+ const logo = screen.getByAltText('Acme logo')
328
+ expect(logo).toHaveAttribute('src', logoSrc)
329
+ expect(logo).toHaveAttribute('data-unoptimized', 'true')
330
+ expect(logo).toHaveClass('object-cover')
331
+ expect(logo).toHaveClass('rounded-full')
332
+ expect(logo).not.toHaveClass('object-contain')
333
+ })
334
+ } finally {
335
+ global.fetch = previousFetch
336
+ window.fetch = previousWindowFetch
337
+ ;(window as Window & { __omOriginalFetch?: typeof fetch }).__omOriginalFetch = previousOriginalFetch
338
+ }
339
+ })
340
+
283
341
  it('renders nested settings links when settings parent route is active', async () => {
284
342
  mockPathname = '/backend/entities/user'
285
343
 
@@ -8,6 +8,7 @@ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
8
8
  import {
9
9
  __reload,
10
10
  AUTH_IDENTITY_STORAGE_KEY,
11
+ AUTH_IDENTITY_USER_STORAGE_KEY,
11
12
  AuthSessionGuard,
12
13
  notifyAuthIdentityChange,
13
14
  } from '../AuthSessionGuard'
@@ -148,3 +149,54 @@ describe('notifyAuthIdentityChange', () => {
148
149
  setItemSpy.mockRestore()
149
150
  })
150
151
  })
152
+
153
+ describe('perspective purge at the rendered identity boundary (#4185)', () => {
154
+ const snapshotKey = 'om_table_perspective_snapshot:customers-companies'
155
+
156
+ beforeEach(() => {
157
+ window.localStorage.clear()
158
+ apiCallMock.mockResolvedValue({ ok: true, status: 200, result: { ok: true } } as never)
159
+ })
160
+
161
+ afterEach(() => {
162
+ window.localStorage.clear()
163
+ })
164
+
165
+ it('purges legacy table state when no previous identity marker exists', () => {
166
+ window.localStorage.setItem(snapshotKey, '{"columnWidths":{"name":240}}')
167
+
168
+ render(<AuthSessionGuard serverUserId="user-1" />)
169
+
170
+ expect(window.localStorage.getItem(snapshotKey)).toBeNull()
171
+ expect(window.localStorage.getItem(AUTH_IDENTITY_USER_STORAGE_KEY)).toBe('user-1')
172
+ })
173
+
174
+ it('keeps a returning user\'s unsaved widths across a reload', () => {
175
+ window.localStorage.setItem(AUTH_IDENTITY_USER_STORAGE_KEY, 'user-1')
176
+ window.localStorage.setItem(snapshotKey, '{"columnWidths":{"name":240}}')
177
+
178
+ render(<AuthSessionGuard serverUserId="user-1" />)
179
+
180
+ expect(window.localStorage.getItem(snapshotKey)).toBe('{"columnWidths":{"name":240}}')
181
+ })
182
+
183
+ it('purges perspective state when the backend renders for a different user', () => {
184
+ window.localStorage.setItem(AUTH_IDENTITY_USER_STORAGE_KEY, 'user-1')
185
+ window.localStorage.setItem(snapshotKey, '{"columnWidths":{"name":240}}')
186
+
187
+ render(<AuthSessionGuard serverUserId="user-2" />)
188
+
189
+ expect(window.localStorage.getItem(snapshotKey)).toBeNull()
190
+ expect(window.localStorage.getItem(AUTH_IDENTITY_USER_STORAGE_KEY)).toBe('user-2')
191
+ })
192
+
193
+ it('does nothing when the shell renders without an authenticated user', () => {
194
+ window.localStorage.setItem(AUTH_IDENTITY_USER_STORAGE_KEY, 'user-1')
195
+ window.localStorage.setItem(snapshotKey, '{"columnWidths":{"name":240}}')
196
+
197
+ render(<AuthSessionGuard serverUserId={null} />)
198
+
199
+ expect(window.localStorage.getItem(snapshotKey)).toBe('{"columnWidths":{"name":240}}')
200
+ expect(window.localStorage.getItem(AUTH_IDENTITY_USER_STORAGE_KEY)).toBe('user-1')
201
+ })
202
+ })
@@ -3,8 +3,14 @@
3
3
  */
4
4
 
5
5
  import * as React from 'react'
6
- import { render, screen, waitFor } from '@testing-library/react'
7
- import { BackendChromeProvider, useBackendChrome } from '../BackendChromeProvider'
6
+ import { act, render, screen, waitFor } from '@testing-library/react'
7
+ import { emitOrganizationScopeChanged } from '@open-mercato/shared/lib/frontend/organizationEvents'
8
+ import type { BackendChromePayload } from '@open-mercato/shared/modules/navigation/backendChrome'
9
+ import {
10
+ BackendChromeProvider,
11
+ useBackendChrome,
12
+ useCurrentOrganization,
13
+ } from '../BackendChromeProvider'
8
14
  import { apiCall } from '../utils/apiCall'
9
15
 
10
16
  jest.mock('../utils/apiCall', () => ({
@@ -21,6 +27,30 @@ function ChromeStateProbe() {
21
27
  )
22
28
  }
23
29
 
30
+ function CurrentOrganizationProbe() {
31
+ const organization = useCurrentOrganization()
32
+ return (
33
+ <span data-testid="current-organization">
34
+ {organization ? `${organization.id}:${organization.name}` : 'none'}
35
+ </span>
36
+ )
37
+ }
38
+
39
+ function makePayload(
40
+ currentOrganization: BackendChromePayload['currentOrganization'],
41
+ ): BackendChromePayload {
42
+ return {
43
+ groups: [],
44
+ settingsSections: [],
45
+ settingsPathPrefixes: [],
46
+ profileSections: [],
47
+ profilePathPrefixes: [],
48
+ grantedFeatures: [],
49
+ roles: [],
50
+ currentOrganization,
51
+ }
52
+ }
53
+
24
54
  describe('BackendChromeProvider', () => {
25
55
  beforeEach(() => {
26
56
  ;(apiCall as jest.Mock).mockReset()
@@ -42,4 +72,70 @@ describe('BackendChromeProvider', () => {
42
72
  expect(screen.getByTestId('ready')).toHaveTextContent('not-ready')
43
73
  expect(apiCall).toHaveBeenCalledWith('/api/auth/admin/nav', { credentials: 'include' })
44
74
  })
75
+
76
+ it('returns null outside a provider', () => {
77
+ render(<CurrentOrganizationProbe />)
78
+
79
+ expect(screen.getByTestId('current-organization')).toHaveTextContent('none')
80
+ })
81
+
82
+ it('returns null before the chrome payload arrives', () => {
83
+ ;(apiCall as jest.Mock).mockReturnValue(new Promise(() => {}))
84
+
85
+ render(
86
+ <BackendChromeProvider adminNavApi="/api/auth/admin/nav?test=pending">
87
+ <CurrentOrganizationProbe />
88
+ </BackendChromeProvider>,
89
+ )
90
+
91
+ expect(screen.getByTestId('current-organization')).toHaveTextContent('none')
92
+ })
93
+
94
+ it('returns the organization from the hydrated payload', async () => {
95
+ ;(apiCall as jest.Mock).mockResolvedValue({
96
+ ok: true,
97
+ result: makePayload({ id: 'org-1', name: 'Northwind Ltd' }),
98
+ })
99
+
100
+ render(
101
+ <BackendChromeProvider adminNavApi="/api/auth/admin/nav?test=hydrated">
102
+ <CurrentOrganizationProbe />
103
+ </BackendChromeProvider>,
104
+ )
105
+
106
+ await waitFor(() => {
107
+ expect(screen.getByTestId('current-organization')).toHaveTextContent('org-1:Northwind Ltd')
108
+ })
109
+ })
110
+
111
+ it('refreshes the organization after the active scope changes', async () => {
112
+ ;(apiCall as jest.Mock)
113
+ .mockResolvedValueOnce({
114
+ ok: true,
115
+ result: makePayload({ id: 'org-1', name: 'Northwind Ltd' }),
116
+ })
117
+ .mockResolvedValueOnce({
118
+ ok: true,
119
+ result: makePayload({ id: 'org-2', name: 'Contoso GmbH' }),
120
+ })
121
+
122
+ render(
123
+ <BackendChromeProvider adminNavApi="/api/auth/admin/nav?test=scope-refresh">
124
+ <CurrentOrganizationProbe />
125
+ </BackendChromeProvider>,
126
+ )
127
+
128
+ await waitFor(() => {
129
+ expect(screen.getByTestId('current-organization')).toHaveTextContent('org-1:Northwind Ltd')
130
+ })
131
+
132
+ act(() => {
133
+ emitOrganizationScopeChanged({ organizationId: 'org-2', tenantId: 'tenant-1' })
134
+ })
135
+
136
+ await waitFor(() => {
137
+ expect(screen.getByTestId('current-organization')).toHaveTextContent('org-2:Contoso GmbH')
138
+ })
139
+ expect(apiCall).toHaveBeenCalledTimes(2)
140
+ })
45
141
  })
@@ -0,0 +1,158 @@
1
+ /** @jest-environment jsdom */
2
+ jest.setTimeout(15000)
3
+
4
+ const fetchCustomFieldFormStructureMock = jest.fn()
5
+ const buildFormFieldFromCustomFieldDefMock = jest.fn()
6
+ const triggerInjectionEventMock = jest.fn(async (_event: string, data: Record<string, unknown>) => ({
7
+ ok: true,
8
+ data,
9
+ }))
10
+
11
+ jest.mock('next/navigation', () => ({
12
+ useRouter: () => ({ push: jest.fn() }),
13
+ usePathname: () => '/',
14
+ useSearchParams: () => new URLSearchParams(),
15
+ }))
16
+ jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
17
+ jest.mock('../confirm-dialog', () => ({
18
+ useConfirmDialog: () => ({
19
+ confirm: jest.fn().mockResolvedValue(true),
20
+ ConfirmDialogElement: null,
21
+ }),
22
+ }))
23
+ jest.mock('../injection/InjectionSpot', () => ({
24
+ __esModule: true,
25
+ InjectionSpot: () => null,
26
+ useInjectionWidgets: () => ({ widgets: [], loading: false, error: null }),
27
+ useInjectionSpotEvents: () => ({ triggerEvent: triggerInjectionEventMock }),
28
+ }))
29
+ jest.mock('../injection/useInjectionDataWidgets', () => ({
30
+ __esModule: true,
31
+ useInjectionDataWidgets: () => ({ widgets: [], isLoading: false, error: null }),
32
+ }))
33
+ jest.mock('../custom-fields/FieldDefinitionsManager', () => {
34
+ const React = require('react')
35
+ return {
36
+ __esModule: true,
37
+ FieldDefinitionsManager: React.forwardRef(() => <div>Field definitions manager</div>),
38
+ }
39
+ })
40
+ jest.mock('../utils/customFieldForms', () => ({
41
+ __esModule: true,
42
+ buildFormFieldFromCustomFieldDef: (...args: unknown[]) => buildFormFieldFromCustomFieldDefMock(...args),
43
+ buildFormFieldsFromCustomFields: jest.fn(() => []),
44
+ fetchCustomFieldFormStructure: (...args: unknown[]) => fetchCustomFieldFormStructureMock(...args),
45
+ }))
46
+
47
+ import * as React from 'react'
48
+ import { act, waitFor } from '@testing-library/react'
49
+ import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
50
+ import { CrudForm, type CrudField, type CrudFormGroup } from '../CrudForm'
51
+
52
+ const ENTITY_ID = 'resources:resources_resource'
53
+ const GENERAL_CODE = 'resources_resource_general'
54
+ const LAPTOP_CODE = 'resources_resource_laptop'
55
+
56
+ const fields: CrudField[] = [{ id: 'name', label: 'Name', type: 'text' }]
57
+ const groups: CrudFormGroup[] = [
58
+ { id: 'details', title: 'Details', fields: ['name'] },
59
+ { id: 'custom', title: 'Custom fields', kind: 'customFields' },
60
+ ]
61
+
62
+ function configureTwoFieldsets() {
63
+ buildFormFieldFromCustomFieldDefMock.mockImplementation((definition: any) => ({
64
+ id: `cf_${definition.key}`,
65
+ label: definition.label ?? definition.key,
66
+ type: 'text',
67
+ }))
68
+ fetchCustomFieldFormStructureMock.mockResolvedValue({
69
+ fields: [],
70
+ definitions: [
71
+ {
72
+ entityId: ENTITY_ID,
73
+ key: 'asset_tag',
74
+ label: 'Asset tag',
75
+ kind: 'text',
76
+ fieldsets: [GENERAL_CODE],
77
+ },
78
+ {
79
+ entityId: ENTITY_ID,
80
+ key: 'serial_number',
81
+ label: 'Serial number',
82
+ kind: 'text',
83
+ fieldsets: [LAPTOP_CODE],
84
+ },
85
+ ],
86
+ metadata: {
87
+ items: [],
88
+ fieldsetsByEntity: {
89
+ [ENTITY_ID]: [
90
+ { code: GENERAL_CODE, label: 'General' },
91
+ { code: LAPTOP_CODE, label: 'Laptops' },
92
+ ],
93
+ },
94
+ entitySettings: {
95
+ [ENTITY_ID]: { singleFieldsetPerRecord: true },
96
+ },
97
+ },
98
+ })
99
+ }
100
+
101
+ function DelayedHost({
102
+ initialValues,
103
+ }: {
104
+ initialValues: Record<string, unknown> | undefined
105
+ }) {
106
+ return (
107
+ <CrudForm
108
+ embedded
109
+ title="Resource"
110
+ entityId={ENTITY_ID}
111
+ fields={fields}
112
+ groups={groups}
113
+ customFieldsetBindings={{ [ENTITY_ID]: { valueKey: 'customFieldsetCode' } }}
114
+ initialValues={initialValues ?? undefined}
115
+ isLoading={!initialValues}
116
+ onSubmit={() => {}}
117
+ />
118
+ )
119
+ }
120
+
121
+ describe('CrudForm customFieldsetBindings hydration', () => {
122
+ beforeEach(() => {
123
+ fetchCustomFieldFormStructureMock.mockReset()
124
+ buildFormFieldFromCustomFieldDefMock.mockReset()
125
+ triggerInjectionEventMock.mockClear()
126
+ configureTwoFieldsets()
127
+ })
128
+
129
+ it('activates the persisted fieldset when initialValues arrive after fieldset metadata loads', async () => {
130
+ // Mirrors the resources edit page: the record (and its persisted
131
+ // customFieldsetCode) loads asynchronously AFTER custom-field metadata has
132
+ // already resolved (gated behind resourceTypesLoaded), so the fieldset
133
+ // selector is hydrated only via the binding, never via a user interaction.
134
+ const { container, rerender } = renderWithProviders(
135
+ <DelayedHost initialValues={undefined} />,
136
+ { dict: { 'ui.forms.actions.save': 'Save' } },
137
+ )
138
+
139
+ await waitFor(() => {
140
+ expect(fetchCustomFieldFormStructureMock).toHaveBeenCalled()
141
+ })
142
+
143
+ await act(async () => {
144
+ rerender(
145
+ <DelayedHost
146
+ initialValues={{ id: 'res-1', name: 'Engineering Laptop 1', customFieldsetCode: LAPTOP_CODE }}
147
+ />,
148
+ )
149
+ await Promise.resolve()
150
+ await Promise.resolve()
151
+ })
152
+
153
+ await waitFor(() => {
154
+ expect(container.querySelector('[data-crud-field-id="cf_serial_number"]')).not.toBeNull()
155
+ })
156
+ expect(container.querySelector('[data-crud-field-id="cf_asset_tag"]')).toBeNull()
157
+ })
158
+ })