@open-mercato/ui 0.8.1-develop.7296.1.2111d779db → 0.8.1-develop.7441.1.b42958b98c

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 (84) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/backend/CrudForm.js +19 -6
  3. package/dist/backend/CrudForm.js.map +2 -2
  4. package/dist/backend/DataTable.js +61 -9
  5. package/dist/backend/DataTable.js.map +2 -2
  6. package/dist/backend/RowActions.js +54 -5
  7. package/dist/backend/RowActions.js.map +2 -2
  8. package/dist/backend/charts/KpiCard.js +2 -1
  9. package/dist/backend/charts/KpiCard.js.map +2 -2
  10. package/dist/backend/detail/AttachmentsSection.js +17 -5
  11. package/dist/backend/detail/AttachmentsSection.js.map +2 -2
  12. package/dist/backend/detail/NotesSection.js +38 -20
  13. package/dist/backend/detail/NotesSection.js.map +2 -2
  14. package/dist/backend/dev/DevRuntimeDiagnosticsBanner.js +11 -1
  15. package/dist/backend/dev/DevRuntimeDiagnosticsBanner.js.map +2 -2
  16. package/dist/backend/icons/lucideRegistry.generated.js +26 -0
  17. package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
  18. package/dist/backend/injection/eventBridge.js +33 -0
  19. package/dist/backend/injection/eventBridge.js.map +2 -2
  20. package/dist/backend/inputs/LookupSelect.js +20 -3
  21. package/dist/backend/inputs/LookupSelect.js.map +2 -2
  22. package/dist/backend/inputs/PhoneNumberField.js +1 -1
  23. package/dist/backend/inputs/PhoneNumberField.js.map +2 -2
  24. package/dist/backend/inputs/TagsInput.js +20 -2
  25. package/dist/backend/inputs/TagsInput.js.map +2 -2
  26. package/dist/backend/messages/EmailThreadsPanel.js +2 -1
  27. package/dist/backend/messages/EmailThreadsPanel.js.map +2 -2
  28. package/dist/backend/messages/MessageComposer.js.map +2 -2
  29. package/dist/backend/messages/index.js.map +2 -2
  30. package/dist/backend/messages/message-compose-form-groups.js +24 -0
  31. package/dist/backend/messages/message-compose-form-groups.js.map +2 -2
  32. package/dist/backend/messages/useMessageCompose.js +27 -0
  33. package/dist/backend/messages/useMessageCompose.js.map +2 -2
  34. package/dist/backend/messages/useMessageComposeOperations.js +1 -0
  35. package/dist/backend/messages/useMessageComposeOperations.js.map +2 -2
  36. package/dist/backend/notifications/NotificationItem.js +8 -1
  37. package/dist/backend/notifications/NotificationItem.js.map +2 -2
  38. package/dist/portal/PortalShell.js +2 -2
  39. package/dist/portal/PortalShell.js.map +2 -2
  40. package/dist/primitives/calendar.js +69 -14
  41. package/dist/primitives/calendar.js.map +2 -2
  42. package/package.json +3 -3
  43. package/src/backend/CrudForm.tsx +30 -8
  44. package/src/backend/DataTable.tsx +92 -19
  45. package/src/backend/RowActions.tsx +114 -6
  46. package/src/backend/__tests__/AttachmentsSection.upload.test.tsx +183 -0
  47. package/src/backend/__tests__/CrudForm.fieldInjection.test.tsx +139 -2
  48. package/src/backend/__tests__/CrudForm.hiddenGroups.test.tsx +1 -1
  49. package/src/backend/__tests__/DataTable.extensions.test.tsx +30 -0
  50. package/src/backend/__tests__/DataTable.perspectiveReconcile.test.tsx +320 -2
  51. package/src/backend/__tests__/DevRuntimeDiagnosticsBanner.test.tsx +117 -0
  52. package/src/backend/__tests__/NotesSection.test.tsx +138 -0
  53. package/src/backend/__tests__/RowActions.test.tsx +202 -0
  54. package/src/backend/charts/KpiCard.tsx +1 -0
  55. package/src/backend/charts/__tests__/KpiCard.test.tsx +20 -0
  56. package/src/backend/detail/AttachmentsSection.tsx +17 -5
  57. package/src/backend/detail/NotesSection.tsx +122 -66
  58. package/src/backend/dev/DevRuntimeDiagnosticsBanner.tsx +24 -1
  59. package/src/backend/icons/lucideRegistry.generated.tsx +26 -0
  60. package/src/backend/injection/__tests__/eventBridge.test.tsx +47 -1
  61. package/src/backend/injection/eventBridge.ts +35 -0
  62. package/src/backend/inputs/LookupSelect.tsx +32 -3
  63. package/src/backend/inputs/PhoneNumberField.tsx +1 -1
  64. package/src/backend/inputs/TagsInput.tsx +24 -3
  65. package/src/backend/inputs/__tests__/LookupSelect.test.tsx +166 -1
  66. package/src/backend/inputs/__tests__/PhoneNumberField.typeahead.test.tsx +43 -0
  67. package/src/backend/inputs/__tests__/TagsInput.test.tsx +81 -0
  68. package/src/backend/messages/EmailThreadsPanel.tsx +21 -1
  69. package/src/backend/messages/MessageComposer.tsx +1 -0
  70. package/src/backend/messages/__tests__/EmailThreadsPanel.senderLabel.test.tsx +99 -0
  71. package/src/backend/messages/__tests__/MessageComposer.test.tsx +44 -0
  72. package/src/backend/messages/__tests__/useMessageCompose.test.tsx +69 -0
  73. package/src/backend/messages/__tests__/useMessageComposeSender.test.tsx +208 -0
  74. package/src/backend/messages/index.ts +1 -0
  75. package/src/backend/messages/message-compose-form-groups.tsx +41 -0
  76. package/src/backend/messages/message-composer.types.ts +26 -0
  77. package/src/backend/messages/useMessageCompose.ts +46 -0
  78. package/src/backend/messages/useMessageComposeOperations.ts +8 -0
  79. package/src/backend/notifications/NotificationItem.tsx +7 -0
  80. package/src/backend/notifications/__tests__/NotificationItem.customRenderer.test.tsx +139 -0
  81. package/src/portal/PortalShell.tsx +7 -4
  82. package/src/portal/__tests__/PortalShell.test.tsx +29 -1
  83. package/src/primitives/__tests__/calendar.test.tsx +174 -1
  84. package/src/primitives/calendar.tsx +168 -96
@@ -0,0 +1,183 @@
1
+ /** @jest-environment jsdom */
2
+
3
+ import * as React from 'react'
4
+ import { act, fireEvent, screen, waitFor } from '@testing-library/react'
5
+ import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
6
+ import { AttachmentsSection } from '../detail/AttachmentsSection'
7
+ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
8
+
9
+ jest.mock('@open-mercato/ui/backend/utils/apiCall', () => ({
10
+ apiCall: jest.fn(),
11
+ }))
12
+
13
+ jest.mock('../injection/useRegisteredComponent', () => ({
14
+ useRegisteredComponent: <T,>(_handle: string, fallback?: React.ComponentType<T>) =>
15
+ fallback ?? ((() => null) as React.ComponentType<T>),
16
+ }))
17
+
18
+ jest.mock('../detail/AttachmentMetadataDialog', () => ({
19
+ AttachmentMetadataDialog: () => null,
20
+ }))
21
+
22
+ jest.mock('../detail/AttachmentDeleteDialog', () => ({
23
+ AttachmentDeleteDialog: () => null,
24
+ }))
25
+
26
+ const REJECTION = 'Executable file types are not allowed as attachments.'
27
+
28
+ type StoredItem = { id: string; fileName: string; fileSize: number; mimeType: string }
29
+
30
+ /**
31
+ * Emulates `/api/attachments` for one record: POST persists the file unless its
32
+ * name ends in `.exe` (the route's dangerous-executable 400), GET lists what was
33
+ * persisted. `holdUploads` parks each POST until `releaseUpload` is called so a
34
+ * test can interact with the section while an upload is in flight.
35
+ */
36
+ function createServer({ holdUploads = false } = {}) {
37
+ const stored: StoredItem[] = []
38
+ const posted: string[] = []
39
+ const pending: Array<() => void> = []
40
+ let holding = holdUploads
41
+ let listCalls = 0
42
+ ;(apiCall as jest.Mock).mockImplementation(async (url: string, init?: { method?: string; body?: FormData }) => {
43
+ if (init?.method === 'POST') {
44
+ const file = init.body?.get('file') as File
45
+ posted.push(file.name)
46
+ if (holding) await new Promise<void>((resolve) => pending.push(resolve))
47
+ if (file.name.endsWith('.exe')) {
48
+ return { ok: false, status: 400, result: { error: REJECTION }, response: { status: 400 } }
49
+ }
50
+ const item = { id: `att-${stored.length + 1}`, fileName: file.name, fileSize: file.size, mimeType: 'text/plain' }
51
+ stored.push(item)
52
+ return { ok: true, status: 200, result: { ok: true, item }, response: { status: 200 } }
53
+ }
54
+ if (url.startsWith('/api/attachments?')) {
55
+ listCalls += 1
56
+ return { ok: true, status: 200, result: { items: [...stored], page: 1, pageSize: 24 }, response: { status: 200 } }
57
+ }
58
+ return { ok: true, status: 200, result: {}, response: { status: 200 } }
59
+ })
60
+ return {
61
+ stored,
62
+ posted,
63
+ listCalls: () => listCalls,
64
+ pendingUploads: () => pending.length,
65
+ releaseAll: () => {
66
+ holding = false
67
+ pending.splice(0).forEach((resolve) => resolve())
68
+ },
69
+ }
70
+ }
71
+
72
+ function textFile(name: string): File {
73
+ return new File([`harmless text for ${name}`], name, { type: 'text/plain' })
74
+ }
75
+
76
+ function fileInput(container: HTMLElement): HTMLInputElement {
77
+ const input = container.querySelector('input[type="file"]')
78
+ if (!input) throw new Error('file input not rendered')
79
+ return input as HTMLInputElement
80
+ }
81
+
82
+ function selectFiles(container: HTMLElement, names: string[]) {
83
+ fireEvent.change(fileInput(container), { target: { files: names.map(textFile) } })
84
+ }
85
+
86
+ async function renderSection(onChanged = jest.fn()) {
87
+ const view = renderWithProviders(
88
+ <AttachmentsSection entityId="customers:customer_entity" recordId="record-1" onChanged={onChanged} />,
89
+ { dict: {} },
90
+ )
91
+ await screen.findByText('No attachments found.')
92
+ return { ...view, onChanged }
93
+ }
94
+
95
+ async function waitForIdle() {
96
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Choose files' })).toBeEnabled())
97
+ }
98
+
99
+ describe('AttachmentsSection multi-file upload (#6397)', () => {
100
+ beforeEach(() => {
101
+ jest.resetAllMocks()
102
+ })
103
+
104
+ it('uploads every file and refreshes the list when all succeed', async () => {
105
+ const server = createServer()
106
+ const { container, onChanged } = await renderSection()
107
+
108
+ selectFiles(container, ['ok-a.txt', 'ok-b.txt', 'ok-c.txt'])
109
+
110
+ expect(await screen.findByText('ok-c.txt')).toBeInTheDocument()
111
+ await waitForIdle()
112
+ expect(server.posted).toEqual(['ok-a.txt', 'ok-b.txt', 'ok-c.txt'])
113
+ expect(screen.getByText('ok-a.txt')).toBeInTheDocument()
114
+ expect(screen.getByText('ok-b.txt')).toBeInTheDocument()
115
+ expect(screen.queryByRole('alert')).toBeNull()
116
+ expect(onChanged).toHaveBeenCalledTimes(1)
117
+ })
118
+
119
+ it('stops at a rejected first file, announces the error and changes nothing', async () => {
120
+ const server = createServer()
121
+ const { container, onChanged } = await renderSection()
122
+
123
+ selectFiles(container, ['blocked.exe', 'ok-b.txt', 'ok-c.txt'])
124
+
125
+ expect(await screen.findByRole('alert')).toHaveTextContent(REJECTION)
126
+ await waitForIdle()
127
+ expect(server.posted).toEqual(['blocked.exe'])
128
+ expect(server.stored).toHaveLength(0)
129
+ expect(screen.getByText('No attachments found.')).toBeInTheDocument()
130
+ expect(onChanged).not.toHaveBeenCalled()
131
+ })
132
+
133
+ it('shows the files saved before a rejected middle file without a reload', async () => {
134
+ const server = createServer()
135
+ const { container, onChanged } = await renderSection()
136
+ const listCallsBefore = server.listCalls()
137
+
138
+ selectFiles(container, ['scout-first.txt', 'scout-blocked.exe', 'scout-last.txt'])
139
+
140
+ expect(await screen.findByText('scout-first.txt')).toBeInTheDocument()
141
+ await waitForIdle()
142
+ expect(server.posted).toEqual(['scout-first.txt', 'scout-blocked.exe'])
143
+ expect(server.listCalls()).toBeGreaterThan(listCallsBefore)
144
+ expect(screen.getByRole('alert')).toHaveTextContent(REJECTION)
145
+ expect(screen.queryByText('No attachments found.')).toBeNull()
146
+ expect(screen.queryByText('scout-last.txt')).toBeNull()
147
+ expect(onChanged).toHaveBeenCalledTimes(1)
148
+ })
149
+
150
+ it('shows every file saved before a rejected last file', async () => {
151
+ const server = createServer()
152
+ const { container } = await renderSection()
153
+
154
+ selectFiles(container, ['last-a.txt', 'last-b.txt', 'last-blocked.exe'])
155
+
156
+ expect(await screen.findByText('last-b.txt')).toBeInTheDocument()
157
+ await waitForIdle()
158
+ expect(server.posted).toEqual(['last-a.txt', 'last-b.txt', 'last-blocked.exe'])
159
+ expect(screen.getByText('last-a.txt')).toBeInTheDocument()
160
+ expect(screen.getByRole('alert')).toHaveTextContent(REJECTION)
161
+ })
162
+
163
+ it('ignores a second selection or drop while a batch is uploading', async () => {
164
+ const server = createServer({ holdUploads: true })
165
+ const { container } = await renderSection()
166
+
167
+ selectFiles(container, ['drop-a.txt', 'drop-b.txt'])
168
+ await waitFor(() => expect(server.pendingUploads()).toBe(1))
169
+
170
+ selectFiles(container, ['late-select.txt'])
171
+ const dropZone = container.querySelector('[role="presentation"]') as HTMLElement
172
+ fireEvent.drop(dropZone, { dataTransfer: { files: [textFile('late-drop.txt')] } })
173
+
174
+ await act(async () => {
175
+ server.releaseAll()
176
+ })
177
+ await waitForIdle()
178
+ await waitFor(() => expect(screen.getByText('drop-b.txt')).toBeInTheDocument())
179
+
180
+ expect(server.posted).toEqual(['drop-a.txt', 'drop-b.txt'])
181
+ expect(server.stored.map((item) => item.fileName)).toEqual(['drop-a.txt', 'drop-b.txt'])
182
+ })
183
+ })
@@ -16,7 +16,11 @@ jest.mock('../injection/InjectionSpot', () => ({
16
16
  __esModule: true,
17
17
  InjectionSpot: () => null,
18
18
  useInjectionWidgets: () => ({ widgets: [], loading: false, error: null }),
19
- useInjectionSpotEvents: () => ({ triggerEvent: jest.fn(async () => ({ ok: true, data: {} })) }),
19
+ // Echo the payload so `transformFormData` is a no-op and submit assertions see
20
+ // exactly what CrudForm forwards to the host `onSubmit`.
21
+ useInjectionSpotEvents: () => ({
22
+ triggerEvent: jest.fn(async (_event: string, data: unknown) => ({ ok: true, data })),
23
+ }),
20
24
  }))
21
25
  jest.mock('../injection/useInjectionDataWidgets', () => ({
22
26
  __esModule: true,
@@ -31,7 +35,8 @@ jest.mock('../injection/useInjectionDataWidgets', () => ({
31
35
  }))
32
36
 
33
37
  import * as React from 'react'
34
- import { waitFor } from '@testing-library/react'
38
+ import { fireEvent, waitFor } from '@testing-library/react'
39
+ import { z } from 'zod'
35
40
  import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
36
41
  import { CrudForm, type CrudField, type CrudFormGroup } from '../CrudForm'
37
42
  import { InjectionPosition } from '@open-mercato/shared/modules/widgets/injection-position'
@@ -139,3 +144,135 @@ describe('CrudForm group field injection (#3047)', () => {
139
144
  expect(labelMatches).toHaveLength(1)
140
145
  })
141
146
  })
147
+
148
+ describe('CrudForm injected field id collisions', () => {
149
+ afterEach(() => {
150
+ injectedFieldWidgets = []
151
+ })
152
+
153
+ function renderCollisionForm(options: {
154
+ fields: CrudField[]
155
+ initialValues: Record<string, unknown>
156
+ onSubmit: (values: unknown) => void
157
+ schema?: z.ZodTypeAny
158
+ }) {
159
+ return renderWithProviders(
160
+ React.createElement(CrudForm as any, {
161
+ title: 'Form',
162
+ entityId: 'catalog:catalog_product_price',
163
+ fields: options.fields,
164
+ groups: [{ id: 'scope', title: 'Scope', fields: options.fields.map((field) => field.id) }],
165
+ initialValues: options.initialValues,
166
+ schema: options.schema,
167
+ onSubmit: options.onSubmit,
168
+ }),
169
+ )
170
+ }
171
+
172
+ it('submits the value of an injected field that reuses a host field id', async () => {
173
+ injectedFieldWidgets = [
174
+ { fields: [{ id: 'customerGroupId', label: 'Customer group', type: 'text', group: 'scope' }] },
175
+ ]
176
+ const onSubmit = jest.fn()
177
+ const { container } = renderCollisionForm({
178
+ fields: [
179
+ { id: 'name', label: 'Name', type: 'text' },
180
+ { id: 'customerGroupId', label: 'Customer group id', type: 'text' },
181
+ ],
182
+ initialValues: { name: 'Retail', customerGroupId: 'group-1' },
183
+ onSubmit,
184
+ })
185
+
186
+ fireEvent.submit(container.querySelector('form') as HTMLFormElement)
187
+
188
+ await waitFor(() => expect(onSubmit).toHaveBeenCalled())
189
+ expect(onSubmit.mock.calls[0][0]).toEqual(
190
+ expect.objectContaining({ name: 'Retail', customerGroupId: 'group-1' }),
191
+ )
192
+ })
193
+
194
+ it('submits the value of an injected field that reuses a group-inline host field id', async () => {
195
+ injectedFieldWidgets = [
196
+ { fields: [{ id: 'customerGroupId', label: 'Customer group', type: 'text', group: 'scope' }] },
197
+ ]
198
+ const onSubmit = jest.fn()
199
+ const { container } = renderWithProviders(
200
+ React.createElement(CrudForm as any, {
201
+ title: 'Form',
202
+ entityId: 'catalog:catalog_product_price',
203
+ fields: [{ id: 'name', label: 'Name', type: 'text' }],
204
+ groups: [
205
+ {
206
+ id: 'scope',
207
+ title: 'Scope',
208
+ fields: ['name', { id: 'customerGroupId', label: 'Customer group id', type: 'text' }],
209
+ },
210
+ ],
211
+ initialValues: { name: 'Retail', customerGroupId: 'group-1' },
212
+ onSubmit,
213
+ }),
214
+ )
215
+
216
+ await waitFor(() => {
217
+ expect(container.querySelector('[data-crud-field-id="customerGroupId"]')).toBeTruthy()
218
+ })
219
+ fireEvent.submit(container.querySelector('form') as HTMLFormElement)
220
+
221
+ await waitFor(() => expect(onSubmit).toHaveBeenCalled())
222
+ expect(onSubmit.mock.calls[0][0]).toEqual(
223
+ expect.objectContaining({ name: 'Retail', customerGroupId: 'group-1' }),
224
+ )
225
+ })
226
+
227
+ it('still strips an injected-only field from the host payload', async () => {
228
+ injectedFieldWidgets = [
229
+ { fields: [{ id: 'widgetOnlyNote', label: 'Widget note', type: 'text', group: 'scope' }] },
230
+ ]
231
+ const onSubmit = jest.fn()
232
+ const { container } = renderCollisionForm({
233
+ fields: [{ id: 'name', label: 'Name', type: 'text' }],
234
+ initialValues: { name: 'Retail', widgetOnlyNote: 'extra' },
235
+ onSubmit,
236
+ })
237
+
238
+ await waitFor(() => {
239
+ expect(container.querySelector('[data-crud-field-id="widgetOnlyNote"]')).toBeTruthy()
240
+ })
241
+ fireEvent.submit(container.querySelector('form') as HTMLFormElement)
242
+
243
+ await waitFor(() => expect(onSubmit).toHaveBeenCalled())
244
+ const submitted = onSubmit.mock.calls[0][0] as Record<string, unknown>
245
+ expect(submitted.name).toBe('Retail')
246
+ expect(submitted).not.toHaveProperty('widgetOnlyNote')
247
+ })
248
+
249
+ it('collapses a dot-path host field that an injected field reuses into its nested shape', async () => {
250
+ injectedFieldWidgets = [
251
+ { fields: [{ id: 'metadata.channel', label: 'Channel', type: 'text', group: 'scope' }] },
252
+ ]
253
+ const onSubmit = jest.fn()
254
+ const { container } = renderCollisionForm({
255
+ fields: [
256
+ { id: 'name', label: 'Name', type: 'text' },
257
+ { id: 'metadata.channel', label: 'Channel', type: 'text' },
258
+ ],
259
+ initialValues: { name: 'Retail', metadata: { channel: 'web' } },
260
+ schema: z.object({
261
+ name: z.string(),
262
+ metadata: z.object({ channel: z.string() }).optional(),
263
+ }),
264
+ onSubmit,
265
+ })
266
+
267
+ await waitFor(() => {
268
+ expect(container.querySelector('[data-crud-field-id="metadata.channel"] input')).toBeTruthy()
269
+ })
270
+ const channelInput = container.querySelector('[data-crud-field-id="metadata.channel"] input') as HTMLInputElement
271
+ await waitFor(() => expect(channelInput.value).toBe('web'))
272
+ fireEvent.change(channelInput, { target: { value: 'store' } })
273
+ fireEvent.submit(container.querySelector('form') as HTMLFormElement)
274
+
275
+ await waitFor(() => expect(onSubmit).toHaveBeenCalled())
276
+ expect(onSubmit.mock.calls[0][0]).toEqual({ name: 'Retail', metadata: { channel: 'store' } })
277
+ })
278
+ })
@@ -5,7 +5,7 @@ jest.setTimeout(15000)
5
5
  // stable id. Hiding is presentation-only — the fields of a hidden group keep their
6
6
  // values and are still submitted, so a smaller form can never silently clear data.
7
7
  let injectedGroupWidgets: unknown[] = []
8
- let injectedFieldWidgets: Array<{ fields: unknown[] }> = []
8
+ let injectedFieldWidgets: Array<{ metadata: { id: string }; fields: unknown[] }> = []
9
9
  const fetchCustomFieldFormStructureMock = jest.fn()
10
10
  const buildFormFieldFromCustomFieldDefMock = jest.fn()
11
11
 
@@ -9,6 +9,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
9
9
  import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
10
10
  import { DataTable } from '../DataTable'
11
11
  import { APP_EVENT_DOM_NAME } from '../injection/useAppEvent'
12
+ import { registerComponentOverrides } from '@open-mercato/shared/modules/widgets/component-registry'
12
13
 
13
14
  const mockRouterRefresh = jest.fn()
14
15
 
@@ -72,12 +73,41 @@ function renderTable(elementProps: Record<string, unknown>) {
72
73
 
73
74
  describe('DataTable extensions', () => {
74
75
  beforeEach(() => {
76
+ registerComponentOverrides([])
75
77
  ;(globalThis as typeof globalThis & { ResizeObserver?: typeof ResizeObserverMock }).ResizeObserver = ResizeObserverMock
76
78
  useInjectionDataWidgetsMock.mockImplementation(() => ({ widgets: [], isLoading: false, error: null }))
77
79
  flashMock.mockReset()
78
80
  mockRouterRefresh.mockReset()
79
81
  })
80
82
 
83
+ afterEach(() => {
84
+ registerComponentOverrides([])
85
+ })
86
+
87
+ it('executes transforms and wrappers at the derived DataTable handle', () => {
88
+ registerComponentOverrides([
89
+ {
90
+ target: { componentId: 'data-table:catalog.products.list' },
91
+ priority: 10,
92
+ propsTransform: (props: Record<string, unknown>) => ({ ...props, title: 'Transformed title' }),
93
+ },
94
+ {
95
+ target: { componentId: 'data-table:catalog.products.list' },
96
+ priority: 20,
97
+ wrapper: (Original: React.ComponentType<Record<string, unknown>>) => function Wrapped(props) {
98
+ return <section data-testid="registered-table-wrapper"><Original {...props} /></section>
99
+ },
100
+ },
101
+ ])
102
+ renderTable({
103
+ columns: [{ accessorKey: 'name', header: 'Name' }],
104
+ data: [{ id: 'r1', name: 'Product' }],
105
+ extensionTableId: 'catalog.products.list',
106
+ })
107
+ expect(screen.getByTestId('registered-table-wrapper')).toBeInTheDocument()
108
+ expect(screen.getByText('Transformed title')).toBeInTheDocument()
109
+ })
110
+
81
111
  it('renders injected columns from data-table extension surface', () => {
82
112
  useInjectionDataWidgetsMock.mockImplementation((spotId: string) => {
83
113
  if (spotId === 'data-table:customers.people:columns') {