@open-mercato/ui 0.9.1-develop.7499.1.fcec456e4e → 0.9.1-develop.7515.1.e2712c8b00

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 (33) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/backend/CrudForm.js +56 -36
  3. package/dist/backend/CrudForm.js.map +2 -2
  4. package/dist/backend/DataTable.js +40 -14
  5. package/dist/backend/DataTable.js.map +2 -2
  6. package/dist/backend/columns/ColumnChooserPanel.js +4 -3
  7. package/dist/backend/columns/ColumnChooserPanel.js.map +2 -2
  8. package/dist/backend/crud/injectedFieldGroups.js +50 -0
  9. package/dist/backend/crud/injectedFieldGroups.js.map +7 -0
  10. package/dist/backend/filters/FilterFieldPicker.js +3 -2
  11. package/dist/backend/filters/FilterFieldPicker.js.map +2 -2
  12. package/dist/backend/utils/api.js +25 -4
  13. package/dist/backend/utils/api.js.map +2 -2
  14. package/dist/backend/utils/groupLabels.js +16 -0
  15. package/dist/backend/utils/groupLabels.js.map +7 -0
  16. package/dist/theme/QueryProvider.js +7 -1
  17. package/dist/theme/QueryProvider.js.map +2 -2
  18. package/package.json +3 -3
  19. package/src/backend/CrudForm.tsx +68 -45
  20. package/src/backend/DataTable.tsx +54 -14
  21. package/src/backend/__tests__/CrudForm.fieldInjection.test.tsx +85 -4
  22. package/src/backend/__tests__/DataTable.injectedColumnPlacement.test.tsx +146 -0
  23. package/src/backend/columns/ColumnChooserPanel.tsx +4 -3
  24. package/src/backend/columns/__tests__/ColumnChooserPanel.groupLabels.test.tsx +72 -0
  25. package/src/backend/crud/__tests__/injectedFieldGroups.test.ts +90 -0
  26. package/src/backend/crud/injectedFieldGroups.ts +75 -0
  27. package/src/backend/filters/FilterFieldPicker.tsx +3 -2
  28. package/src/backend/filters/__tests__/FilterFieldPicker.groupLabels.test.tsx +77 -0
  29. package/src/backend/utils/__tests__/api.test.ts +67 -0
  30. package/src/backend/utils/api.ts +33 -4
  31. package/src/backend/utils/groupLabels.ts +26 -0
  32. package/src/theme/QueryProvider.tsx +7 -1
  33. package/src/theme/__tests__/QueryProvider.i18n.test.tsx +40 -0
@@ -92,7 +92,7 @@ import type { CustomFieldDefLike } from '@open-mercato/shared/modules/entities/v
92
92
  import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../primitives/dialog'
93
93
  import { FieldDefinitionsManager, type FieldDefinitionsManagerHandle } from './custom-fields/FieldDefinitionsManager'
94
94
  import { useConfirmDialog } from './confirm-dialog'
95
- import { useInjectionSpotEvents, InjectionSpot, useInjectionWidgets } from './injection/InjectionSpot'
95
+ import { useInjectionSpotEvents, InjectionSpot, useInjectionWidgets, type LoadedInjectionSpotWidget } from './injection/InjectionSpot'
96
96
  import { dispatchBackendMutationError } from './injection/mutationEvents'
97
97
  import { VersionHistoryAction } from './version-history/VersionHistoryAction'
98
98
  import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
@@ -105,7 +105,7 @@ import { SortableGroupHandleProvider, type SortableGroupHandleProps } from './cr
105
105
  import { useGroupOrder } from './crud/useGroupOrder'
106
106
  import { InjectedField } from './injection/InjectedField'
107
107
  import type { InjectionFieldDefinition, FieldContext } from '@open-mercato/shared/modules/widgets/injection'
108
- import { insertByInjectionPlacement } from '@open-mercato/shared/modules/widgets/injection-position'
108
+ import { placeInjectedFieldsInGroups, type InjectedFieldWidgetPlacement } from './crud/injectedFieldGroups'
109
109
  import { evaluateInjectedVisibility } from './injection/visibility-utils'
110
110
  import { ComponentReplacementHandles } from '@open-mercato/shared/modules/widgets/component-registry'
111
111
  import { crudFormExtensionSpotId, extensionSpotChildId } from '@open-mercato/shared/modules/widgets/extension-points'
@@ -502,6 +502,26 @@ export type CrudFormGroup = {
502
502
  bare?: boolean
503
503
  }
504
504
 
505
+ function renderNoInjectedFieldWidget(): null {
506
+ return null
507
+ }
508
+
509
+ async function skipInjectedFieldDeleteEvent(): Promise<void> {}
510
+
511
+ // Field widgets persist values of the record being edited, so a delete must not fall
512
+ // back to their save handlers the way it does for component widgets.
513
+ const INJECTED_FIELD_DELETE_EVENT_DEFAULTS = {
514
+ onBeforeDelete: skipInjectedFieldDeleteEvent,
515
+ onDelete: skipInjectedFieldDeleteEvent,
516
+ onAfterDelete: skipInjectedFieldDeleteEvent,
517
+ }
518
+
519
+ function injectionWidgetPriority(widget: LoadedInjectionSpotWidget): number {
520
+ const placementPriority = widget.placement?.priority
521
+ if (typeof placementPriority === 'number') return placementPriority
522
+ return typeof widget.module.metadata.priority === 'number' ? widget.module.metadata.priority : 0
523
+ }
524
+
505
525
  // Appends `legacy` entries not already present in `primary` (by `keyOf`), so a
506
526
  // `legacyInjectionSpotId` bridge never renders the same widget twice when it is
507
527
  // registered on both the primary and the legacy spot.
@@ -1239,7 +1259,30 @@ export function CrudForm<TValues extends Record<string, unknown>>({
1239
1259
  [primaryFieldWidgets, legacyFieldWidgets],
1240
1260
  )
1241
1261
 
1242
- const { triggerEvent: triggerInjectionEvent } = useInjectionSpotEvents(resolvedInjectionSpotId ?? '', injectionWidgets)
1262
+ const injectionEventWidgets = React.useMemo<LoadedInjectionSpotWidget[]>(() => {
1263
+ const fieldEventWidgets: LoadedInjectionSpotWidget[] = []
1264
+ for (const widget of injectedFieldWidgets) {
1265
+ if (!('fields' in widget) || !widget.eventHandlers) continue
1266
+ fieldEventWidgets.push({
1267
+ widgetId: widget.metadata.id,
1268
+ module: {
1269
+ metadata: widget.metadata,
1270
+ eventHandlers: { ...INJECTED_FIELD_DELETE_EVENT_DEFAULTS, ...widget.eventHandlers },
1271
+ Widget: renderNoInjectedFieldWidget,
1272
+ },
1273
+ moduleId: widget.moduleId,
1274
+ key: widget.key,
1275
+ placement: widget.placement,
1276
+ })
1277
+ }
1278
+ if (!fieldEventWidgets.length) return injectionWidgets
1279
+ return mergeByKey(injectionWidgets, fieldEventWidgets, (widget) => widget.widgetId)
1280
+ .map((widget, index) => ({ widget, index }))
1281
+ .sort((left, right) => injectionWidgetPriority(right.widget) - injectionWidgetPriority(left.widget) || left.index - right.index)
1282
+ .map((entry) => entry.widget)
1283
+ }, [injectionWidgets, injectedFieldWidgets])
1284
+
1285
+ const { triggerEvent: triggerInjectionEvent } = useInjectionSpotEvents(resolvedInjectionSpotId ?? '', injectionEventWidgets)
1243
1286
  const extendedInjectionEventsEnabled = CRUDFORM_EXTENDED_EVENTS_ENABLED && Boolean(resolvedInjectionSpotId)
1244
1287
 
1245
1288
  // Fields that active injection widgets declare as required (e.g. the SEO helper
@@ -1918,6 +1961,26 @@ export function CrudForm<TValues extends Record<string, unknown>>({
1918
1961
  return definitions
1919
1962
  }, [injectedFieldWidgets])
1920
1963
 
1964
+ const injectedFieldPlacements = React.useMemo(() => {
1965
+ const placements = new Map<string, InjectedFieldWidgetPlacement | undefined>()
1966
+ for (const widget of injectedFieldWidgets) {
1967
+ if (!('fields' in widget)) continue
1968
+ for (const field of widget.fields ?? []) {
1969
+ placements.set((field as InjectionFieldDefinition).id, widget.placement)
1970
+ }
1971
+ }
1972
+ return placements
1973
+ }, [injectedFieldWidgets])
1974
+
1975
+ const groupsWithInjectedFields = React.useMemo(() => {
1976
+ if (!groups || groups.length === 0 || injectedFieldDefinitions.length === 0) return groups
1977
+ return placeInjectedFieldsInGroups(groups, injectedFieldDefinitions, injectedFieldPlacements, (definition) => {
1978
+ if (process.env.NODE_ENV !== 'production') {
1979
+ logger.warn('Injected field targets a group that does not exist; appended to the last plain-field group', { fieldId: definition.id, group: definition.group })
1980
+ }
1981
+ })
1982
+ }, [groups, injectedFieldDefinitions, injectedFieldPlacements])
1983
+
1921
1984
  const injectedFieldContext = React.useMemo<FieldContext>(() => {
1922
1985
  const recordValues = values as Record<string, unknown>
1923
1986
  const organizationId = typeof recordValues.organizationId === 'string' ? recordValues.organizationId : null
@@ -1974,27 +2037,15 @@ export function CrudForm<TValues extends Record<string, unknown>>({
1974
2037
  }
1975
2038
  return (group.fields ?? []).map((entry) => (typeof entry === 'string' ? entry : entry.id))
1976
2039
  }
1977
- const declaredGroups = groups ?? []
1978
2040
  const hidden = new Set<string>()
1979
2041
  const visible = new Set<string>()
1980
- for (const group of declaredGroups) {
2042
+ for (const group of groupsWithInjectedFields ?? []) {
1981
2043
  const target = hiddenGroupIdSet.has(group.id) ? hidden : visible
1982
2044
  for (const fieldId of groupFieldIds(group)) target.add(fieldId)
1983
2045
  }
1984
- // Mirror the injection fallback in `groupsWithInjectedFields`: a definition
1985
- // whose target group does not exist is appended to the last declared group,
1986
- // so it is hidden exactly when that fallback group is.
1987
- const declaredGroupIds = new Set(declaredGroups.map((group) => group.id))
1988
- const fallbackGroupId = declaredGroups[declaredGroups.length - 1]?.id
1989
- for (const definition of injectedFieldDefinitions) {
1990
- const targetGroupId = definition.group && declaredGroupIds.has(definition.group)
1991
- ? definition.group
1992
- : fallbackGroupId
1993
- if (targetGroupId && hiddenGroupIdSet.has(targetGroupId)) hidden.add(definition.id)
1994
- }
1995
2046
  for (const fieldId of visible) hidden.delete(fieldId)
1996
2047
  return hidden
1997
- }, [cfFields, groups, hiddenGroupIdSet, injectedFieldDefinitions, placedCustomFieldIds])
2048
+ }, [cfFields, groupsWithInjectedFields, hiddenGroupIdSet, placedCustomFieldIds])
1998
2049
 
1999
2050
  const hiddenInjectedFieldIds = React.useMemo(() => {
2000
2051
  const hidden = new Set<string>()
@@ -2265,34 +2316,6 @@ export function CrudForm<TValues extends Record<string, unknown>>({
2265
2316
  return pairs.map((p) => p.group)
2266
2317
  }, [injectionWidgets, injectionContext, pending, setValues, values])
2267
2318
 
2268
- const groupsWithInjectedFields = React.useMemo(() => {
2269
- if (!groups || groups.length === 0 || injectedFieldDefinitions.length === 0) return groups
2270
- const cloned = groups.map((group) => ({ ...group, fields: [...(group.fields ?? [])] }))
2271
- const fallbackIndex = cloned.length - 1
2272
- for (const definition of injectedFieldDefinitions) {
2273
- const targetIndex = cloned.findIndex((group) => group.id === definition.group)
2274
- const index = targetIndex >= 0 ? targetIndex : fallbackIndex
2275
- if (targetIndex < 0 && process.env.NODE_ENV !== 'production') {
2276
- logger.warn('Injected field targets a group that does not exist; appended to last group', { fieldId: definition.id, group: definition.group })
2277
- }
2278
- if (index < 0) continue
2279
- const fieldEntries = cloned[index].fields ?? []
2280
- const alreadyPresent = fieldEntries.some((entry) => {
2281
- const entryId = typeof entry === 'string' ? entry : entry.id
2282
- return entryId === definition.id
2283
- })
2284
- cloned[index].fields = alreadyPresent
2285
- ? fieldEntries
2286
- : insertByInjectionPlacement(
2287
- fieldEntries,
2288
- definition.id,
2289
- definition.placement,
2290
- (entry) => (typeof entry === 'string' ? entry : entry.id),
2291
- )
2292
- }
2293
- return cloned
2294
- }, [groups, injectedFieldDefinitions])
2295
-
2296
2319
  const shouldAutoGroup = (!groupsWithInjectedFields || groupsWithInjectedFields.length === 0) && injectionGroupCards.length > 0
2297
2320
  const declaredGroupsForLayout = React.useMemo(() => {
2298
2321
  const baseGroups = groupsWithInjectedFields && groupsWithInjectedFields.length ? groupsWithInjectedFields : []
@@ -65,7 +65,7 @@ import type {
65
65
  import { ComponentReplacementHandles } from '@open-mercato/shared/modules/widgets/component-registry'
66
66
  import { useRegisteredComponent } from './injection/useRegisteredComponent'
67
67
  import { dataTableExtensionSpotId, extensionSpotChildId } from '@open-mercato/shared/modules/widgets/extension-points'
68
- import { insertByInjectionPlacement } from '@open-mercato/shared/modules/widgets/injection-position'
68
+ import { insertByInjectionPlacement, InjectionPosition, type InjectionPlacement } from '@open-mercato/shared/modules/widgets/injection-position'
69
69
  import { useVirtualizer } from '@tanstack/react-virtual'
70
70
  import type {
71
71
  FilterFieldDef as AdvancedFilterFieldDef,
@@ -170,6 +170,49 @@ export function withDataTableNamespaces<T extends Record<string, unknown>>(
170
170
  }
171
171
  }
172
172
 
173
+ // Columns register in waves (injected columns and custom fields load asynchronously), so a
174
+ // stored order rarely names every column. A column the order does not know yet takes its
175
+ // place from the column definitions — right after its nearest known predecessor — instead
176
+ // of being appended, which would discard an injected column's `placement` (#7083). One that
177
+ // follows every known column is still appended, keeping the user's arrangement intact. An
178
+ // injected column anchored `Before`/`After` a column already in the order follows that anchor
179
+ // wherever the user moved it.
180
+ function reconcileColumnOrder(
181
+ prev: string[],
182
+ ids: string[],
183
+ placements: ReadonlyMap<string, InjectionPlacement | undefined> = new Map(),
184
+ ): string[] {
185
+ const allowed = new Set(ids)
186
+ const result = prev.filter((id) => allowed.has(id))
187
+ const known = new Set(result)
188
+ ids.forEach((id, index) => {
189
+ if (known.has(id)) return
190
+ const placement = placements.get(id)
191
+ const anchorIndex = placement?.relativeTo ? result.indexOf(placement.relativeTo) : -1
192
+ if (anchorIndex !== -1 && (placement?.position === InjectionPosition.Before || placement?.position === InjectionPosition.After)) {
193
+ result.splice(placement.position === InjectionPosition.Before ? anchorIndex : anchorIndex + 1, 0, id)
194
+ known.add(id)
195
+ return
196
+ }
197
+ const hasKnownSuccessor = ids.slice(index + 1).some((candidate) => known.has(candidate))
198
+ if (!hasKnownSuccessor) {
199
+ result.push(id)
200
+ } else {
201
+ let insertAt = 0
202
+ for (let cursor = index - 1; cursor >= 0; cursor -= 1) {
203
+ const predecessorIndex = result.indexOf(ids[cursor])
204
+ if (predecessorIndex !== -1) {
205
+ insertAt = predecessorIndex + 1
206
+ break
207
+ }
208
+ }
209
+ result.splice(insertAt, 0, id)
210
+ }
211
+ known.add(id)
212
+ })
213
+ return result
214
+ }
215
+
173
216
  function resolveDataTableRowId<T>(row: T, index: number): string {
174
217
  if (row && typeof row === 'object') {
175
218
  const candidate = (row as Record<string, unknown>).id
@@ -1921,23 +1964,20 @@ function DataTableImpl<T extends RowData>({
1921
1964
  [baseInjectionContext, hasInjectedBulkActions, rowSelection],
1922
1965
  )
1923
1966
  React.useEffect(() => {
1924
- const ids = table.getAllLeafColumns().map((column) => column.id)
1967
+ // `getAllLeafColumns()` is already sorted by `columnOrder`, which would hide where a new
1968
+ // column sits in the definitions; flattening `getAllColumns()` keeps definition order.
1969
+ const ids = table.getAllColumns().flatMap((column) => column.getLeafColumns()).map((column) => column.id)
1925
1970
  if (!ids.length) return
1971
+ const placements = new Map(
1972
+ injectedColumnDefs.map(({ def, placement }) => [(def as { id?: string }).id ?? '', placement] as const),
1973
+ )
1926
1974
  setColumnOrder((prev) => {
1927
1975
  if (!prev.length) return ids
1928
- const allowed = ids
1929
- const filtered = prev.filter((id) => allowed.includes(id))
1930
- const seen = new Set(filtered)
1931
- for (const id of allowed) {
1932
- if (!seen.has(id)) {
1933
- filtered.push(id)
1934
- seen.add(id)
1935
- }
1936
- }
1937
- const changed = filtered.length !== prev.length || filtered.some((id, index) => id !== prev[index])
1938
- return changed ? filtered : prev
1976
+ const next = reconcileColumnOrder(prev, ids, placements)
1977
+ const changed = next.length !== prev.length || next.some((id, index) => id !== prev[index])
1978
+ return changed ? next : prev
1939
1979
  })
1940
- }, [table, mergedColumns])
1980
+ }, [table, mergedColumns, injectedColumnDefs])
1941
1981
 
1942
1982
  // Auto-hiding is a per-column default, applied once per column — not an enforcement.
1943
1983
  // It cannot be latched by a single has-run boolean: columns arrive in waves, because
@@ -4,7 +4,8 @@ jest.setTimeout(15000)
4
4
  // Injected field definitions for the CrudForm `:fields` injection spot. The
5
5
  // hook is mocked so the test drives `injectedFieldDefinitions` directly without
6
6
  // standing up the full injection registry/bootstrap. See issue #3047.
7
- let injectedFieldWidgets: Array<{ fields: unknown[] }> = []
7
+ let injectedFieldWidgets: Array<Record<string, unknown> & { fields: unknown[] }> = []
8
+ let capturedEventWidgets: Array<{ widgetId: string; module: { eventHandlers?: Record<string, unknown> } }> = []
8
9
 
9
10
  jest.mock('next/navigation', () => ({
10
11
  useRouter: () => ({ push: () => {} }),
@@ -18,9 +19,10 @@ jest.mock('../injection/InjectionSpot', () => ({
18
19
  useInjectionWidgets: () => ({ widgets: [], loading: false, error: null }),
19
20
  // Echo the payload so `transformFormData` is a no-op and submit assertions see
20
21
  // exactly what CrudForm forwards to the host `onSubmit`.
21
- useInjectionSpotEvents: () => ({
22
- triggerEvent: jest.fn(async (_event: string, data: unknown) => ({ ok: true, data })),
23
- }),
22
+ useInjectionSpotEvents: (_spotId: string, widgets?: typeof capturedEventWidgets) => {
23
+ capturedEventWidgets = widgets ?? []
24
+ return { triggerEvent: jest.fn(async (_event: string, data: unknown) => ({ ok: true, data })) }
25
+ },
24
26
  }))
25
27
  jest.mock('../injection/useInjectionDataWidgets', () => ({
26
28
  __esModule: true,
@@ -65,6 +67,7 @@ function orderedFieldIds(container: HTMLElement): string[] {
65
67
  describe('CrudForm group field injection (#3047)', () => {
66
68
  afterEach(() => {
67
69
  injectedFieldWidgets = []
70
+ capturedEventWidgets = []
68
71
  })
69
72
 
70
73
  it('honors placement when injecting a field into an existing group', async () => {
@@ -143,6 +146,84 @@ describe('CrudForm group field injection (#3047)', () => {
143
146
  )
144
147
  expect(labelMatches).toHaveLength(1)
145
148
  })
149
+
150
+ it('renders a group-kind injection targeting an undeclared group as its own card, even when the last group is customFields (#6142)', async () => {
151
+ const onBeforeSave = jest.fn(async () => ({ ok: true }))
152
+ injectedFieldWidgets = [
153
+ {
154
+ metadata: { id: 'wms.injection.catalog-inventory-profile' },
155
+ moduleId: 'wms',
156
+ key: 'wms:catalog-inventory-profile',
157
+ placement: { kind: 'group', column: 2, groupLabel: 'Inventory profile', groupDescription: 'Warehouse settings', priority: 120 },
158
+ fields: [
159
+ { id: 'wms.manageInventory', label: 'Manage inventory with WMS', type: 'boolean', group: 'wms.inventoryProfile' },
160
+ { id: 'wms.safetyStock', label: 'Safety stock', type: 'number', group: 'wms.inventoryProfile' },
161
+ ],
162
+ eventHandlers: { onBeforeSave },
163
+ },
164
+ ]
165
+
166
+ const { container, getByText } = renderWithProviders(
167
+ React.createElement(CrudForm as any, {
168
+ title: 'Form',
169
+ entityId: 'catalog:catalog_product',
170
+ fields: baseFields,
171
+ groups: [
172
+ ...groups,
173
+ { id: 'custom', title: 'Custom attributes', column: 2, kind: 'customFields' },
174
+ ],
175
+ onSubmit: () => {},
176
+ }),
177
+ )
178
+
179
+ await waitFor(() => {
180
+ expect(container.querySelector('[data-crud-field-id="wms.manageInventory"]')).toBeTruthy()
181
+ })
182
+ expect(container.querySelector('[data-crud-field-id="wms.safetyStock"]')).toBeTruthy()
183
+ expect(getByText('Inventory profile')).toBeTruthy()
184
+ expect(getByText('Warehouse settings')).toBeTruthy()
185
+ expect(orderedFieldIds(container).slice(0, 3)).toEqual(['firstName', 'lastName', 'email'])
186
+ })
187
+
188
+ it('dispatches injection events to field widgets that declare event handlers (#6142)', async () => {
189
+ const onBeforeSave = jest.fn(async () => ({ ok: true }))
190
+ injectedFieldWidgets = [
191
+ {
192
+ metadata: { id: 'wms.injection.catalog-inventory-profile' },
193
+ moduleId: 'wms',
194
+ key: 'wms:catalog-inventory-profile',
195
+ fields: [{ id: 'wms.manageInventory', label: 'Manage inventory with WMS', type: 'boolean', group: 'personalData' }],
196
+ eventHandlers: { onBeforeSave },
197
+ },
198
+ {
199
+ metadata: { id: 'example.injection.handlerless-field' },
200
+ moduleId: 'example',
201
+ key: 'example:handlerless-field',
202
+ fields: [{ id: 'example.note', label: 'Note', type: 'text', group: 'personalData' }],
203
+ },
204
+ ]
205
+
206
+ renderWithProviders(
207
+ React.createElement(CrudForm as any, {
208
+ title: 'Form',
209
+ entityId: 'catalog:catalog_product',
210
+ fields: baseFields,
211
+ groups,
212
+ onSubmit: () => {},
213
+ }),
214
+ )
215
+
216
+ await waitFor(() => {
217
+ expect(capturedEventWidgets.map((widget) => widget.widgetId)).toEqual(['wms.injection.catalog-inventory-profile'])
218
+ })
219
+ const handlers = capturedEventWidgets[0].module.eventHandlers as Record<string, (...args: unknown[]) => unknown>
220
+ expect(handlers.onBeforeSave).toBe(onBeforeSave)
221
+ for (const deleteEvent of ['onBeforeDelete', 'onDelete', 'onAfterDelete']) {
222
+ expect(typeof handlers[deleteEvent]).toBe('function')
223
+ await handlers[deleteEvent]({})
224
+ }
225
+ expect(onBeforeSave).not.toHaveBeenCalled()
226
+ })
146
227
  })
147
228
 
148
229
  describe('CrudForm injected field id collisions', () => {
@@ -0,0 +1,146 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import * as React from 'react'
5
+ import { render, screen, waitFor } from '@testing-library/react'
6
+ import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
7
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
8
+ import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
9
+ import { InjectionPosition, type InjectionPlacement } from '@open-mercato/shared/modules/widgets/injection-position'
10
+ import { DataTable } from '../DataTable'
11
+
12
+ jest.mock('next/navigation', () => ({
13
+ useRouter: () => ({ push: jest.fn(), replace: jest.fn(), prefetch: jest.fn(), refresh: jest.fn() }),
14
+ }))
15
+
16
+ const useInjectionDataWidgetsMock = jest.fn()
17
+ jest.mock('../injection/useInjectionDataWidgets', () => ({
18
+ useInjectionDataWidgets: (spotId: string) => useInjectionDataWidgetsMock(spotId),
19
+ }))
20
+
21
+ jest.mock('@open-mercato/shared/modules/widgets/injection-loader', () => ({
22
+ getInjectionRegistryVersion: () => 0,
23
+ subscribeToInjectionRegistryChanges: () => () => {},
24
+ loadInjectionWidgetsForSpot: jest.fn(async () => []),
25
+ loadInjectionDataWidgetsForSpot: jest.fn(async () => []),
26
+ }))
27
+
28
+ type Row = { id: string; reference: string; status: string; amount: string }
29
+
30
+ const ROWS: Row[] = [{ id: '1', reference: 'PAY-1', status: 'paid', amount: '10.00' }]
31
+
32
+ const COLUMNS: ColumnDef<Row>[] = [
33
+ { id: 'reference', accessorKey: 'reference', header: 'Reference' },
34
+ { id: 'status', accessorKey: 'status', header: 'Status' },
35
+ { id: 'amount', accessorKey: 'amount', header: 'Amount' },
36
+ ]
37
+
38
+ const TABLE_ID = 'tests.injected-placement'
39
+
40
+ function mockInjectedColumn(placement: InjectionPlacement | undefined) {
41
+ useInjectionDataWidgetsMock.mockImplementation((spotId: string) => {
42
+ if (spotId !== `data-table:${TABLE_ID}:columns`) return { widgets: [], isLoading: false, error: null }
43
+ return {
44
+ widgets: [
45
+ {
46
+ metadata: { id: 'tests.injected-placement.column' },
47
+ columns: [{ id: 'gateway_status', header: 'Gateway status', accessorKey: 'status', placement }],
48
+ },
49
+ ],
50
+ isLoading: false,
51
+ error: null,
52
+ }
53
+ })
54
+ }
55
+
56
+ function mockNoInjectedColumns() {
57
+ useInjectionDataWidgetsMock.mockImplementation(() => ({ widgets: [], isLoading: false, error: null }))
58
+ }
59
+
60
+ function Harness({ initialSettings }: { initialSettings?: unknown }) {
61
+ const queryClient = React.useMemo(
62
+ () => new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }),
63
+ [],
64
+ )
65
+ return (
66
+ <QueryClientProvider client={queryClient}>
67
+ <I18nProvider locale="en" dict={{}}>
68
+ <DataTable
69
+ columns={COLUMNS as never}
70
+ data={ROWS as never}
71
+ extensionTableId={TABLE_ID}
72
+ {...(initialSettings
73
+ ? ({ perspective: { tableId: TABLE_ID, initialState: { initialSettings } } } as never)
74
+ : {})}
75
+ />
76
+ </I18nProvider>
77
+ </QueryClientProvider>
78
+ )
79
+ }
80
+
81
+ function headerOrder(): string[] {
82
+ return screen.getAllByRole('columnheader').map((cell) => cell.textContent?.trim() ?? '').filter(Boolean)
83
+ }
84
+
85
+ async function renderWithLateInjectedColumn(placement: InjectionPlacement | undefined, initialSettings?: unknown) {
86
+ mockNoInjectedColumns()
87
+ const view = render(<Harness initialSettings={initialSettings} />)
88
+ await waitFor(() => expect(screen.getByText('Reference')).toBeTruthy())
89
+ mockInjectedColumn(placement)
90
+ view.rerender(<Harness initialSettings={initialSettings} />)
91
+ await waitFor(() => expect(screen.getByText('Gateway status')).toBeTruthy())
92
+ }
93
+
94
+ describe('DataTable — placement of injected columns that arrive after the first render (#7083)', () => {
95
+ beforeEach(() => {
96
+ window.localStorage.clear()
97
+ for (const entry of document.cookie.split(';')) {
98
+ const name = entry.split('=')[0]?.trim()
99
+ if (name) document.cookie = `${name}=; Max-Age=0; Path=/`
100
+ }
101
+ })
102
+
103
+ it('renders an After-placed column right after its anchor', async () => {
104
+ await renderWithLateInjectedColumn({ position: InjectionPosition.After, relativeTo: 'status' })
105
+ await waitFor(() => expect(headerOrder()).toEqual(['Reference', 'Status', 'Gateway status', 'Amount']))
106
+ })
107
+
108
+ it('renders a Before-placed column right before its anchor', async () => {
109
+ await renderWithLateInjectedColumn({ position: InjectionPosition.Before, relativeTo: 'status' })
110
+ await waitFor(() => expect(headerOrder()).toEqual(['Reference', 'Gateway status', 'Status', 'Amount']))
111
+ })
112
+
113
+ it('renders a First-placed column first', async () => {
114
+ await renderWithLateInjectedColumn({ position: InjectionPosition.First })
115
+ await waitFor(() => expect(headerOrder()).toEqual(['Gateway status', 'Reference', 'Status', 'Amount']))
116
+ })
117
+
118
+ it('still appends a column with the default placement', async () => {
119
+ await renderWithLateInjectedColumn(undefined)
120
+ await waitFor(() => expect(headerOrder()).toEqual(['Reference', 'Status', 'Amount', 'Gateway status']))
121
+ })
122
+
123
+ it('keeps a stored column order and slots the late column next to its anchor', async () => {
124
+ await renderWithLateInjectedColumn(
125
+ { position: InjectionPosition.After, relativeTo: 'status' },
126
+ { columnOrder: ['amount', 'status', 'reference'] },
127
+ )
128
+ await waitFor(() => expect(headerOrder()).toEqual(['Amount', 'Status', 'Gateway status', 'Reference']))
129
+ })
130
+
131
+ it('follows a Before anchor that a stored column order moved', async () => {
132
+ await renderWithLateInjectedColumn(
133
+ { position: InjectionPosition.Before, relativeTo: 'status' },
134
+ { columnOrder: ['amount', 'status', 'reference'] },
135
+ )
136
+ await waitFor(() => expect(headerOrder()).toEqual(['Amount', 'Gateway status', 'Status', 'Reference']))
137
+ })
138
+
139
+ it('follows an After anchor on the last defined column that a stored column order moved', async () => {
140
+ await renderWithLateInjectedColumn(
141
+ { position: InjectionPosition.After, relativeTo: 'amount' },
142
+ { columnOrder: ['amount', 'status', 'reference'] },
143
+ )
144
+ await waitFor(() => expect(headerOrder()).toEqual(['Amount', 'Gateway status', 'Status', 'Reference']))
145
+ })
146
+ })
@@ -7,6 +7,7 @@ import { Switch } from '../../primitives/switch'
7
7
  import { Input } from '../../primitives/input'
8
8
  import { SearchInput } from '../../primitives/search-input'
9
9
  import { useT } from '@open-mercato/shared/lib/i18n/context'
10
+ import { UNGROUPED_GROUP_ID, resolveGroupLabel } from '../utils/groupLabels'
10
11
  import {
11
12
  DndContext,
12
13
  closestCenter,
@@ -131,13 +132,13 @@ export function ColumnChooserSection({
131
132
 
132
133
  const groups = new Map<string, ColumnChooserField[]>()
133
134
  for (const col of filtered) {
134
- const group = col.group || t('ui.columnChooser.ungrouped', 'Other')
135
+ const group = col.group || UNGROUPED_GROUP_ID
135
136
  const list = groups.get(group) ?? []
136
137
  list.push(col)
137
138
  groups.set(group, list)
138
139
  }
139
140
  return groups
140
- }, [availableColumns, searchQuery, visibleSet, t])
141
+ }, [availableColumns, searchQuery, visibleSet])
141
142
 
142
143
  const toggleGroup = React.useCallback((group: string) => {
143
144
  setExpandedGroups((prev) => {
@@ -227,7 +228,7 @@ export function ColumnChooserSection({
227
228
  onClick={() => toggleGroup(group)}
228
229
  >
229
230
  <ChevronRight className={`size-4 transition-transform ${isCollapsed ? '' : 'rotate-90'}`} />
230
- <span>{group}</span>
231
+ <span>{resolveGroupLabel(t, group, 'ui.columnChooser.ungrouped', 'Other')}</span>
231
232
  </Button>
232
233
  {!isCollapsed ? (
233
234
  <div className="space-y-2 mt-2">
@@ -0,0 +1,72 @@
1
+ // packages/ui/src/backend/columns/__tests__/ColumnChooserPanel.groupLabels.test.tsx
2
+ import * as React from 'react'
3
+ import { render, screen } from '@testing-library/react'
4
+ import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
5
+ import type { Locale } from '@open-mercato/shared/lib/i18n/config'
6
+ import { ColumnChooserSection } from '../ColumnChooserPanel'
7
+ import type { ColumnChooserField } from '../ColumnChooserPanel'
8
+
9
+ const availableColumns: ColumnChooserField[] = [
10
+ { key: 'owner', label: 'Owner', group: 'customers.columnGroups.crm' },
11
+ { key: 'email', label: 'Email', group: 'Contact' },
12
+ ]
13
+
14
+ function renderSection(locale: Locale, dict: Record<string, string>, columns = availableColumns) {
15
+ return render(
16
+ <I18nProvider locale={locale} dict={dict}>
17
+ <ColumnChooserSection
18
+ availableColumns={columns}
19
+ visibleColumnKeys={[]}
20
+ columnOrder={[]}
21
+ onToggleColumn={() => {}}
22
+ onReorderColumns={() => {}}
23
+ />
24
+ </I18nProvider>,
25
+ )
26
+ }
27
+
28
+ describe('ColumnChooserSection group labels', () => {
29
+ it('translates group labels a host registered in its locale file', () => {
30
+ renderSection('pl', { 'customers.columnGroups.crm': 'CRM PL' })
31
+ expect(screen.getByText('CRM PL')).toBeInTheDocument()
32
+ expect(screen.queryByText('customers.columnGroups.crm')).not.toBeInTheDocument()
33
+ })
34
+
35
+ it('renders unrecognized group strings verbatim', () => {
36
+ renderSection('pl', { 'customers.columnGroups.crm': 'CRM PL' })
37
+ expect(screen.getByText('Contact')).toBeInTheDocument()
38
+ })
39
+
40
+ it('falls back to the raw group label when no translation is registered', () => {
41
+ renderSection('en', {})
42
+ expect(screen.getByText('customers.columnGroups.crm')).toBeInTheDocument()
43
+ expect(screen.getByText('Contact')).toBeInTheDocument()
44
+ })
45
+
46
+ it('never resolves tenant-authored group titles as translation keys', () => {
47
+ renderSection('pl', { 'Deal pipeline': 'Lejek sprzedaży' }, [
48
+ { key: 'stage', label: 'Stage', group: 'Deal pipeline' },
49
+ ])
50
+ expect(screen.getByText('Deal pipeline')).toBeInTheDocument()
51
+ expect(screen.queryByText('Lejek sprzedaży')).not.toBeInTheDocument()
52
+ })
53
+
54
+ it('translates the ungrouped fallback for columns that declare no group', () => {
55
+ renderSection('pl', { 'ui.columnChooser.ungrouped': 'Pozostałe' }, [
56
+ { key: 'notes', label: 'Notes' },
57
+ ])
58
+ expect(screen.getByText('Pozostałe')).toBeInTheDocument()
59
+ expect(screen.queryByText('Other')).not.toBeInTheDocument()
60
+ })
61
+
62
+ it('keys grouping on the raw group string so two groups sharing a translation stay apart', () => {
63
+ renderSection('pl', {
64
+ 'customers.columnGroups.crm': 'CRM',
65
+ 'customers.columnGroups.pipeline': 'CRM',
66
+ }, [
67
+ { key: 'owner', label: 'Owner', group: 'customers.columnGroups.crm' },
68
+ { key: 'stage', label: 'Stage', group: 'customers.columnGroups.pipeline' },
69
+ ])
70
+ expect(screen.getAllByText('CRM')).toHaveLength(2)
71
+ })
72
+ })