@open-mercato/ui 0.8.1-develop.7263.1.bbc6db3437 → 0.8.1-develop.7266.1.8e520bbe03

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.
@@ -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
+ })
@@ -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: publicMessage ? true : params.sendViaEmail,
145
+ sendViaEmail: params.sendViaEmail,
146
146
  isDraft: input.isDraft,
147
147
  }
148
148
  }