@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +4 -2
- package/agentic/standalone-guide.md +1 -1
- package/build.mjs +2 -54
- package/dist/ai/AiChat.js +26 -7
- package/dist/ai/AiChat.js.map +2 -2
- package/dist/ai/LoopDisabledBanner.js +1 -1
- package/dist/ai/LoopDisabledBanner.js.map +2 -2
- package/dist/ai/parts/ConfirmationCard.js +4 -4
- package/dist/ai/parts/ConfirmationCard.js.map +2 -2
- package/dist/ai/parts/FieldDiffCard.js +2 -2
- package/dist/ai/parts/FieldDiffCard.js.map +2 -2
- package/dist/ai/parts/MutationResultCard.js +3 -3
- package/dist/ai/parts/MutationResultCard.js.map +2 -2
- package/dist/ai/ui-parts/pending-phase3-placeholder.js +1 -1
- package/dist/ai/ui-parts/pending-phase3-placeholder.js.map +2 -2
- package/dist/backend/AppShell.js +43 -4
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/AuthSessionGuard.js +14 -0
- package/dist/backend/AuthSessionGuard.js.map +2 -2
- package/dist/backend/BackendChromeProvider.js +6 -1
- package/dist/backend/BackendChromeProvider.js.map +2 -2
- package/dist/backend/CrudForm.js +6 -4
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/DataTable.js +20 -18
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/JsonBuilder.js +3 -3
- package/dist/backend/JsonBuilder.js.map +2 -2
- package/dist/backend/NextStepCallout.js +4 -4
- package/dist/backend/NextStepCallout.js.map +1 -1
- package/dist/backend/PerspectiveSidebar.js +1 -1
- package/dist/backend/PerspectiveSidebar.js.map +2 -2
- package/dist/backend/ProfileDropdown.js +2 -1
- package/dist/backend/ProfileDropdown.js.map +2 -2
- package/dist/backend/RowActions.js +2 -2
- package/dist/backend/RowActions.js.map +2 -2
- package/dist/backend/confirm-dialog/ConfirmDialog.js +13 -1
- package/dist/backend/confirm-dialog/ConfirmDialog.js.map +2 -2
- package/dist/backend/crud/useGroupOrder.js +14 -21
- package/dist/backend/crud/useGroupOrder.js.map +2 -2
- package/dist/backend/custom-fields/FieldDefinitionsManager.js +1 -1
- package/dist/backend/custom-fields/FieldDefinitionsManager.js.map +2 -2
- package/dist/backend/dashboard/DashboardScreen.js +3 -3
- package/dist/backend/dashboard/DashboardScreen.js.map +2 -2
- package/dist/backend/detail/AddressTiles.js +4 -5
- package/dist/backend/detail/AddressTiles.js.map +2 -2
- package/dist/backend/detail/AttachmentDeleteDialog.js +1 -1
- package/dist/backend/detail/AttachmentDeleteDialog.js.map +2 -2
- package/dist/backend/detail/AttachmentMetadataDialog.js.map +1 -1
- package/dist/backend/detail/AttachmentsSection.js +1 -1
- package/dist/backend/detail/AttachmentsSection.js.map +2 -2
- package/dist/backend/detail/NotesSection.js +1 -1
- package/dist/backend/detail/NotesSection.js.map +2 -2
- package/dist/backend/detail/TagsSection.js +1 -1
- package/dist/backend/detail/TagsSection.js.map +2 -2
- package/dist/backend/filters/AdvancedFilterPanel.js +1 -1
- package/dist/backend/filters/AdvancedFilterPanel.js.map +2 -2
- package/dist/backend/forms/FormHeader.js +1 -2
- package/dist/backend/forms/FormHeader.js.map +2 -2
- package/dist/backend/icons/lucideRegistry.generated.js +1 -25
- package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
- package/dist/backend/icons/lucideRegistry.js +3 -3
- package/dist/backend/icons/lucideRegistry.js.map +2 -2
- package/dist/backend/icons/lucideRegistryRuntime.js +28 -0
- package/dist/backend/icons/lucideRegistryRuntime.js.map +7 -0
- package/dist/backend/injection/spotIds.js +23 -18
- package/dist/backend/injection/spotIds.js.map +2 -2
- package/dist/backend/inputs/ComboboxInput.js.map +1 -1
- package/dist/backend/messages/EmailThreadsPanel.js +1 -1
- package/dist/backend/messages/EmailThreadsPanel.js.map +2 -2
- package/dist/backend/perspectiveState.js +34 -0
- package/dist/backend/perspectiveState.js.map +7 -0
- package/dist/backend/schedule/ScheduleAgenda.js +3 -3
- package/dist/backend/schedule/ScheduleAgenda.js.map +1 -1
- package/dist/backend/schedule/ScheduleGrid.js +3 -3
- package/dist/backend/schedule/ScheduleGrid.js.map +1 -1
- package/dist/backend/schedule/ScheduleToolbar.js +87 -70
- package/dist/backend/schedule/ScheduleToolbar.js.map +2 -2
- package/dist/backend/sidebar/SidebarCustomizationEditor.js +30 -2
- package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
- package/dist/backend/sidebar/customization-helpers.js +30 -0
- package/dist/backend/sidebar/customization-helpers.js.map +2 -2
- package/dist/backend/utils/apiCall.js +21 -1
- package/dist/backend/utils/apiCall.js.map +2 -2
- package/dist/backend/utils/customFieldDefs.js.map +2 -2
- package/dist/backend/utils/customFieldForms.js +10 -9
- package/dist/backend/utils/customFieldForms.js.map +2 -2
- package/dist/backend/utils/fetchAllPages.js +57 -0
- package/dist/backend/utils/fetchAllPages.js.map +7 -0
- package/dist/backend/utils/nav.js +5 -2
- package/dist/backend/utils/nav.js.map +2 -2
- package/dist/backend/version-history/VersionHistoryPanel.js +1 -1
- package/dist/backend/version-history/VersionHistoryPanel.js.map +2 -2
- package/dist/frontend/LanguageSwitcher.js +2 -1
- package/dist/frontend/LanguageSwitcher.js.map +2 -2
- package/dist/portal/PortalContext.js +1 -1
- package/dist/portal/PortalContext.js.map +2 -2
- package/dist/portal/hooks/portalBridgeStatus.js +25 -0
- package/dist/portal/hooks/portalBridgeStatus.js.map +7 -0
- package/dist/portal/hooks/usePortalEventBridge.js +6 -0
- package/dist/portal/hooks/usePortalEventBridge.js.map +2 -2
- package/dist/portal/hooks/usePortalNotifications.js +42 -4
- package/dist/portal/hooks/usePortalNotifications.js.map +2 -2
- package/dist/portal/utils/nav.js +2 -3
- package/dist/portal/utils/nav.js.map +2 -2
- package/dist/primitives/ErrorNotice.js +1 -1
- package/dist/primitives/ErrorNotice.js.map +2 -2
- package/dist/primitives/Notice.js +1 -1
- package/dist/primitives/Notice.js.map +1 -1
- package/dist/primitives/button.js +9 -4
- package/dist/primitives/button.js.map +2 -2
- package/dist/theme/ThemeProvider.js +1 -1
- package/dist/theme/ThemeProvider.js.map +2 -2
- package/dist/theme/index.js +3 -0
- package/dist/theme/index.js.map +2 -2
- package/dist/theme/theme-init-script.js +17 -0
- package/dist/theme/theme-init-script.js.map +7 -0
- package/figma/alert.figma.tsx +35 -0
- package/figma/badge.figma.tsx +33 -0
- package/figma/button.figma.tsx +33 -0
- package/figma/checkbox.figma.tsx +28 -0
- package/figma/drawer.figma.tsx +40 -0
- package/figma/input-variants.figma.tsx +58 -0
- package/figma/radio.figma.tsx +16 -0
- package/figma/select.figma.tsx +24 -0
- package/figma/status-badge.figma.tsx +24 -0
- package/figma/switch.figma.tsx +28 -0
- package/figma/tabs.figma.tsx +22 -0
- package/figma/tag.figma.tsx +27 -0
- package/figma.config.json +6 -0
- package/jest.config.cjs +2 -1
- package/package.json +21 -18
- package/scripts/lucideRegistrySource.cjs +133 -0
- package/src/ai/AiChat.tsx +36 -7
- package/src/ai/LoopDisabledBanner.tsx +1 -1
- package/src/ai/__tests__/AiChat.test.tsx +31 -0
- package/src/ai/parts/ConfirmationCard.tsx +4 -4
- package/src/ai/parts/FieldDiffCard.tsx +2 -2
- package/src/ai/parts/MutationResultCard.tsx +3 -3
- package/src/ai/ui-parts/pending-phase3-placeholder.tsx +2 -2
- package/src/backend/AppShell.tsx +55 -4
- package/src/backend/AuthSessionGuard.tsx +29 -0
- package/src/backend/BackendChromeProvider.tsx +19 -1
- package/src/backend/CrudForm.tsx +18 -4
- package/src/backend/DataTable.tsx +36 -19
- package/src/backend/JsonBuilder.tsx +3 -3
- package/src/backend/NextStepCallout.tsx +4 -4
- package/src/backend/PerspectiveSidebar.tsx +1 -1
- package/src/backend/ProfileDropdown.tsx +1 -0
- package/src/backend/RowActions.tsx +2 -2
- package/src/backend/__tests__/AppShell.test.tsx +65 -7
- package/src/backend/__tests__/AuthSessionGuard.test.tsx +52 -0
- package/src/backend/__tests__/BackendChromeProvider.test.tsx +98 -2
- package/src/backend/__tests__/CrudForm.fieldsetBinding.test.tsx +158 -0
- package/src/backend/__tests__/CrudForm.groupInjectionColumn.test.tsx +98 -0
- package/src/backend/__tests__/DataTable.metaHiddenLateColumns.test.tsx +105 -0
- package/src/backend/__tests__/DataTable.perspectiveStorage.test.ts +62 -1
- package/src/backend/__tests__/nav-utils.test.ts +139 -0
- package/src/backend/confirm-dialog/ConfirmDialog.tsx +16 -1
- package/src/backend/confirm-dialog/__tests__/ConfirmDialog.test.tsx +68 -1
- package/src/backend/crud/__tests__/useGroupOrder.test.ts +39 -1
- package/src/backend/crud/useGroupOrder.ts +30 -25
- package/src/backend/custom-fields/FieldDefinitionsManager.tsx +1 -1
- package/src/backend/dashboard/DashboardScreen.tsx +3 -3
- package/src/backend/detail/AddressTiles.tsx +3 -6
- package/src/backend/detail/AttachmentDeleteDialog.tsx +1 -1
- package/src/backend/detail/AttachmentMetadataDialog.tsx +1 -1
- package/src/backend/detail/AttachmentsSection.tsx +1 -1
- package/src/backend/detail/NotesSection.tsx +1 -1
- package/src/backend/detail/TagsSection.tsx +1 -1
- package/src/backend/detail/__tests__/AttachmentAssignmentsEditor.test.tsx +21 -0
- package/src/backend/filters/AdvancedFilterPanel.tsx +1 -1
- package/src/backend/forms/FormHeader.tsx +1 -2
- package/src/backend/icons/__tests__/lucideRegistryGenerator.test.ts +177 -0
- package/src/backend/icons/lucideRegistry.generated.tsx +3 -37
- package/src/backend/icons/lucideRegistry.ts +2 -2
- package/src/backend/icons/lucideRegistryRuntime.tsx +36 -0
- package/src/backend/injection/__tests__/spotIds.extension-points.test.ts +38 -0
- package/src/backend/injection/spotIds.ts +23 -18
- package/src/backend/inputs/ComboboxInput.tsx +1 -1
- package/src/backend/messages/EmailThreadsPanel.tsx +1 -1
- package/src/backend/perspectiveState.ts +44 -0
- package/src/backend/schedule/ScheduleAgenda.tsx +3 -3
- package/src/backend/schedule/ScheduleGrid.tsx +3 -3
- package/src/backend/schedule/ScheduleToolbar.tsx +89 -68
- package/src/backend/sidebar/SidebarCustomizationEditor.tsx +42 -3
- package/src/backend/sidebar/__tests__/group-visibility.test.ts +131 -0
- package/src/backend/sidebar/customization-helpers.ts +47 -0
- package/src/backend/utils/__tests__/apiCall.test.ts +61 -0
- package/src/backend/utils/__tests__/customFieldForms.optionsPaging.test.ts +95 -0
- package/src/backend/utils/__tests__/fetchAllPages.test.ts +103 -0
- package/src/backend/utils/apiCall.ts +29 -1
- package/src/backend/utils/customFieldDefs.ts +1 -0
- package/src/backend/utils/customFieldForms.ts +15 -11
- package/src/backend/utils/fetchAllPages.ts +93 -0
- package/src/backend/utils/nav.ts +22 -2
- package/src/backend/version-history/VersionHistoryPanel.tsx +1 -1
- package/src/frontend/LanguageSwitcher.tsx +1 -0
- package/src/portal/PortalContext.tsx +1 -1
- package/src/portal/hooks/__tests__/usePortalEventBridge.test.tsx +66 -0
- package/src/portal/hooks/__tests__/usePortalNotifications.test.tsx +155 -0
- package/src/portal/hooks/portalBridgeStatus.ts +29 -0
- package/src/portal/hooks/usePortalEventBridge.ts +6 -0
- package/src/portal/hooks/usePortalNotifications.ts +55 -12
- package/src/portal/utils/__tests__/nav.test.ts +2 -2
- package/src/portal/utils/nav.ts +3 -4
- package/src/primitives/ErrorNotice.tsx +1 -1
- package/src/primitives/Notice.tsx +5 -5
- package/src/primitives/__tests__/button.test.tsx +70 -0
- package/src/primitives/button.tsx +10 -4
- package/src/theme/ThemeProvider.tsx +1 -2
- package/src/theme/__tests__/theme-init-script.test.ts +70 -0
- package/src/theme/index.ts +1 -0
- 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 ?
|
|
1356
|
-
?? (extensionTableIdProp ?
|
|
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 ?
|
|
1383
|
+
() => (resolvedInjectionSpotId ? extensionSpotChildId(resolvedInjectionSpotId, 'header') : null),
|
|
1380
1384
|
[resolvedInjectionSpotId]
|
|
1381
1385
|
)
|
|
1382
1386
|
const toolbarInjectionSpotId = React.useMemo(
|
|
1383
|
-
() => (resolvedInjectionSpotId ?
|
|
1387
|
+
() => (resolvedInjectionSpotId ? extensionSpotChildId(resolvedInjectionSpotId, 'toolbar') : null),
|
|
1384
1388
|
[resolvedInjectionSpotId]
|
|
1385
1389
|
)
|
|
1386
1390
|
const searchTrailingInjectionSpotId = React.useMemo(
|
|
1387
|
-
() => (resolvedInjectionSpotId ?
|
|
1391
|
+
() => (resolvedInjectionSpotId ? extensionSpotChildId(resolvedInjectionSpotId, 'search-trailing') : null),
|
|
1388
1392
|
[resolvedInjectionSpotId]
|
|
1389
1393
|
)
|
|
1390
1394
|
const footerInjectionSpotId = React.useMemo(
|
|
1391
|
-
() => (resolvedInjectionSpotId ?
|
|
1395
|
+
() => (resolvedInjectionSpotId ? extensionSpotChildId(resolvedInjectionSpotId, 'footer') : null),
|
|
1392
1396
|
[resolvedInjectionSpotId]
|
|
1393
1397
|
)
|
|
1394
1398
|
const { widgets: columnWidgets } = useInjectionDataWidgets(
|
|
1395
|
-
extensionTableId ?
|
|
1399
|
+
extensionTableId ? dataTableExtensionSpotId(extensionTableId, 'columns') : '__disabled__:columns',
|
|
1396
1400
|
)
|
|
1397
1401
|
const { widgets: rowActionWidgets } = useInjectionDataWidgets(
|
|
1398
|
-
extensionTableId ?
|
|
1402
|
+
extensionTableId ? dataTableExtensionSpotId(extensionTableId, 'row-actions') : '__disabled__:row-actions',
|
|
1399
1403
|
)
|
|
1400
1404
|
const { widgets: bulkActionWidgets } = useInjectionDataWidgets(
|
|
1401
|
-
extensionTableId ?
|
|
1405
|
+
extensionTableId ? dataTableExtensionSpotId(extensionTableId, 'bulk-actions') : '__disabled__:bulk-actions',
|
|
1402
1406
|
)
|
|
1403
1407
|
const { widgets: filterWidgets } = useInjectionDataWidgets(
|
|
1404
|
-
extensionTableId ?
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
52
|
-
warning: 'border-
|
|
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-
|
|
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
|
|
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>
|
|
@@ -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-
|
|
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-
|
|
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(
|
|
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:
|
|
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=
|
|
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 {
|
|
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
|
+
})
|