@open-mercato/ui 0.8.1-develop.7261.1.5d42353f61 → 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.
- 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/messages/useMessageComposeOperations.js +1 -1
- package/dist/backend/messages/useMessageComposeOperations.js.map +2 -2
- package/dist/backend/notifications/NotificationDispatcher.js +3 -1
- package/dist/backend/notifications/NotificationDispatcher.js.map +2 -2
- package/dist/backend/notifications/localizeNotification.js +11 -0
- package/dist/backend/notifications/localizeNotification.js.map +7 -0
- package/dist/backend/utils/useAutoDiscoveredFields.js +5 -4
- package/dist/backend/utils/useAutoDiscoveredFields.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/messages/__tests__/useMessageComposeOperations.test.tsx +75 -0
- package/src/backend/messages/useMessageComposeOperations.ts +1 -1
- package/src/backend/notifications/NotificationDispatcher.ts +3 -1
- package/src/backend/notifications/__tests__/NotificationDispatcher.localize.test.tsx +118 -0
- package/src/backend/notifications/localizeNotification.ts +35 -0
- package/src/backend/utils/__tests__/useAutoDiscoveredFields.injectedColumns.test.tsx +76 -0
- package/src/backend/utils/useAutoDiscoveredFields.ts +8 -4
|
@@ -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:
|
|
145
|
+
sendViaEmail: params.sendViaEmail,
|
|
146
146
|
isDraft: input.isDraft,
|
|
147
147
|
}
|
|
148
148
|
}
|
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
} from '@open-mercato/shared/modules/notifications/handler'
|
|
12
12
|
import type { NotificationDto } from '@open-mercato/shared/modules/notifications/types'
|
|
13
13
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
14
|
+
import { localizeNotificationCopy } from './localizeNotification'
|
|
14
15
|
|
|
15
16
|
const logger = createLogger('ui').child({ component: 'notifications' })
|
|
16
17
|
|
|
@@ -106,6 +107,7 @@ class NotificationDispatcher {
|
|
|
106
107
|
|
|
107
108
|
let handlerRan = false
|
|
108
109
|
let hadFeatureBlockedHandler = false
|
|
110
|
+
const localized = localizeNotificationCopy(notification, runtime.t)
|
|
109
111
|
|
|
110
112
|
for (const entry of sortedEntries) {
|
|
111
113
|
const handler = entry.handler
|
|
@@ -117,7 +119,7 @@ class NotificationDispatcher {
|
|
|
117
119
|
if (this.shouldDebounce(handler, notification.id)) continue
|
|
118
120
|
handlerRan = true
|
|
119
121
|
try {
|
|
120
|
-
void Promise.resolve(handler.handle(
|
|
122
|
+
void Promise.resolve(handler.handle(localized, context))
|
|
121
123
|
} catch (error) {
|
|
122
124
|
logger.error('Notification handler failed', { handlerId: handler.id, err: error })
|
|
123
125
|
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { NotificationDto } from '@open-mercato/shared/modules/notifications/types'
|
|
2
|
+
import { registerNotificationHandlers } from '@open-mercato/shared/lib/notifications/handler-registry'
|
|
3
|
+
import {
|
|
4
|
+
__resetNotificationDispatcherForTests,
|
|
5
|
+
dispatchNotificationHandlers,
|
|
6
|
+
subscribeNotificationEffects,
|
|
7
|
+
} from '../NotificationDispatcher'
|
|
8
|
+
|
|
9
|
+
const flash = jest.fn()
|
|
10
|
+
jest.mock('@open-mercato/ui/backend/FlashMessages', () => ({
|
|
11
|
+
flash: (...args: unknown[]) => flash(...args),
|
|
12
|
+
}))
|
|
13
|
+
|
|
14
|
+
const TITLE_KEY = 'communication_channels.notifications.channel_requires_reauth.title'
|
|
15
|
+
const BODY_KEY = 'communication_channels.notifications.channel_requires_reauth.body'
|
|
16
|
+
|
|
17
|
+
const dictionary: Record<string, string> = {
|
|
18
|
+
[TITLE_KEY]: 'Channel needs reconnection',
|
|
19
|
+
[BODY_KEY]: 'Authentication expired. Reconnect {{channel}} to resume sending and receiving messages.',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function translate(key: string, fallback?: string, variables?: Record<string, string>): string {
|
|
23
|
+
const template = dictionary[key] ?? fallback ?? key
|
|
24
|
+
return Object.entries(variables ?? {}).reduce(
|
|
25
|
+
(text, [name, value]) => text.replaceAll(`{{${name}}}`, value),
|
|
26
|
+
template,
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function makeNotification(overrides: Partial<NotificationDto> = {}): NotificationDto {
|
|
31
|
+
return {
|
|
32
|
+
id: 'n1',
|
|
33
|
+
type: 'communication_channels.channel.requires_reauth',
|
|
34
|
+
title: TITLE_KEY,
|
|
35
|
+
body: BODY_KEY,
|
|
36
|
+
titleKey: TITLE_KEY,
|
|
37
|
+
bodyKey: BODY_KEY,
|
|
38
|
+
bodyVariables: { channel: 'Sales inbox' },
|
|
39
|
+
severity: 'warning',
|
|
40
|
+
status: 'unread',
|
|
41
|
+
actions: [],
|
|
42
|
+
createdAt: new Date().toISOString(),
|
|
43
|
+
...overrides,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function runtime(t?: typeof translate) {
|
|
48
|
+
return {
|
|
49
|
+
features: [],
|
|
50
|
+
currentPath: '/backend',
|
|
51
|
+
t,
|
|
52
|
+
refreshNotifications: jest.fn(),
|
|
53
|
+
navigate: jest.fn(),
|
|
54
|
+
markAsRead: jest.fn(async () => {}),
|
|
55
|
+
dismiss: jest.fn(async () => {}),
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe('NotificationDispatcher — handler copy localization (#6099)', () => {
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
__resetNotificationDispatcherForTests()
|
|
62
|
+
flash.mockReset()
|
|
63
|
+
registerNotificationHandlers([
|
|
64
|
+
{
|
|
65
|
+
moduleId: 'test',
|
|
66
|
+
handlers: [
|
|
67
|
+
{
|
|
68
|
+
id: 'test.toast',
|
|
69
|
+
notificationType: 'communication_channels.channel.requires_reauth',
|
|
70
|
+
handle(notification, context) {
|
|
71
|
+
context.toast({ title: notification.title, body: notification.body ?? undefined, severity: 'warning' })
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
afterAll(() => {
|
|
80
|
+
registerNotificationHandlers([])
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('hands handlers the translated title and body (with variables) instead of the dictionary keys', () => {
|
|
84
|
+
dispatchNotificationHandlers([makeNotification()], runtime(translate))
|
|
85
|
+
|
|
86
|
+
expect(flash).toHaveBeenCalledTimes(1)
|
|
87
|
+
expect(flash).toHaveBeenCalledWith(
|
|
88
|
+
'Channel needs reconnection: Authentication expired. Reconnect Sales inbox to resume sending and receiving messages.',
|
|
89
|
+
'warning',
|
|
90
|
+
)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('leaves a notification without keys untouched', () => {
|
|
94
|
+
dispatchNotificationHandlers(
|
|
95
|
+
[makeNotification({ title: 'Plain title', body: 'Plain body', titleKey: null, bodyKey: null, bodyVariables: null })],
|
|
96
|
+
runtime(translate),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
expect(flash).toHaveBeenCalledWith('Plain title: Plain body', 'warning')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('falls back to the stored copy when no translator is available', () => {
|
|
103
|
+
dispatchNotificationHandlers([makeNotification()], runtime(undefined))
|
|
104
|
+
|
|
105
|
+
expect(flash).toHaveBeenCalledWith(`${TITLE_KEY}: ${BODY_KEY}`, 'warning')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('does not rewrite the DTO seen by effect subscribers', () => {
|
|
109
|
+
const seen: NotificationDto[] = []
|
|
110
|
+
const unsubscribe = subscribeNotificationEffects('communication_channels.*', (item) => seen.push(item))
|
|
111
|
+
|
|
112
|
+
dispatchNotificationHandlers([makeNotification({ id: 'n2' })], runtime(translate))
|
|
113
|
+
unsubscribe()
|
|
114
|
+
|
|
115
|
+
expect(seen).toHaveLength(1)
|
|
116
|
+
expect(seen[0].title).toBe(TITLE_KEY)
|
|
117
|
+
})
|
|
118
|
+
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { NotificationDto } from '@open-mercato/shared/modules/notifications/types'
|
|
2
|
+
|
|
3
|
+
export type NotificationTranslate = (
|
|
4
|
+
key: string,
|
|
5
|
+
fallback?: string,
|
|
6
|
+
variables?: Record<string, string>,
|
|
7
|
+
) => string
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Give reactive handlers display copy instead of dictionary keys.
|
|
11
|
+
*
|
|
12
|
+
* `buildBaseNotificationFields` persists `title` / `body` as the type's
|
|
13
|
+
* `titleKey` / `bodyKey`; the bell panel translates them through
|
|
14
|
+
* `NotificationItem`, but a handler that forwards `notification.title` to
|
|
15
|
+
* `context.toast(...)` would show the raw key. Resolving here, once per
|
|
16
|
+
* dispatch, keeps every handler's toast/popup localized without each one
|
|
17
|
+
* having to remember the translator (#6099).
|
|
18
|
+
*
|
|
19
|
+
* Without a translator (dispatch outside an `I18nProvider`) the DTO is
|
|
20
|
+
* returned untouched, so the previous behaviour is the fallback.
|
|
21
|
+
*/
|
|
22
|
+
export function localizeNotificationCopy(
|
|
23
|
+
notification: NotificationDto,
|
|
24
|
+
t: NotificationTranslate | undefined,
|
|
25
|
+
): NotificationDto {
|
|
26
|
+
if (!t) return notification
|
|
27
|
+
const title = notification.titleKey
|
|
28
|
+
? t(notification.titleKey, notification.title, notification.titleVariables ?? undefined)
|
|
29
|
+
: notification.title
|
|
30
|
+
const body = notification.bodyKey
|
|
31
|
+
? t(notification.bodyKey, notification.body ?? notification.bodyKey, notification.bodyVariables ?? undefined)
|
|
32
|
+
: notification.body
|
|
33
|
+
if (title === notification.title && body === notification.body) return notification
|
|
34
|
+
return { ...notification, title, body }
|
|
35
|
+
}
|