@open-mercato/ui 0.6.7-develop.6860.1.f796b22e80 → 0.6.7

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 (167) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +2 -4
  3. package/agentic/standalone-guide.md +1 -1
  4. package/build.mjs +54 -2
  5. package/dist/ai/AiChat.js +2 -21
  6. package/dist/ai/AiChat.js.map +2 -2
  7. package/dist/backend/AppShell.js +4 -43
  8. package/dist/backend/AppShell.js.map +2 -2
  9. package/dist/backend/AuthSessionGuard.js +0 -14
  10. package/dist/backend/AuthSessionGuard.js.map +2 -2
  11. package/dist/backend/BackendChromeProvider.js +1 -6
  12. package/dist/backend/BackendChromeProvider.js.map +2 -2
  13. package/dist/backend/CrudForm.js +4 -6
  14. package/dist/backend/CrudForm.js.map +2 -2
  15. package/dist/backend/DataTable.js +18 -20
  16. package/dist/backend/DataTable.js.map +2 -2
  17. package/dist/backend/JsonBuilder.js +3 -3
  18. package/dist/backend/JsonBuilder.js.map +2 -2
  19. package/dist/backend/NextStepCallout.js +4 -4
  20. package/dist/backend/NextStepCallout.js.map +1 -1
  21. package/dist/backend/ProfileDropdown.js +1 -2
  22. package/dist/backend/ProfileDropdown.js.map +2 -2
  23. package/dist/backend/RowActions.js +2 -2
  24. package/dist/backend/RowActions.js.map +2 -2
  25. package/dist/backend/confirm-dialog/ConfirmDialog.js +1 -13
  26. package/dist/backend/confirm-dialog/ConfirmDialog.js.map +2 -2
  27. package/dist/backend/crud/useGroupOrder.js +21 -14
  28. package/dist/backend/crud/useGroupOrder.js.map +2 -2
  29. package/dist/backend/detail/AddressTiles.js +5 -4
  30. package/dist/backend/detail/AddressTiles.js.map +2 -2
  31. package/dist/backend/detail/AttachmentDeleteDialog.js +1 -1
  32. package/dist/backend/detail/AttachmentDeleteDialog.js.map +2 -2
  33. package/dist/backend/detail/AttachmentMetadataDialog.js.map +1 -1
  34. package/dist/backend/detail/AttachmentsSection.js +1 -1
  35. package/dist/backend/detail/AttachmentsSection.js.map +2 -2
  36. package/dist/backend/detail/NotesSection.js +1 -1
  37. package/dist/backend/detail/NotesSection.js.map +2 -2
  38. package/dist/backend/detail/TagsSection.js +1 -1
  39. package/dist/backend/detail/TagsSection.js.map +2 -2
  40. package/dist/backend/forms/FormHeader.js +2 -1
  41. package/dist/backend/forms/FormHeader.js.map +2 -2
  42. package/dist/backend/icons/lucideRegistry.generated.js +25 -1
  43. package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
  44. package/dist/backend/icons/lucideRegistry.js +3 -3
  45. package/dist/backend/icons/lucideRegistry.js.map +2 -2
  46. package/dist/backend/injection/spotIds.js +18 -23
  47. package/dist/backend/injection/spotIds.js.map +2 -2
  48. package/dist/backend/schedule/ScheduleAgenda.js +3 -3
  49. package/dist/backend/schedule/ScheduleAgenda.js.map +1 -1
  50. package/dist/backend/schedule/ScheduleGrid.js +3 -3
  51. package/dist/backend/schedule/ScheduleGrid.js.map +1 -1
  52. package/dist/backend/schedule/ScheduleToolbar.js +70 -87
  53. package/dist/backend/schedule/ScheduleToolbar.js.map +2 -2
  54. package/dist/backend/sidebar/SidebarCustomizationEditor.js +2 -30
  55. package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
  56. package/dist/backend/sidebar/customization-helpers.js +0 -30
  57. package/dist/backend/sidebar/customization-helpers.js.map +2 -2
  58. package/dist/backend/utils/api.js +13 -3
  59. package/dist/backend/utils/api.js.map +2 -2
  60. package/dist/backend/utils/apiCall.js +1 -21
  61. package/dist/backend/utils/apiCall.js.map +2 -2
  62. package/dist/backend/utils/customFieldDefs.js.map +2 -2
  63. package/dist/backend/utils/customFieldForms.js +9 -10
  64. package/dist/backend/utils/customFieldForms.js.map +2 -2
  65. package/dist/backend/utils/nav.js +2 -5
  66. package/dist/backend/utils/nav.js.map +2 -2
  67. package/dist/frontend/LanguageSwitcher.js +1 -2
  68. package/dist/frontend/LanguageSwitcher.js.map +2 -2
  69. package/dist/portal/PortalContext.js +1 -1
  70. package/dist/portal/PortalContext.js.map +2 -2
  71. package/dist/portal/hooks/usePortalEventBridge.js +0 -6
  72. package/dist/portal/hooks/usePortalEventBridge.js.map +2 -2
  73. package/dist/portal/hooks/usePortalNotifications.js +4 -42
  74. package/dist/portal/hooks/usePortalNotifications.js.map +2 -2
  75. package/dist/portal/utils/nav.js +3 -2
  76. package/dist/portal/utils/nav.js.map +2 -2
  77. package/dist/primitives/button.js +4 -9
  78. package/dist/primitives/button.js.map +2 -2
  79. package/jest.config.cjs +1 -2
  80. package/package.json +18 -21
  81. package/src/ai/AiChat.tsx +1 -30
  82. package/src/ai/__tests__/AiChat.test.tsx +0 -31
  83. package/src/backend/AppShell.tsx +4 -55
  84. package/src/backend/AuthSessionGuard.tsx +0 -29
  85. package/src/backend/BackendChromeProvider.tsx +1 -19
  86. package/src/backend/CrudForm.tsx +4 -18
  87. package/src/backend/DataTable.tsx +19 -36
  88. package/src/backend/JsonBuilder.tsx +3 -3
  89. package/src/backend/NextStepCallout.tsx +4 -4
  90. package/src/backend/ProfileDropdown.tsx +0 -1
  91. package/src/backend/RowActions.tsx +2 -2
  92. package/src/backend/__tests__/AppShell.test.tsx +7 -65
  93. package/src/backend/__tests__/AuthSessionGuard.test.tsx +0 -52
  94. package/src/backend/__tests__/BackendChromeProvider.test.tsx +2 -98
  95. package/src/backend/__tests__/DataTable.perspectiveStorage.test.ts +1 -62
  96. package/src/backend/__tests__/nav-utils.test.ts +0 -139
  97. package/src/backend/confirm-dialog/ConfirmDialog.tsx +1 -16
  98. package/src/backend/confirm-dialog/__tests__/ConfirmDialog.test.tsx +1 -68
  99. package/src/backend/crud/__tests__/useGroupOrder.test.ts +1 -39
  100. package/src/backend/crud/useGroupOrder.ts +25 -30
  101. package/src/backend/detail/AddressTiles.tsx +6 -3
  102. package/src/backend/detail/AttachmentDeleteDialog.tsx +1 -1
  103. package/src/backend/detail/AttachmentMetadataDialog.tsx +1 -1
  104. package/src/backend/detail/AttachmentsSection.tsx +1 -1
  105. package/src/backend/detail/NotesSection.tsx +1 -1
  106. package/src/backend/detail/TagsSection.tsx +1 -1
  107. package/src/backend/detail/__tests__/AttachmentAssignmentsEditor.test.tsx +0 -21
  108. package/src/backend/forms/FormHeader.tsx +2 -1
  109. package/src/backend/icons/lucideRegistry.generated.tsx +37 -3
  110. package/src/backend/icons/lucideRegistry.ts +2 -2
  111. package/src/backend/injection/spotIds.ts +18 -23
  112. package/src/backend/schedule/ScheduleAgenda.tsx +3 -3
  113. package/src/backend/schedule/ScheduleGrid.tsx +3 -3
  114. package/src/backend/schedule/ScheduleToolbar.tsx +68 -89
  115. package/src/backend/sidebar/SidebarCustomizationEditor.tsx +3 -42
  116. package/src/backend/sidebar/customization-helpers.ts +0 -47
  117. package/src/backend/utils/__tests__/api.test.ts +27 -0
  118. package/src/backend/utils/__tests__/apiCall.test.ts +0 -61
  119. package/src/backend/utils/api.ts +20 -3
  120. package/src/backend/utils/apiCall.ts +1 -29
  121. package/src/backend/utils/customFieldDefs.ts +0 -1
  122. package/src/backend/utils/customFieldForms.ts +11 -15
  123. package/src/backend/utils/nav.ts +2 -22
  124. package/src/frontend/LanguageSwitcher.tsx +0 -1
  125. package/src/portal/PortalContext.tsx +1 -1
  126. package/src/portal/hooks/usePortalEventBridge.ts +0 -6
  127. package/src/portal/hooks/usePortalNotifications.ts +12 -55
  128. package/src/portal/utils/__tests__/nav.test.ts +2 -2
  129. package/src/portal/utils/nav.ts +4 -3
  130. package/src/primitives/button.tsx +4 -10
  131. package/dist/backend/icons/lucideRegistryRuntime.js +0 -28
  132. package/dist/backend/icons/lucideRegistryRuntime.js.map +0 -7
  133. package/dist/backend/perspectiveState.js +0 -34
  134. package/dist/backend/perspectiveState.js.map +0 -7
  135. package/dist/backend/utils/fetchAllPages.js +0 -57
  136. package/dist/backend/utils/fetchAllPages.js.map +0 -7
  137. package/dist/portal/hooks/portalBridgeStatus.js +0 -25
  138. package/dist/portal/hooks/portalBridgeStatus.js.map +0 -7
  139. package/figma/alert.figma.tsx +0 -35
  140. package/figma/badge.figma.tsx +0 -33
  141. package/figma/button.figma.tsx +0 -33
  142. package/figma/checkbox.figma.tsx +0 -28
  143. package/figma/drawer.figma.tsx +0 -40
  144. package/figma/input-variants.figma.tsx +0 -58
  145. package/figma/radio.figma.tsx +0 -16
  146. package/figma/select.figma.tsx +0 -24
  147. package/figma/status-badge.figma.tsx +0 -24
  148. package/figma/switch.figma.tsx +0 -28
  149. package/figma/tabs.figma.tsx +0 -22
  150. package/figma/tag.figma.tsx +0 -27
  151. package/figma.config.json +0 -6
  152. package/scripts/lucideRegistrySource.cjs +0 -133
  153. package/src/backend/__tests__/CrudForm.fieldsetBinding.test.tsx +0 -158
  154. package/src/backend/__tests__/CrudForm.groupInjectionColumn.test.tsx +0 -98
  155. package/src/backend/__tests__/DataTable.metaHiddenLateColumns.test.tsx +0 -105
  156. package/src/backend/icons/__tests__/lucideRegistryGenerator.test.ts +0 -177
  157. package/src/backend/icons/lucideRegistryRuntime.tsx +0 -36
  158. package/src/backend/injection/__tests__/spotIds.extension-points.test.ts +0 -38
  159. package/src/backend/perspectiveState.ts +0 -44
  160. package/src/backend/sidebar/__tests__/group-visibility.test.ts +0 -131
  161. package/src/backend/utils/__tests__/customFieldForms.optionsPaging.test.ts +0 -95
  162. package/src/backend/utils/__tests__/fetchAllPages.test.ts +0 -103
  163. package/src/backend/utils/fetchAllPages.ts +0 -93
  164. package/src/portal/hooks/__tests__/usePortalEventBridge.test.tsx +0 -66
  165. package/src/portal/hooks/__tests__/usePortalNotifications.test.tsx +0 -155
  166. package/src/portal/hooks/portalBridgeStatus.ts +0 -29
  167. package/src/primitives/__tests__/button.test.tsx +0 -70
@@ -1,98 +0,0 @@
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
- })
@@ -1,105 +0,0 @@
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,177 +0,0 @@
1
- import { execFileSync } from 'node:child_process'
2
- import { readFileSync } from 'node:fs'
3
- import { join } from 'node:path'
4
- import { Project, ScriptKind, SyntaxKind, type ObjectLiteralExpression, type SourceFile } from 'ts-morph'
5
- import { buildLucideRegistrySource } from '../../../../scripts/lucideRegistrySource.cjs'
6
-
7
- const packageDir = join(__dirname, '..', '..', '..', '..')
8
- const iconsDir = join(packageDir, 'src', 'backend', 'icons')
9
-
10
- type ResolvedIcon = { kebab: string; exportName: string }
11
-
12
- const populatedFixture: ResolvedIcon[] = [
13
- { kebab: 'bell', exportName: 'Bell' },
14
- { kebab: 'alert-circle', exportName: 'AlertCircle' },
15
- { kebab: 'bar-chart-2', exportName: 'BarChart2' },
16
- ]
17
-
18
- const duplicateExportFixture: ResolvedIcon[] = [
19
- { kebab: 'trash', exportName: 'Trash' },
20
- { kebab: 'trash-can', exportName: 'Trash' },
21
- ]
22
-
23
- function parse(source: string): SourceFile {
24
- const project = new Project({ useInMemoryFileSystem: true })
25
- return project.createSourceFile('lucideRegistry.generated.tsx', source, {
26
- overwrite: true,
27
- scriptKind: ScriptKind.TSX,
28
- })
29
- }
30
-
31
- function syntacticDiagnostics(source: string): string[] {
32
- const sourceFile = parse(source)
33
- return sourceFile
34
- .getProject()
35
- .getProgram()
36
- .getSyntacticDiagnostics(sourceFile)
37
- .map((diagnostic) => `${diagnostic.getLineNumber() ?? 0}: ${diagnostic.getMessageText()}`)
38
- }
39
-
40
- function registryObject(source: string): ObjectLiteralExpression {
41
- const declaration = parse(source).getVariableDeclarationOrThrow('LUCIDE_ICON_REGISTRY')
42
- return declaration.getInitializerIfKindOrThrow(SyntaxKind.ObjectLiteralExpression)
43
- }
44
-
45
- function registryEntries(source: string): Record<string, string> {
46
- const entries: Record<string, string> = {}
47
- for (const property of registryObject(source).getProperties()) {
48
- const assignment = property.asKindOrThrow(SyntaxKind.PropertyAssignment)
49
- entries[assignment.getSymbolOrThrow().getName()] = assignment.getInitializerOrThrow().getText()
50
- }
51
- return entries
52
- }
53
-
54
- function valueImportNames(source: string, moduleSpecifier: string): string[] {
55
- return parse(source)
56
- .getImportDeclarations()
57
- .filter((declaration) => declaration.getModuleSpecifierValue() === moduleSpecifier)
58
- .filter((declaration) => !declaration.isTypeOnly())
59
- .flatMap((declaration) => declaration.getNamedImports().map((named) => named.getName()))
60
- }
61
-
62
- describe('buildLucideRegistrySource', () => {
63
- it('maps every discovered kebab name to its lucide export', () => {
64
- expect(registryEntries(buildLucideRegistrySource(populatedFixture))).toEqual({
65
- 'alert-circle': 'AlertCircle',
66
- 'bar-chart-2': 'BarChart2',
67
- bell: 'Bell',
68
- })
69
- })
70
-
71
- it('emits the registry keys sorted by kebab name', () => {
72
- const keys = registryObject(buildLucideRegistrySource(populatedFixture))
73
- .getProperties()
74
- .map((property) => property.asKindOrThrow(SyntaxKind.PropertyAssignment).getSymbolOrThrow().getName())
75
- expect(keys).toEqual(['alert-circle', 'bar-chart-2', 'bell'])
76
- })
77
-
78
- it('imports each lucide export exactly once', () => {
79
- expect(valueImportNames(buildLucideRegistrySource(populatedFixture), 'lucide-react')).toEqual([
80
- 'AlertCircle',
81
- 'BarChart2',
82
- 'Bell',
83
- ])
84
- })
85
-
86
- it('quotes keys that are not valid identifiers', () => {
87
- const property = registryObject(buildLucideRegistrySource(populatedFixture))
88
- .getPropertyOrThrow("'bar-chart-2'")
89
- .asKindOrThrow(SyntaxKind.PropertyAssignment)
90
- expect(property.getNameNode().getKind()).toBe(SyntaxKind.StringLiteral)
91
- })
92
-
93
- it('de-duplicates the value import when two names share one export', () => {
94
- const source = buildLucideRegistrySource(duplicateExportFixture)
95
- expect(valueImportNames(source, 'lucide-react')).toEqual(['Trash'])
96
- expect(registryEntries(source)).toEqual({ trash: 'Trash', 'trash-can': 'Trash' })
97
- })
98
-
99
- it('emits a valid module with an empty registry when no icons are discovered', () => {
100
- const source = buildLucideRegistrySource([])
101
- expect(registryObject(source).getProperties()).toHaveLength(0)
102
- expect(valueImportNames(source, 'lucide-react')).toEqual([])
103
- expect(source).not.toContain("import {} from 'lucide-react'")
104
- })
105
-
106
- it('keeps the registry a plain mutable object literal', () => {
107
- const source = buildLucideRegistrySource(populatedFixture)
108
- const declaration = parse(source).getVariableDeclarationOrThrow('LUCIDE_ICON_REGISTRY')
109
- expect(declaration.getTypeNodeOrThrow().getText()).toBe('Record<string, LucideIcon>')
110
- expect(declaration.getVariableStatementOrThrow().isExported()).toBe(true)
111
- expect(source).not.toContain('as const')
112
- expect(source).not.toContain('Object.freeze')
113
- })
114
-
115
- it('keeps the LucideIcon import type-only', () => {
116
- const typeOnly = parse(buildLucideRegistrySource(populatedFixture))
117
- .getImportDeclarations()
118
- .filter((declaration) => declaration.isTypeOnly())
119
- .flatMap((declaration) => declaration.getNamedImports().map((named) => named.getName()))
120
- expect(typeOnly).toEqual(['LucideIcon'])
121
- })
122
-
123
- it.each([
124
- ['populated', populatedFixture],
125
- ['empty', [] as ResolvedIcon[]],
126
- ['duplicate exports', duplicateExportFixture],
127
- ])('emits %s output with zero syntactic diagnostics', (_label, fixture) => {
128
- expect(syntacticDiagnostics(buildLucideRegistrySource(fixture))).toEqual([])
129
- })
130
-
131
- it('fails loudly when an export name would produce an unparseable module', () => {
132
- expect(() => buildLucideRegistrySource([{ kebab: 'broken', exportName: 'not a valid export' }])).toThrow(
133
- /not syntactically valid/
134
- )
135
- })
136
- })
137
-
138
- describe('lucideRegistry barrel', () => {
139
- it('keeps its public export list unchanged', () => {
140
- const project = new Project({ useInMemoryFileSystem: true })
141
- const barrel = project.createSourceFile(
142
- 'lucideRegistry.ts',
143
- readFileSync(join(iconsDir, 'lucideRegistry.ts'), 'utf-8'),
144
- { overwrite: true }
145
- )
146
- expect([...barrel.getExportedDeclarations().keys()].sort()).toEqual([
147
- 'LUCIDE_ICON_REGISTRY',
148
- 'registerAdditionalIcons',
149
- 'resolveRegisteredLucideIcon',
150
- 'resolveRegisteredLucideIconNode',
151
- ])
152
- })
153
- })
154
-
155
- describe('lucideRegistry.generated importers', () => {
156
- it('is imported only from within src/backend/icons', () => {
157
- const repoRoot = join(packageDir, '..', '..')
158
- let matches: string[] = []
159
- try {
160
- matches = execFileSync(
161
- 'git',
162
- ['grep', '-l', 'lucideRegistry.generated', '--', '*.ts', '*.tsx'],
163
- { cwd: repoRoot, encoding: 'utf-8' }
164
- )
165
- .split('\n')
166
- .filter(Boolean)
167
- } catch (error) {
168
- const status = (error as { status?: number }).status
169
- if (status !== 1) throw error
170
- }
171
-
172
- const outsideIconsFolder = matches.filter(
173
- (path) => !path.startsWith('packages/ui/src/backend/icons/')
174
- )
175
- expect(outsideIconsFolder).toEqual([])
176
- })
177
- })
@@ -1,36 +0,0 @@
1
- import type * as React from 'react'
2
- import type { LucideIcon } from 'lucide-react'
3
- import { LUCIDE_ICON_REGISTRY } from './lucideRegistry.generated'
4
-
5
- function normalizeKebabIconName(input: string): string {
6
- const trimmed = input.trim()
7
- if (!trimmed) return ''
8
- const withoutPrefix = trimmed.startsWith('lucide:') ? trimmed.slice('lucide:'.length) : trimmed
9
- if (!withoutPrefix) return ''
10
- if (!withoutPrefix.includes('-') && !withoutPrefix.includes('_') && !withoutPrefix.includes(' ') && /[A-Z]/.test(withoutPrefix)) {
11
- return withoutPrefix
12
- .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
13
- .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
14
- .toLowerCase()
15
- }
16
- return withoutPrefix
17
- .replace(/[_\s]+/g, '-')
18
- .replace(/-+/g, '-')
19
- .toLowerCase()
20
- }
21
-
22
- export function resolveRegisteredLucideIcon(name: string | undefined): LucideIcon | null {
23
- if (!name) return null
24
- const normalized = normalizeKebabIconName(name)
25
- if (!normalized) return null
26
- return (LUCIDE_ICON_REGISTRY as Record<string, LucideIcon | undefined>)[normalized] ?? null
27
- }
28
-
29
- export function resolveRegisteredLucideIconNode(
30
- name: string | undefined,
31
- className: string
32
- ): React.ReactNode | null {
33
- const Icon = resolveRegisteredLucideIcon(name)
34
- if (!Icon) return null
35
- return <Icon className={className} />
36
- }
@@ -1,38 +0,0 @@
1
- import {
2
- CRUD_FORM_EXTENSION_SURFACES,
3
- DATA_TABLE_EXTENSION_SURFACES,
4
- } from '@open-mercato/shared/modules/widgets/extension-points'
5
- import { CrudFormInjectionSpots, DataTableInjectionSpots } from '../spotIds'
6
-
7
- describe('standard extension point spot IDs', () => {
8
- it('keeps every bound DataTable suffix aligned with the runtime helper', () => {
9
- const tableId = 'catalog.products.list'
10
- const boundSuffixes = DATA_TABLE_EXTENSION_SURFACES
11
- .filter((surface) => surface.bound && surface.suffix)
12
- .map((surface) => surface.suffix)
13
-
14
- expect(boundSuffixes).toEqual([
15
- 'header',
16
- 'footer',
17
- 'toolbar',
18
- 'search-trailing',
19
- 'columns',
20
- 'row-actions',
21
- 'bulk-actions',
22
- 'filters',
23
- ])
24
- expect(DataTableInjectionSpots.emptyState(tableId)).toBe('data-table:catalog.products.list:empty-state')
25
- })
26
-
27
- it('keeps bound CrudForm spots distinct from helper-only spots', () => {
28
- const entityId = 'catalog.product'
29
- const boundKeys = CRUD_FORM_EXTENSION_SURFACES
30
- .filter((surface) => surface.bound)
31
- .map((surface) => surface.key)
32
-
33
- expect(boundKeys).toEqual(['base', 'header', 'fields', 'replacement'])
34
- expect(CrudFormInjectionSpots.base(entityId)).toBe('crud-form:catalog.product')
35
- expect(CrudFormInjectionSpots.header(entityId)).toBe('crud-form:catalog.product:header')
36
- expect(CrudFormInjectionSpots.fields(entityId)).toBe('crud-form:catalog.product:fields')
37
- })
38
- })
@@ -1,44 +0,0 @@
1
- export const PERSPECTIVE_COOKIE_PREFIX = 'om_table_perspective'
2
- export const PERSPECTIVE_STORAGE_PREFIX = 'om_table_perspective_snapshot'
3
-
4
- /**
5
- * Purge every browser-local DataTable perspective snapshot and active-view cookie
6
- * across all tables in this origin. Snapshots persist the unsaved/live table state
7
- * (column widths, order, visibility) keyed only by `tableId`, so they are shared by
8
- * every account that signs in on the same browser profile. Call this at the auth
9
- * identity boundary so one user's unsaved layout never carries over to a different
10
- * user — including a different tenant — reusing the same browser tab (#4185).
11
- *
12
- * Lives here rather than in DataTable so the backend shell can call it without
13
- * pulling the whole table implementation into the bundle of every backend page.
14
- * `@open-mercato/ui/backend/DataTable` re-exports it, which is the published
15
- * import path and must stay stable.
16
- */
17
- export function clearAllPerspectiveState(): void {
18
- if (typeof window !== 'undefined') {
19
- try {
20
- const storage = window.localStorage
21
- const staleKeys: string[] = []
22
- for (let index = 0; index < storage.length; index += 1) {
23
- const key = storage.key(index)
24
- if (key && key.startsWith(`${PERSPECTIVE_STORAGE_PREFIX}:`)) staleKeys.push(key)
25
- }
26
- for (const key of staleKeys) storage.removeItem(key)
27
- } catch {
28
- // private mode / quota errors are non-fatal
29
- }
30
- }
31
- if (typeof document !== 'undefined') {
32
- try {
33
- const cookies = document.cookie ? document.cookie.split(';') : []
34
- for (const cookie of cookies) {
35
- const name = cookie.split('=')[0]?.trim()
36
- if (name && name.startsWith(`${PERSPECTIVE_COOKIE_PREFIX}:`)) {
37
- document.cookie = `${name}=; Path=/; Max-Age=0; SameSite=Lax`
38
- }
39
- }
40
- } catch {
41
- // ignore — cookie access can throw in sandboxed contexts
42
- }
43
- }
44
- }
@@ -1,131 +0,0 @@
1
- import {
2
- applyGroupHidden,
3
- collectGroupItemKeys,
4
- resolveGroupVisibility,
5
- resolveItemKey,
6
- type SidebarGroup,
7
- } from '../customization-helpers'
8
-
9
- // Hiding a whole group used to require toggling every item in it individually — `AppShell` drops a
10
- // group only once *all* of its items are hidden. These cases cover the bulk toggle that does it in one
11
- // action, and pin the invariant that matters for compatibility: it writes the same `hiddenItemIds`
12
- // keys the per-item switches write, and nothing else.
13
-
14
- const group: SidebarGroup = {
15
- id: 'catalog.nav.group',
16
- name: 'Catalog',
17
- items: [
18
- { href: '/backend/catalog/products', title: 'Products' },
19
- {
20
- href: '/backend/catalog/categories',
21
- title: 'Categories',
22
- children: [
23
- { href: '/backend/catalog/categories/tree', title: 'Tree' },
24
- { href: '/backend/catalog/categories/import', title: 'Import' },
25
- ],
26
- },
27
- ],
28
- }
29
-
30
- const ALL_KEYS = [
31
- '/backend/catalog/products',
32
- '/backend/catalog/categories',
33
- '/backend/catalog/categories/tree',
34
- '/backend/catalog/categories/import',
35
- ]
36
-
37
- describe('collectGroupItemKeys', () => {
38
- it('includes top-level items and every nested descendant', () => {
39
- expect(collectGroupItemKeys(group).sort()).toEqual([...ALL_KEYS].sort())
40
- })
41
-
42
- it('returns nothing for an empty group', () => {
43
- expect(collectGroupItemKeys({ id: 'empty', name: 'Empty', items: [] })).toEqual([])
44
- })
45
-
46
- it('prefers an explicit item id over the href, matching resolveItemKey', () => {
47
- const withIds: SidebarGroup = {
48
- id: 'g',
49
- name: 'G',
50
- items: [{ id: 'custom-id', href: '/backend/thing', title: 'Thing' }],
51
- }
52
-
53
- expect(collectGroupItemKeys(withIds)).toEqual([resolveItemKey(withIds.items[0])])
54
- })
55
- })
56
-
57
- describe('resolveGroupVisibility', () => {
58
- it('is visible when nothing is hidden', () => {
59
- expect(resolveGroupVisibility(group, {})).toBe('visible')
60
- })
61
-
62
- it('is hidden only when every item including descendants is hidden', () => {
63
- const allHidden = Object.fromEntries(ALL_KEYS.map((key) => [key, true]))
64
-
65
- expect(resolveGroupVisibility(group, allHidden)).toBe('hidden')
66
- })
67
-
68
- it('is partial when some but not all items are hidden', () => {
69
- expect(resolveGroupVisibility(group, { '/backend/catalog/products': true })).toBe('partial')
70
- })
71
-
72
- it('is partial when only a nested descendant is left visible', () => {
73
- const allButOne = Object.fromEntries(
74
- ALL_KEYS.filter((key) => key !== '/backend/catalog/categories/import').map((key) => [key, true]),
75
- )
76
-
77
- expect(resolveGroupVisibility(group, allButOne)).toBe('partial')
78
- })
79
-
80
- it('treats an empty group as visible rather than hidden', () => {
81
- expect(resolveGroupVisibility({ id: 'empty', name: 'Empty', items: [] }, {})).toBe('visible')
82
- })
83
- })
84
-
85
- describe('applyGroupHidden', () => {
86
- it('hides every item in the group, descendants included', () => {
87
- const next = applyGroupHidden({}, group, true)
88
-
89
- expect(Object.keys(next).sort()).toEqual([...ALL_KEYS].sort())
90
- expect(resolveGroupVisibility(group, next)).toBe('hidden')
91
- })
92
-
93
- it('clears every item in the group when shown', () => {
94
- const hidden = applyGroupHidden({}, group, true)
95
- const next = applyGroupHidden(hidden, group, false)
96
-
97
- expect(next).toEqual({})
98
- expect(resolveGroupVisibility(group, next)).toBe('visible')
99
- })
100
-
101
- it('leaves keys belonging to other groups untouched', () => {
102
- const other = { '/backend/sales/orders': true }
103
-
104
- expect(applyGroupHidden(other, group, true)['/backend/sales/orders']).toBe(true)
105
- expect(applyGroupHidden(other, group, false)).toEqual(other)
106
- })
107
-
108
- it('does not mutate the input map', () => {
109
- const input = { '/backend/sales/orders': true }
110
- const snapshot = { ...input }
111
-
112
- applyGroupHidden(input, group, true)
113
-
114
- expect(input).toEqual(snapshot)
115
- })
116
-
117
- it('reaches the same state as toggling each item by hand', () => {
118
- const byHand = ALL_KEYS.reduce<Record<string, boolean>>((acc, key) => {
119
- acc[key] = true
120
- return acc
121
- }, {})
122
-
123
- expect(applyGroupHidden({}, group, true)).toEqual(byHand)
124
- })
125
-
126
- it('is idempotent', () => {
127
- const once = applyGroupHidden({}, group, true)
128
-
129
- expect(applyGroupHidden(once, group, true)).toEqual(once)
130
- })
131
- })