@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/backend/CrudForm.js +56 -36
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/DataTable.js +40 -14
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/columns/ColumnChooserPanel.js +4 -3
- package/dist/backend/columns/ColumnChooserPanel.js.map +2 -2
- package/dist/backend/crud/injectedFieldGroups.js +50 -0
- package/dist/backend/crud/injectedFieldGroups.js.map +7 -0
- package/dist/backend/filters/FilterFieldPicker.js +3 -2
- package/dist/backend/filters/FilterFieldPicker.js.map +2 -2
- package/dist/backend/utils/api.js +25 -4
- package/dist/backend/utils/api.js.map +2 -2
- package/dist/backend/utils/groupLabels.js +16 -0
- package/dist/backend/utils/groupLabels.js.map +7 -0
- package/dist/theme/QueryProvider.js +7 -1
- package/dist/theme/QueryProvider.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/CrudForm.tsx +68 -45
- package/src/backend/DataTable.tsx +54 -14
- package/src/backend/__tests__/CrudForm.fieldInjection.test.tsx +85 -4
- package/src/backend/__tests__/DataTable.injectedColumnPlacement.test.tsx +146 -0
- package/src/backend/columns/ColumnChooserPanel.tsx +4 -3
- package/src/backend/columns/__tests__/ColumnChooserPanel.groupLabels.test.tsx +72 -0
- package/src/backend/crud/__tests__/injectedFieldGroups.test.ts +90 -0
- package/src/backend/crud/injectedFieldGroups.ts +75 -0
- package/src/backend/filters/FilterFieldPicker.tsx +3 -2
- package/src/backend/filters/__tests__/FilterFieldPicker.groupLabels.test.tsx +77 -0
- package/src/backend/utils/__tests__/api.test.ts +67 -0
- package/src/backend/utils/api.ts +33 -4
- package/src/backend/utils/groupLabels.ts +26 -0
- package/src/theme/QueryProvider.tsx +7 -1
- package/src/theme/__tests__/QueryProvider.i18n.test.tsx +40 -0
package/src/backend/CrudForm.tsx
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
1929
|
-
const
|
|
1930
|
-
|
|
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
|
-
|
|
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 ||
|
|
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
|
|
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
|
+
})
|