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

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 (85) 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 +62 -10
  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 +4 -5
  43. package/src/backend/CrudForm.tsx +30 -8
  44. package/src/backend/DataTable.tsx +95 -20
  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__/DataTable.stickyResponsive.test.tsx +19 -0
  52. package/src/backend/__tests__/DevRuntimeDiagnosticsBanner.test.tsx +117 -0
  53. package/src/backend/__tests__/NotesSection.test.tsx +138 -0
  54. package/src/backend/__tests__/RowActions.test.tsx +202 -0
  55. package/src/backend/charts/KpiCard.tsx +1 -0
  56. package/src/backend/charts/__tests__/KpiCard.test.tsx +20 -0
  57. package/src/backend/detail/AttachmentsSection.tsx +17 -5
  58. package/src/backend/detail/NotesSection.tsx +122 -66
  59. package/src/backend/dev/DevRuntimeDiagnosticsBanner.tsx +24 -1
  60. package/src/backend/icons/lucideRegistry.generated.tsx +26 -0
  61. package/src/backend/injection/__tests__/eventBridge.test.tsx +47 -1
  62. package/src/backend/injection/eventBridge.ts +35 -0
  63. package/src/backend/inputs/LookupSelect.tsx +32 -3
  64. package/src/backend/inputs/PhoneNumberField.tsx +1 -1
  65. package/src/backend/inputs/TagsInput.tsx +24 -3
  66. package/src/backend/inputs/__tests__/LookupSelect.test.tsx +166 -1
  67. package/src/backend/inputs/__tests__/PhoneNumberField.typeahead.test.tsx +43 -0
  68. package/src/backend/inputs/__tests__/TagsInput.test.tsx +81 -0
  69. package/src/backend/messages/EmailThreadsPanel.tsx +21 -1
  70. package/src/backend/messages/MessageComposer.tsx +1 -0
  71. package/src/backend/messages/__tests__/EmailThreadsPanel.senderLabel.test.tsx +99 -0
  72. package/src/backend/messages/__tests__/MessageComposer.test.tsx +44 -0
  73. package/src/backend/messages/__tests__/useMessageCompose.test.tsx +69 -0
  74. package/src/backend/messages/__tests__/useMessageComposeSender.test.tsx +208 -0
  75. package/src/backend/messages/index.ts +1 -0
  76. package/src/backend/messages/message-compose-form-groups.tsx +41 -0
  77. package/src/backend/messages/message-composer.types.ts +26 -0
  78. package/src/backend/messages/useMessageCompose.ts +46 -0
  79. package/src/backend/messages/useMessageComposeOperations.ts +8 -0
  80. package/src/backend/notifications/NotificationItem.tsx +7 -0
  81. package/src/backend/notifications/__tests__/NotificationItem.customRenderer.test.tsx +139 -0
  82. package/src/portal/PortalShell.tsx +7 -4
  83. package/src/portal/__tests__/PortalShell.test.tsx +29 -1
  84. package/src/primitives/__tests__/calendar.test.tsx +174 -1
  85. package/src/primitives/calendar.tsx +168 -96
@@ -19,9 +19,26 @@ export function RowActions({ items = [] }: { items?: RowActionItem[] }) {
19
19
  const btnRef = React.useRef<HTMLButtonElement>(null)
20
20
  const menuRef = React.useRef<HTMLDivElement>(null)
21
21
  const hoverTimeoutRef = React.useRef<NodeJS.Timeout | null>(null)
22
+ /**
23
+ * Whether this open should pull focus into the menu.
24
+ *
25
+ * Only a deliberate open does — a click, or Enter/Space on the trigger, which
26
+ * the browser also reports as a click. The menu opens on hover too, and taking
27
+ * focus on a hover open meant brushing the pointer over ⋯ while typing in a
28
+ * list's search field pulled the caret out of the field (#6771).
29
+ *
30
+ * Consumed on use, so repositioning the panel mid-scroll cannot re-trigger it.
31
+ */
32
+ const focusOnOpenRef = React.useRef(false)
22
33
  const [anchorRect, setAnchorRect] = React.useState<DOMRect | null>(null)
23
34
  const [direction, setDirection] = React.useState<'down' | 'up'>('down')
24
35
 
36
+ /** The menu's items in DOM order, as focusable elements. */
37
+ const getFocusableItems = React.useCallback((): HTMLElement[] => {
38
+ if (!menuRef.current) return []
39
+ return Array.from(menuRef.current.querySelectorAll<HTMLElement>('[role="menuitem"]'))
40
+ }, [])
41
+
25
42
  const updatePosition = React.useCallback(() => {
26
43
  if (!btnRef.current) return
27
44
  const rect = btnRef.current.getBoundingClientRect()
@@ -42,10 +59,60 @@ export function RowActions({ items = [] }: { items?: RowActionItem[] }) {
42
59
  }
43
60
  }
44
61
  function onKey(e: KeyboardEvent) {
62
+ // This listener is on `document`, and the menu can be open while the user
63
+ // is working somewhere else entirely — it opens on hover. So every key
64
+ // below is scoped to "focus is actually in this menu", otherwise a
65
+ // hover-opened menu would swallow the arrows and Home/End of someone
66
+ // typing in the list's search field (#6771). The trigger counts, so
67
+ // ArrowDown from it still walks into the menu.
68
+ const active = document.activeElement
69
+ const focusWithin = Boolean(
70
+ (menuRef.current && active && menuRef.current.contains(active)) || (active && active === btnRef.current),
71
+ )
45
72
  if (e.key === 'Escape') {
46
73
  setOpen(false)
47
- btnRef.current?.focus()
74
+ // Only pull focus back if it was ours to begin with.
75
+ if (focusWithin) btnRef.current?.focus()
76
+ return
77
+ }
78
+ if (!focusWithin) return
79
+ // Arrow keys move between items instead of doing nothing (#6718). Without
80
+ // this the only way to reach an item was to Tab through it — and since
81
+ // every row's trigger sits in the tab order, reaching the first item of a
82
+ // six-row table took eleven Tab presses.
83
+ const focusables = getFocusableItems()
84
+ if (!focusables.length) return
85
+ const activeIndex = focusables.indexOf(document.activeElement as HTMLElement)
86
+ let nextIndex: number | null = null
87
+ switch (e.key) {
88
+ case 'ArrowDown':
89
+ nextIndex = activeIndex < 0 ? 0 : (activeIndex + 1) % focusables.length
90
+ break
91
+ case 'ArrowUp':
92
+ nextIndex = activeIndex < 0
93
+ ? focusables.length - 1
94
+ : (activeIndex - 1 + focusables.length) % focusables.length
95
+ break
96
+ case 'Home':
97
+ nextIndex = 0
98
+ break
99
+ case 'End':
100
+ nextIndex = focusables.length - 1
101
+ break
102
+ case 'Tab':
103
+ // A menu is modal for the keyboard: Tab must not escape it into the
104
+ // next row's trigger, which is what made the menu feel unreachable.
105
+ e.preventDefault()
106
+ nextIndex = e.shiftKey
107
+ ? (activeIndex <= 0 ? focusables.length - 1 : activeIndex - 1)
108
+ : (activeIndex < 0 || activeIndex === focusables.length - 1 ? 0 : activeIndex + 1)
109
+ break
110
+ default:
111
+ return
48
112
  }
113
+ if (nextIndex === null) return
114
+ e.preventDefault()
115
+ focusables[nextIndex]?.focus()
49
116
  }
50
117
  function onScrollOrResize() {
51
118
  updatePosition()
@@ -60,7 +127,28 @@ export function RowActions({ items = [] }: { items?: RowActionItem[] }) {
60
127
  window.removeEventListener('scroll', onScrollOrResize, true)
61
128
  window.removeEventListener('resize', onScrollOrResize)
62
129
  }
63
- }, [open, updatePosition])
130
+ }, [open, updatePosition, getFocusableItems])
131
+
132
+ // Move focus into the menu so the arrow keys have somewhere to start, and so a
133
+ // screen reader announces the item rather than the still-focused trigger.
134
+ //
135
+ // Keyed on `anchorRect` rather than deferred with a timer: the panel only
136
+ // renders once `updatePosition` has measured the trigger, which is a second
137
+ // render, so on the first pass there is nothing to focus yet. This effect
138
+ // re-runs when that measurement lands, by which point the portal has
139
+ // committed. A `requestAnimationFrame` would also have waited for it, but
140
+ // browsers throttle rAF in a background tab, so the focus could silently never
141
+ // happen — a timer-free dependency cannot be throttled.
142
+ React.useEffect(() => {
143
+ if (!open) {
144
+ focusOnOpenRef.current = false
145
+ return
146
+ }
147
+ if (!anchorRect || !focusOnOpenRef.current) return
148
+ focusOnOpenRef.current = false
149
+ const [first] = getFocusableItems()
150
+ first?.focus()
151
+ }, [open, anchorRect, getFocusableItems])
64
152
 
65
153
  // Cleanup timeout on unmount
66
154
  React.useEffect(() => {
@@ -100,7 +188,9 @@ export function RowActions({ items = [] }: { items?: RowActionItem[] }) {
100
188
  variant="ghost"
101
189
  aria-haspopup="menu"
102
190
  aria-expanded={open}
103
- onClick={(e) => { e.stopPropagation(); setOpen(true); requestAnimationFrame(updatePosition) }}
191
+ // A click — and Enter/Space on the trigger, which the browser reports as
192
+ // one — is a deliberate open, so it takes focus. Hover does not (#6771).
193
+ onClick={(e) => { e.stopPropagation(); focusOnOpenRef.current = true; setOpen(true); requestAnimationFrame(updatePosition) }}
104
194
  >
105
195
  <span aria-hidden="true">⋯</span>
106
196
  <span className="sr-only">{t('ui.rowActions.openActions', 'Open actions')}</span>
@@ -109,7 +199,22 @@ export function RowActions({ items = [] }: { items?: RowActionItem[] }) {
109
199
  <div
110
200
  ref={menuRef}
111
201
  role="menu"
112
- className="fixed w-44 max-w-[calc(100vw-1rem)] rounded-md border bg-background p-1 shadow focus-visible:outline-none z-dropdown"
202
+ // Sized to its longest item instead of a fixed 11rem: the items are
203
+ // Buttons, which are `whitespace-nowrap`, so a label wider than the box
204
+ // overflowed its right border — "Zarejestruj push ponownie" in Polish
205
+ // overhung by ~10px (#6719). `min-w-44` keeps the old width as a floor;
206
+ // past `max-w-xs` the items wrap instead of growing further.
207
+ //
208
+ // `flex flex-col` is load-bearing for that, not cosmetic. Button is
209
+ // `inline-flex`, so under a block panel the items are inline-level and
210
+ // `max-content` is the width of them all laid end to end — 523px here,
211
+ // past the cap, so the panel was pinned at `max-w-xs` whatever the
212
+ // labels said. They only looked stacked because `w-full` forced each
213
+ // onto its own line. Blockifying them as flex items makes `max-content`
214
+ // the widest item, which is what `w-max` was meant to measure.
215
+ //
216
+ // Same fix, same shape as ActionsDropdown (#3580).
217
+ className="fixed flex flex-col w-max min-w-44 max-w-xs rounded-md border bg-background p-1 shadow focus-visible:outline-none z-dropdown"
113
218
  style={{
114
219
  top: direction === 'down' ? anchorRect.bottom + 8 : anchorRect.top - 8,
115
220
  left: Math.min(anchorRect.right, window.innerWidth - 8),
@@ -123,7 +228,7 @@ export function RowActions({ items = [] }: { items?: RowActionItem[] }) {
123
228
  <a
124
229
  key={idx}
125
230
  href={it.href}
126
- className={`block w-full text-left px-2 py-1 text-sm rounded hover:bg-accent ${it.destructive ? 'text-destructive' : ''}`}
231
+ className={`block w-full text-left px-2 py-1 text-sm rounded hover:bg-accent whitespace-normal ${it.destructive ? 'text-destructive' : ''}`}
127
232
  role="menuitem"
128
233
  onClick={(event) => {
129
234
  event.stopPropagation()
@@ -138,7 +243,10 @@ export function RowActions({ items = [] }: { items?: RowActionItem[] }) {
138
243
  type="button"
139
244
  variant="ghost"
140
245
  size="sm"
141
- className={`w-full justify-start rounded-none font-normal ${it.destructive ? 'text-destructive' : ''}`}
246
+ // `whitespace-normal h-auto` so a label that reaches the panel's
247
+ // max width wraps to a second line rather than overflowing: the
248
+ // Button primitive is `whitespace-nowrap` by default (#6719).
249
+ className={`w-full justify-start rounded-none font-normal whitespace-normal h-auto py-1.5 text-left ${it.destructive ? 'text-destructive' : ''}`}
142
250
  role="menuitem"
143
251
  onClick={(event) => {
144
252
  event.stopPropagation()
@@ -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') {