@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
|
@@ -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
|
|
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 [
|
|
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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
358
|
+
<Alert status="error">
|
|
359
359
|
<AlertTitle>{t('entities.customFields.errors.title', 'Something went wrong')}</AlertTitle>
|
|
360
360
|
<AlertDescription>{statusError}</AlertDescription>
|
|
361
361
|
</Alert>
|