@open-mercato/ui 0.8.1-develop.7263.1.bbc6db3437 → 0.8.1-develop.7267.1.2e95af80a6
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 +63 -16
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/icons/lucideRegistry.generated.js +0 -2
- package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
- package/dist/backend/messages/useMessageComposeOperations.js +1 -1
- package/dist/backend/messages/useMessageComposeOperations.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/CrudForm.tsx +126 -18
- package/src/backend/__tests__/CrudForm.hiddenGroups.test.tsx +428 -0
- package/src/backend/icons/lucideRegistry.generated.tsx +0 -2
- package/src/backend/messages/__tests__/useMessageComposeOperations.test.tsx +75 -0
- package/src/backend/messages/useMessageComposeOperations.ts +1 -1
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
jest.setTimeout(15000)
|
|
3
|
+
|
|
4
|
+
// Coverage for `hiddenGroupIds` (#5876): a host may omit declared groups by their
|
|
5
|
+
// stable id. Hiding is presentation-only — the fields of a hidden group keep their
|
|
6
|
+
// values and are still submitted, so a smaller form can never silently clear data.
|
|
7
|
+
let injectedGroupWidgets: unknown[] = []
|
|
8
|
+
let injectedFieldWidgets: Array<{ fields: unknown[] }> = []
|
|
9
|
+
const fetchCustomFieldFormStructureMock = jest.fn()
|
|
10
|
+
const buildFormFieldFromCustomFieldDefMock = jest.fn()
|
|
11
|
+
|
|
12
|
+
jest.mock('next/navigation', () => ({
|
|
13
|
+
useRouter: () => ({ push: () => {} }),
|
|
14
|
+
usePathname: () => '/',
|
|
15
|
+
useSearchParams: () => new URLSearchParams(),
|
|
16
|
+
}))
|
|
17
|
+
jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
|
|
18
|
+
jest.mock('../injection/InjectionSpot', () => ({
|
|
19
|
+
__esModule: true,
|
|
20
|
+
InjectionSpot: () => null,
|
|
21
|
+
useInjectionWidgets: () => ({ widgets: injectedGroupWidgets, loading: false, error: null }),
|
|
22
|
+
useInjectionSpotEvents: () => ({ triggerEvent: jest.fn(async () => ({ ok: true, data: {} })) }),
|
|
23
|
+
}))
|
|
24
|
+
jest.mock('../injection/useInjectionDataWidgets', () => ({
|
|
25
|
+
__esModule: true,
|
|
26
|
+
useInjectionDataWidgets: () => ({ widgets: injectedFieldWidgets, isLoading: false, error: null }),
|
|
27
|
+
}))
|
|
28
|
+
// Passes through to the real hook so the persisted-order assertions exercise its
|
|
29
|
+
// actual localStorage writes; the spy only captures what CrudForm hands it.
|
|
30
|
+
let mockCapturedReorder: ((fromIndex: number, toIndex: number) => void) | null = null
|
|
31
|
+
jest.mock('../crud/useGroupOrder', () => {
|
|
32
|
+
const actual = jest.requireActual('../crud/useGroupOrder')
|
|
33
|
+
return {
|
|
34
|
+
__esModule: true,
|
|
35
|
+
useGroupOrder: (pageType: string, defaultGroupIds: string[]) => {
|
|
36
|
+
const result = actual.useGroupOrder(pageType, defaultGroupIds)
|
|
37
|
+
mockCapturedReorder = result.reorder
|
|
38
|
+
return result
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
jest.mock('../utils/customFieldForms', () => ({
|
|
43
|
+
__esModule: true,
|
|
44
|
+
buildFormFieldFromCustomFieldDef: (...args: unknown[]) => buildFormFieldFromCustomFieldDefMock(...args),
|
|
45
|
+
buildFormFieldsFromCustomFields: jest.fn(() => []),
|
|
46
|
+
fetchCustomFieldFormStructure: (...args: unknown[]) => fetchCustomFieldFormStructureMock(...args),
|
|
47
|
+
}))
|
|
48
|
+
|
|
49
|
+
import * as React from 'react'
|
|
50
|
+
import { act, fireEvent, waitFor } from '@testing-library/react'
|
|
51
|
+
import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
|
|
52
|
+
import { CrudForm, type CrudField, type CrudFormGroup } from '../CrudForm'
|
|
53
|
+
|
|
54
|
+
const fields: CrudField[] = [
|
|
55
|
+
{ id: 'name', label: 'Name', type: 'text' },
|
|
56
|
+
{ id: 'legalName', label: 'Legal name', type: 'text' },
|
|
57
|
+
{ id: 'note', label: 'Note', type: 'text' },
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
const groups: CrudFormGroup[] = [
|
|
61
|
+
{ id: 'details', title: 'Details', column: 1, fields: ['name'] },
|
|
62
|
+
{ id: 'profile', title: 'Profile', column: 1, fields: ['legalName'] },
|
|
63
|
+
{ id: 'notes', title: 'Notes', column: 2, fields: ['note'] },
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
const initialValues = { name: 'Acme', legalName: 'Acme Sp. z o.o.', note: 'hello' }
|
|
67
|
+
|
|
68
|
+
type RenderOptions = {
|
|
69
|
+
hiddenGroupIds?: readonly string[]
|
|
70
|
+
onSubmit?: (values: unknown) => void
|
|
71
|
+
formGroups?: CrudFormGroup[]
|
|
72
|
+
formFields?: CrudField[]
|
|
73
|
+
formInitialValues?: Record<string, unknown>
|
|
74
|
+
noGroups?: boolean
|
|
75
|
+
entityId?: string
|
|
76
|
+
collapsibleGroups?: boolean
|
|
77
|
+
sortableGroups?: boolean | { pageType: string }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const ORDER_PAGE_TYPE = 'company-form'
|
|
81
|
+
const ORDER_STORAGE_KEY = `om:group-order:${ORDER_PAGE_TYPE}`
|
|
82
|
+
|
|
83
|
+
// One column, so the rendered card order is the persisted order rather than a
|
|
84
|
+
// column split.
|
|
85
|
+
const singleColumnGroups: CrudFormGroup[] = [
|
|
86
|
+
{ id: 'details', title: 'Details', column: 1, fields: ['name'] },
|
|
87
|
+
{ id: 'profile', title: 'Profile', column: 1, fields: ['legalName'] },
|
|
88
|
+
{ id: 'notes', title: 'Notes', column: 1, fields: ['note'] },
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
function renderForm(options: RenderOptions = {}) {
|
|
92
|
+
return renderWithProviders(
|
|
93
|
+
<CrudForm
|
|
94
|
+
title="Company"
|
|
95
|
+
fields={options.formFields ?? fields}
|
|
96
|
+
groups={options.noGroups ? undefined : (options.formGroups ?? groups)}
|
|
97
|
+
entityId={options.entityId}
|
|
98
|
+
initialValues={options.formInitialValues ?? initialValues}
|
|
99
|
+
hiddenGroupIds={options.hiddenGroupIds}
|
|
100
|
+
collapsibleGroups={options.collapsibleGroups}
|
|
101
|
+
sortableGroups={options.sortableGroups}
|
|
102
|
+
onSubmit={options.onSubmit ?? (() => {})}
|
|
103
|
+
/>,
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Group cards render their title in a `text-sm font-medium` div; every rendered
|
|
108
|
+
// field control is wrapped in a `data-crud-field-id` node.
|
|
109
|
+
function groupTitles(container: HTMLElement): string[] {
|
|
110
|
+
return Array.from(container.querySelectorAll('div.text-sm.font-medium'))
|
|
111
|
+
.map((node) => node.textContent?.trim() ?? '')
|
|
112
|
+
.filter(Boolean)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function fieldIds(container: HTMLElement): string[] {
|
|
116
|
+
return Array.from(container.querySelectorAll('[data-crud-field-id]'))
|
|
117
|
+
.map((node) => node.getAttribute('data-crud-field-id') ?? '')
|
|
118
|
+
.filter(Boolean)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function submitForm(container: HTMLElement) {
|
|
122
|
+
fireEvent.submit(container.querySelector('form') as HTMLFormElement)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
describe('CrudForm hiddenGroupIds', () => {
|
|
126
|
+
afterEach(() => {
|
|
127
|
+
injectedGroupWidgets = []
|
|
128
|
+
injectedFieldWidgets = []
|
|
129
|
+
mockCapturedReorder = null
|
|
130
|
+
window.localStorage.clear()
|
|
131
|
+
fetchCustomFieldFormStructureMock.mockReset()
|
|
132
|
+
buildFormFieldFromCustomFieldDefMock.mockReset()
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
describe('absence preserves existing behavior', () => {
|
|
136
|
+
it('renders every declared group when the prop is omitted', () => {
|
|
137
|
+
const { container } = renderForm()
|
|
138
|
+
expect(groupTitles(container)).toEqual(expect.arrayContaining(['Details', 'Profile', 'Notes']))
|
|
139
|
+
expect(fieldIds(container)).toEqual(['name', 'legalName', 'note'])
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('renders identically for an omitted prop and an empty array', () => {
|
|
143
|
+
// React's useId yields a fresh form id per render, so normalize it away —
|
|
144
|
+
// everything else must match byte for byte.
|
|
145
|
+
const normalize = (html: string) => html.replace(/_r_[0-9a-z]+_/g, '_r_id_')
|
|
146
|
+
const omitted = normalize(renderForm().container.innerHTML)
|
|
147
|
+
const empty = normalize(renderForm({ hiddenGroupIds: [] }).container.innerHTML)
|
|
148
|
+
expect(empty).toBe(omitted)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('still uses the flat layout for a form that declares no groups at all', () => {
|
|
152
|
+
const { container } = renderForm({ noGroups: true })
|
|
153
|
+
expect(fieldIds(container)).toEqual(['name', 'legalName', 'note'])
|
|
154
|
+
expect(groupTitles(container)).not.toContain('Details')
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
describe('hiding by stable id', () => {
|
|
159
|
+
it('removes only the named group and leaves its siblings untouched', () => {
|
|
160
|
+
const { container } = renderForm({ hiddenGroupIds: ['profile'] })
|
|
161
|
+
const titles = groupTitles(container)
|
|
162
|
+
expect(titles).toEqual(expect.arrayContaining(['Details', 'Notes']))
|
|
163
|
+
expect(titles).not.toContain('Profile')
|
|
164
|
+
expect(fieldIds(container)).toEqual(['name', 'note'])
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('hides several groups at once', () => {
|
|
168
|
+
const { container } = renderForm({ hiddenGroupIds: ['profile', 'notes'] })
|
|
169
|
+
const titles = groupTitles(container)
|
|
170
|
+
expect(titles).toContain('Details')
|
|
171
|
+
expect(titles).not.toContain('Profile')
|
|
172
|
+
expect(titles).not.toContain('Notes')
|
|
173
|
+
expect(fieldIds(container)).toEqual(['name'])
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('ignores an id that matches no declared group', () => {
|
|
177
|
+
const { container } = renderForm({ hiddenGroupIds: ['does-not-exist'] })
|
|
178
|
+
expect(groupTitles(container)).toEqual(expect.arrayContaining(['Details', 'Profile', 'Notes']))
|
|
179
|
+
expect(fieldIds(container)).toEqual(['name', 'legalName', 'note'])
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('does not fall back to the flat field list when every group is hidden', () => {
|
|
183
|
+
const { container } = renderForm({ hiddenGroupIds: ['details', 'profile', 'notes'] })
|
|
184
|
+
expect(fieldIds(container)).toEqual([])
|
|
185
|
+
const titles = groupTitles(container)
|
|
186
|
+
expect(titles).not.toContain('Details')
|
|
187
|
+
expect(titles).not.toContain('Profile')
|
|
188
|
+
expect(titles).not.toContain('Notes')
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('drops the secondary column when the only column-2 group is hidden', () => {
|
|
192
|
+
const findSidebarGrid = (root: HTMLElement) =>
|
|
193
|
+
Array.from(root.querySelectorAll('div')).find((node) => node.className.includes('7fr_3fr'))
|
|
194
|
+
|
|
195
|
+
expect(findSidebarGrid(renderForm().container)).toBeTruthy()
|
|
196
|
+
expect(findSidebarGrid(renderForm({ hiddenGroupIds: ['notes'] }).container)).toBeUndefined()
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
describe('interaction with other group features', () => {
|
|
201
|
+
it('excludes hidden groups from the sortable drag handles', () => {
|
|
202
|
+
const visible = renderForm({ sortableGroups: true, collapsibleGroups: true }).container
|
|
203
|
+
expect(visible.querySelectorAll('button[aria-label="Drag to reorder"]').length).toBe(2)
|
|
204
|
+
|
|
205
|
+
const { container } = renderForm({
|
|
206
|
+
hiddenGroupIds: ['profile'],
|
|
207
|
+
sortableGroups: true,
|
|
208
|
+
collapsibleGroups: true,
|
|
209
|
+
})
|
|
210
|
+
expect(container.querySelectorAll('button[aria-label="Drag to reorder"]').length).toBe(1)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
// Hiding is presentation-only, so it must not mutate the user's stored
|
|
214
|
+
// preference: `useGroupOrder` drops every saved id missing from the defaults it
|
|
215
|
+
// is given and writes the pruned list back on the next reorder.
|
|
216
|
+
it('keeps a hidden group in the persisted order when another card is reordered', async () => {
|
|
217
|
+
window.localStorage.setItem(ORDER_STORAGE_KEY, JSON.stringify(['profile', 'notes', 'details']))
|
|
218
|
+
const { container } = renderForm({
|
|
219
|
+
formGroups: singleColumnGroups,
|
|
220
|
+
hiddenGroupIds: ['profile'],
|
|
221
|
+
sortableGroups: { pageType: ORDER_PAGE_TYPE },
|
|
222
|
+
})
|
|
223
|
+
await waitFor(() => expect(groupTitles(container)).toEqual(['Notes', 'Details']))
|
|
224
|
+
|
|
225
|
+
act(() => { mockCapturedReorder?.(0, 1) })
|
|
226
|
+
|
|
227
|
+
expect(JSON.parse(window.localStorage.getItem(ORDER_STORAGE_KEY) as string))
|
|
228
|
+
.toEqual(['notes', 'profile', 'details'])
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('restores a hidden group to its saved position once it is shown again', async () => {
|
|
232
|
+
window.localStorage.setItem(ORDER_STORAGE_KEY, JSON.stringify(['profile', 'notes', 'details']))
|
|
233
|
+
const hidden = renderForm({
|
|
234
|
+
formGroups: singleColumnGroups,
|
|
235
|
+
hiddenGroupIds: ['profile'],
|
|
236
|
+
sortableGroups: { pageType: ORDER_PAGE_TYPE },
|
|
237
|
+
})
|
|
238
|
+
await waitFor(() => expect(groupTitles(hidden.container)).toEqual(['Notes', 'Details']))
|
|
239
|
+
act(() => { mockCapturedReorder?.(0, 1) })
|
|
240
|
+
|
|
241
|
+
const shown = renderForm({
|
|
242
|
+
formGroups: singleColumnGroups,
|
|
243
|
+
sortableGroups: { pageType: ORDER_PAGE_TYPE },
|
|
244
|
+
})
|
|
245
|
+
await waitFor(() => expect(groupTitles(shown.container)).toEqual(['Notes', 'Profile', 'Details']))
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('renders no collapsible header or control for a hidden group', () => {
|
|
249
|
+
const { container } = renderForm({ hiddenGroupIds: ['profile'], collapsibleGroups: true })
|
|
250
|
+
expect(groupTitles(container)).not.toContain('Profile')
|
|
251
|
+
expect(fieldIds(container)).not.toContain('legalName')
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('hides an injected widget group card addressed by its widget: id', async () => {
|
|
255
|
+
injectedGroupWidgets = [
|
|
256
|
+
{
|
|
257
|
+
widgetId: 'customer_accounts.injection.company-users',
|
|
258
|
+
placement: { kind: 'group', column: 1, groupLabel: 'Portal users', priority: 200 },
|
|
259
|
+
module: {
|
|
260
|
+
metadata: { title: 'Portal users', description: '' },
|
|
261
|
+
Widget: () => <div data-testid="portal-users-widget">portal users</div>,
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
]
|
|
265
|
+
|
|
266
|
+
const { container: shown } = renderForm()
|
|
267
|
+
await waitFor(() => {
|
|
268
|
+
expect(shown.querySelector('[data-testid="portal-users-widget"]')).toBeTruthy()
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
const { container } = renderForm({
|
|
272
|
+
hiddenGroupIds: ['widget:customer_accounts.injection.company-users'],
|
|
273
|
+
})
|
|
274
|
+
expect(container.querySelector('[data-testid="portal-users-widget"]')).toBeNull()
|
|
275
|
+
// the rest of the form is untouched
|
|
276
|
+
expect(fieldIds(container)).toEqual(['name', 'legalName', 'note'])
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it('does not gate submit on an injected field that fell back into a hidden group', async () => {
|
|
280
|
+
// A definition whose target group does not exist is appended to the LAST
|
|
281
|
+
// declared group; when that group is hidden the field renders nowhere, so
|
|
282
|
+
// it must not block submit either.
|
|
283
|
+
injectedFieldWidgets = [
|
|
284
|
+
{
|
|
285
|
+
fields: [
|
|
286
|
+
{
|
|
287
|
+
id: 'injectedOnly',
|
|
288
|
+
label: 'Injected only',
|
|
289
|
+
type: 'text',
|
|
290
|
+
group: 'no-such-group',
|
|
291
|
+
required: true,
|
|
292
|
+
},
|
|
293
|
+
],
|
|
294
|
+
},
|
|
295
|
+
]
|
|
296
|
+
const onSubmit = jest.fn()
|
|
297
|
+
|
|
298
|
+
const { container } = renderForm({
|
|
299
|
+
formFields: [fields[0]],
|
|
300
|
+
formGroups: [
|
|
301
|
+
{ id: 'details', title: 'Details', column: 1, fields: ['name'] },
|
|
302
|
+
{ id: 'profile', title: 'Profile', column: 1, fields: [] },
|
|
303
|
+
],
|
|
304
|
+
formInitialValues: { name: 'Acme' },
|
|
305
|
+
hiddenGroupIds: ['profile'],
|
|
306
|
+
onSubmit,
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
await waitFor(() => {
|
|
310
|
+
expect(fieldIds(container)).not.toContain('injectedOnly')
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
submitForm(container)
|
|
314
|
+
|
|
315
|
+
await waitFor(() => expect(onSubmit).toHaveBeenCalled())
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
it('hides a customFields group card together with its resolved custom fields', async () => {
|
|
319
|
+
fetchCustomFieldFormStructureMock.mockResolvedValue({
|
|
320
|
+
definitions: [
|
|
321
|
+
{
|
|
322
|
+
key: 'segment',
|
|
323
|
+
entityId: 'customers:company',
|
|
324
|
+
label: 'Segment',
|
|
325
|
+
kind: 'text',
|
|
326
|
+
formEditable: true,
|
|
327
|
+
},
|
|
328
|
+
],
|
|
329
|
+
metadata: { items: [], fieldsetsByEntity: {}, entitySettings: {} },
|
|
330
|
+
})
|
|
331
|
+
buildFormFieldFromCustomFieldDefMock.mockImplementation(
|
|
332
|
+
(definition: { key: string; label?: string }) => ({
|
|
333
|
+
id: `cf_${definition.key}`,
|
|
334
|
+
label: definition.label ?? definition.key,
|
|
335
|
+
type: 'text',
|
|
336
|
+
}),
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
const withCustomFields: CrudFormGroup[] = [
|
|
340
|
+
groups[0],
|
|
341
|
+
{ id: 'customFields', column: 2, kind: 'customFields' },
|
|
342
|
+
]
|
|
343
|
+
|
|
344
|
+
const { container: shown } = renderForm({
|
|
345
|
+
formGroups: withCustomFields,
|
|
346
|
+
entityId: 'customers:company',
|
|
347
|
+
formInitialValues: { name: 'Acme' },
|
|
348
|
+
formFields: [fields[0]],
|
|
349
|
+
})
|
|
350
|
+
await waitFor(() => {
|
|
351
|
+
expect(fieldIds(shown)).toContain('cf_segment')
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
const { container } = renderForm({
|
|
355
|
+
formGroups: withCustomFields,
|
|
356
|
+
entityId: 'customers:company',
|
|
357
|
+
formInitialValues: { name: 'Acme' },
|
|
358
|
+
formFields: [fields[0]],
|
|
359
|
+
hiddenGroupIds: ['customFields'],
|
|
360
|
+
})
|
|
361
|
+
await waitFor(() => {
|
|
362
|
+
expect(fetchCustomFieldFormStructureMock).toHaveBeenCalled()
|
|
363
|
+
})
|
|
364
|
+
expect(fieldIds(container)).toEqual(['name'])
|
|
365
|
+
expect(fieldIds(container)).not.toContain('cf_segment')
|
|
366
|
+
})
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
describe('submission semantics', () => {
|
|
370
|
+
it('submits the values of a hidden group unchanged', async () => {
|
|
371
|
+
const onSubmit = jest.fn()
|
|
372
|
+
const { container } = renderForm({ hiddenGroupIds: ['profile'], onSubmit })
|
|
373
|
+
|
|
374
|
+
submitForm(container)
|
|
375
|
+
|
|
376
|
+
await waitFor(() => expect(onSubmit).toHaveBeenCalled())
|
|
377
|
+
expect(onSubmit.mock.calls[0][0]).toEqual(
|
|
378
|
+
expect.objectContaining({ name: 'Acme', legalName: 'Acme Sp. z o.o.', note: 'hello' }),
|
|
379
|
+
)
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
it('does not block submit on a required field that lives only in a hidden group', async () => {
|
|
383
|
+
const onSubmit = jest.fn()
|
|
384
|
+
|
|
385
|
+
const { container } = renderForm({
|
|
386
|
+
formFields: [
|
|
387
|
+
{ id: 'name', label: 'Name', type: 'text' },
|
|
388
|
+
{ id: 'legalName', label: 'Legal name', type: 'text', required: true },
|
|
389
|
+
],
|
|
390
|
+
formGroups: [
|
|
391
|
+
{ id: 'details', title: 'Details', column: 1, fields: ['name'] },
|
|
392
|
+
{ id: 'profile', title: 'Profile', column: 1, fields: ['legalName'] },
|
|
393
|
+
],
|
|
394
|
+
formInitialValues: { name: 'Acme', legalName: '' },
|
|
395
|
+
hiddenGroupIds: ['profile'],
|
|
396
|
+
onSubmit,
|
|
397
|
+
})
|
|
398
|
+
expect(fieldIds(container)).toEqual(['name'])
|
|
399
|
+
|
|
400
|
+
submitForm(container)
|
|
401
|
+
|
|
402
|
+
await waitFor(() => expect(onSubmit).toHaveBeenCalled())
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
it('still blocks submit when that required field is also placed in a visible group', async () => {
|
|
406
|
+
const onSubmit = jest.fn()
|
|
407
|
+
|
|
408
|
+
const { container } = renderForm({
|
|
409
|
+
formFields: [{ id: 'legalName', label: 'Legal name', type: 'text', required: true }],
|
|
410
|
+
formGroups: [
|
|
411
|
+
{ id: 'details', title: 'Details', column: 1, fields: ['legalName'] },
|
|
412
|
+
{ id: 'profile', title: 'Profile', column: 1, fields: ['legalName'] },
|
|
413
|
+
],
|
|
414
|
+
formInitialValues: { legalName: '' },
|
|
415
|
+
hiddenGroupIds: ['profile'],
|
|
416
|
+
onSubmit,
|
|
417
|
+
})
|
|
418
|
+
expect(fieldIds(container)).toEqual(['legalName'])
|
|
419
|
+
|
|
420
|
+
submitForm(container)
|
|
421
|
+
|
|
422
|
+
await waitFor(() => {
|
|
423
|
+
expect(container.querySelector('[data-crud-field-id="legalName"]')).toBeTruthy()
|
|
424
|
+
})
|
|
425
|
+
expect(onSubmit).not.toHaveBeenCalled()
|
|
426
|
+
})
|
|
427
|
+
})
|
|
428
|
+
})
|
|
@@ -127,7 +127,6 @@ import {
|
|
|
127
127
|
Reply,
|
|
128
128
|
RotateCcw,
|
|
129
129
|
Ruler,
|
|
130
|
-
Search,
|
|
131
130
|
Send,
|
|
132
131
|
Settings,
|
|
133
132
|
Shapes,
|
|
@@ -300,7 +299,6 @@ export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
|
|
|
300
299
|
'reply': Reply,
|
|
301
300
|
'rotate-ccw': RotateCcw,
|
|
302
301
|
'ruler': Ruler,
|
|
303
|
-
'search': Search,
|
|
304
302
|
'send': Send,
|
|
305
303
|
'settings': Settings,
|
|
306
304
|
'shapes': Shapes,
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { renderHook } from '@testing-library/react'
|
|
6
|
+
import { useComposeSendOperation } from '../useMessageComposeOperations'
|
|
7
|
+
import type { MessageTypeItem } from '../message-composer.types'
|
|
8
|
+
|
|
9
|
+
const messageType: MessageTypeItem = {
|
|
10
|
+
type: 'note',
|
|
11
|
+
module: 'messages',
|
|
12
|
+
labelKey: 'messages.types.note',
|
|
13
|
+
icon: 'message',
|
|
14
|
+
allowReply: true,
|
|
15
|
+
allowForward: true,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const t = (_key: string, fallback: string) => fallback
|
|
19
|
+
|
|
20
|
+
function buildParams(overrides: Partial<Parameters<typeof useComposeSendOperation>[0]> = {}) {
|
|
21
|
+
return {
|
|
22
|
+
t,
|
|
23
|
+
messageType: 'note',
|
|
24
|
+
createableMessageTypes: [messageType],
|
|
25
|
+
priority: 'normal' as const,
|
|
26
|
+
visibility: 'internal' as const,
|
|
27
|
+
externalEmail: '',
|
|
28
|
+
recipientIds: ['user-1'],
|
|
29
|
+
subject: 'Subject',
|
|
30
|
+
body: 'Body',
|
|
31
|
+
bodyFormat: 'text' as const,
|
|
32
|
+
sendViaEmail: false,
|
|
33
|
+
contextObject: null,
|
|
34
|
+
defaultValues: undefined,
|
|
35
|
+
contextActionOptions: [],
|
|
36
|
+
normalizedRequiredActionMode: 'none' as const,
|
|
37
|
+
shouldShowContextActions: false,
|
|
38
|
+
contextActionRequired: false,
|
|
39
|
+
contextActionType: '',
|
|
40
|
+
...overrides,
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('useComposeSendOperation payload sendViaEmail', () => {
|
|
45
|
+
it('preserves an explicit false toggle for a public message', () => {
|
|
46
|
+
const { result } = renderHook(() =>
|
|
47
|
+
useComposeSendOperation(
|
|
48
|
+
buildParams({ visibility: 'public', externalEmail: 'user@example.com', sendViaEmail: false }),
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
const request = result.current.buildRequest({ attachmentIds: [] })
|
|
53
|
+
expect(request.payload.sendViaEmail).toBe(false)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('preserves an explicit true toggle for a public message', () => {
|
|
57
|
+
const { result } = renderHook(() =>
|
|
58
|
+
useComposeSendOperation(
|
|
59
|
+
buildParams({ visibility: 'public', externalEmail: 'user@example.com', sendViaEmail: true }),
|
|
60
|
+
),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
const request = result.current.buildRequest({ attachmentIds: [] })
|
|
64
|
+
expect(request.payload.sendViaEmail).toBe(true)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('preserves the toggle for an internal message', () => {
|
|
68
|
+
const { result } = renderHook(() =>
|
|
69
|
+
useComposeSendOperation(buildParams({ visibility: 'internal', sendViaEmail: false })),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
const request = result.current.buildRequest({ attachmentIds: [] })
|
|
73
|
+
expect(request.payload.sendViaEmail).toBe(false)
|
|
74
|
+
})
|
|
75
|
+
})
|
|
@@ -142,7 +142,7 @@ function buildComposePayload(
|
|
|
142
142
|
sourceEntityId: sourceEntityId ?? undefined,
|
|
143
143
|
objects: contextObjects,
|
|
144
144
|
attachmentIds: input.attachmentIds.length > 0 ? input.attachmentIds : undefined,
|
|
145
|
-
sendViaEmail:
|
|
145
|
+
sendViaEmail: params.sendViaEmail,
|
|
146
146
|
isDraft: input.isDraft,
|
|
147
147
|
}
|
|
148
148
|
}
|