@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.
@@ -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
  }
@@ -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(notification, context))
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
+ }