@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/backend/CrudForm.js +19 -6
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/DataTable.js +61 -9
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/RowActions.js +54 -5
- package/dist/backend/RowActions.js.map +2 -2
- package/dist/backend/charts/KpiCard.js +2 -1
- package/dist/backend/charts/KpiCard.js.map +2 -2
- package/dist/backend/detail/AttachmentsSection.js +17 -5
- package/dist/backend/detail/AttachmentsSection.js.map +2 -2
- package/dist/backend/detail/NotesSection.js +38 -20
- package/dist/backend/detail/NotesSection.js.map +2 -2
- package/dist/backend/dev/DevRuntimeDiagnosticsBanner.js +11 -1
- package/dist/backend/dev/DevRuntimeDiagnosticsBanner.js.map +2 -2
- package/dist/backend/icons/lucideRegistry.generated.js +26 -0
- package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
- package/dist/backend/injection/eventBridge.js +33 -0
- package/dist/backend/injection/eventBridge.js.map +2 -2
- package/dist/backend/inputs/LookupSelect.js +20 -3
- package/dist/backend/inputs/LookupSelect.js.map +2 -2
- package/dist/backend/inputs/PhoneNumberField.js +1 -1
- package/dist/backend/inputs/PhoneNumberField.js.map +2 -2
- package/dist/backend/inputs/TagsInput.js +20 -2
- package/dist/backend/inputs/TagsInput.js.map +2 -2
- package/dist/backend/messages/EmailThreadsPanel.js +2 -1
- package/dist/backend/messages/EmailThreadsPanel.js.map +2 -2
- package/dist/backend/messages/MessageComposer.js.map +2 -2
- package/dist/backend/messages/index.js.map +2 -2
- package/dist/backend/messages/message-compose-form-groups.js +24 -0
- package/dist/backend/messages/message-compose-form-groups.js.map +2 -2
- package/dist/backend/messages/useMessageCompose.js +27 -0
- package/dist/backend/messages/useMessageCompose.js.map +2 -2
- package/dist/backend/messages/useMessageComposeOperations.js +1 -0
- package/dist/backend/messages/useMessageComposeOperations.js.map +2 -2
- package/dist/backend/notifications/NotificationItem.js +8 -1
- package/dist/backend/notifications/NotificationItem.js.map +2 -2
- package/dist/portal/PortalShell.js +2 -2
- package/dist/portal/PortalShell.js.map +2 -2
- package/dist/primitives/calendar.js +69 -14
- package/dist/primitives/calendar.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/CrudForm.tsx +30 -8
- package/src/backend/DataTable.tsx +92 -19
- package/src/backend/RowActions.tsx +114 -6
- package/src/backend/__tests__/AttachmentsSection.upload.test.tsx +183 -0
- package/src/backend/__tests__/CrudForm.fieldInjection.test.tsx +139 -2
- package/src/backend/__tests__/CrudForm.hiddenGroups.test.tsx +1 -1
- package/src/backend/__tests__/DataTable.extensions.test.tsx +30 -0
- package/src/backend/__tests__/DataTable.perspectiveReconcile.test.tsx +320 -2
- package/src/backend/__tests__/DevRuntimeDiagnosticsBanner.test.tsx +117 -0
- package/src/backend/__tests__/NotesSection.test.tsx +138 -0
- package/src/backend/__tests__/RowActions.test.tsx +202 -0
- package/src/backend/charts/KpiCard.tsx +1 -0
- package/src/backend/charts/__tests__/KpiCard.test.tsx +20 -0
- package/src/backend/detail/AttachmentsSection.tsx +17 -5
- package/src/backend/detail/NotesSection.tsx +122 -66
- package/src/backend/dev/DevRuntimeDiagnosticsBanner.tsx +24 -1
- package/src/backend/icons/lucideRegistry.generated.tsx +26 -0
- package/src/backend/injection/__tests__/eventBridge.test.tsx +47 -1
- package/src/backend/injection/eventBridge.ts +35 -0
- package/src/backend/inputs/LookupSelect.tsx +32 -3
- package/src/backend/inputs/PhoneNumberField.tsx +1 -1
- package/src/backend/inputs/TagsInput.tsx +24 -3
- package/src/backend/inputs/__tests__/LookupSelect.test.tsx +166 -1
- package/src/backend/inputs/__tests__/PhoneNumberField.typeahead.test.tsx +43 -0
- package/src/backend/inputs/__tests__/TagsInput.test.tsx +81 -0
- package/src/backend/messages/EmailThreadsPanel.tsx +21 -1
- package/src/backend/messages/MessageComposer.tsx +1 -0
- package/src/backend/messages/__tests__/EmailThreadsPanel.senderLabel.test.tsx +99 -0
- package/src/backend/messages/__tests__/MessageComposer.test.tsx +44 -0
- package/src/backend/messages/__tests__/useMessageCompose.test.tsx +69 -0
- package/src/backend/messages/__tests__/useMessageComposeSender.test.tsx +208 -0
- package/src/backend/messages/index.ts +1 -0
- package/src/backend/messages/message-compose-form-groups.tsx +41 -0
- package/src/backend/messages/message-composer.types.ts +26 -0
- package/src/backend/messages/useMessageCompose.ts +46 -0
- package/src/backend/messages/useMessageComposeOperations.ts +8 -0
- package/src/backend/notifications/NotificationItem.tsx +7 -0
- package/src/backend/notifications/__tests__/NotificationItem.customRenderer.test.tsx +139 -0
- package/src/portal/PortalShell.tsx +7 -4
- package/src/portal/__tests__/PortalShell.test.tsx +29 -1
- package/src/primitives/__tests__/calendar.test.tsx +174 -1
- 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
|
-
|
|
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') {
|