@open-mercato/ui 0.7.1-develop.7153.1.7145d295e6 → 0.7.1-develop.7170.1.d95074d7ba
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/dist/backend/CrudForm.js +45 -21
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/DataTable.js +7 -6
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/SectionHeader.js +17 -8
- package/dist/backend/SectionHeader.js.map +2 -2
- package/dist/backend/injection/InjectedField.js +55 -9
- package/dist/backend/injection/InjectedField.js.map +2 -2
- package/dist/backend/utils/nav.js +34 -4
- package/dist/backend/utils/nav.js.map +2 -2
- package/dist/primitives/date-format.js +68 -1
- package/dist/primitives/date-format.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/CrudForm.tsx +40 -9
- package/src/backend/DataTable.tsx +11 -15
- package/src/backend/SectionHeader.tsx +28 -5
- package/src/backend/__tests__/CrudForm.localeNumber.test.tsx +159 -0
- package/src/backend/__tests__/CrudForm.validation.test.tsx +1 -1
- package/src/backend/__tests__/DataTable.dateCells.test.tsx +87 -0
- package/src/backend/__tests__/SectionHeader.test.tsx +105 -0
- package/src/backend/__tests__/nav-utils.test.ts +180 -0
- package/src/backend/injection/InjectedField.tsx +69 -9
- package/src/backend/injection/__tests__/InjectedField.localeNumber.test.tsx +94 -0
- package/src/backend/utils/nav.ts +75 -5
- package/src/primitives/__tests__/date-format.test.ts +185 -0
- package/src/primitives/date-format.ts +145 -0
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
2
|
buildAdminNav,
|
|
3
|
+
buildProfileSections,
|
|
3
4
|
buildSettingsSections,
|
|
4
5
|
computeSettingsPathPrefixes,
|
|
5
6
|
convertToSectionNavGroups,
|
|
7
|
+
mergeSectionsWithDiscovered,
|
|
6
8
|
type AdminNavItem,
|
|
9
|
+
type SettingsSection,
|
|
7
10
|
} from '../utils/nav'
|
|
8
11
|
|
|
9
12
|
describe('settings navigation helpers', () => {
|
|
@@ -342,3 +345,180 @@ describe('settings navigation helpers', () => {
|
|
|
342
345
|
])
|
|
343
346
|
})
|
|
344
347
|
})
|
|
348
|
+
|
|
349
|
+
describe('profile navigation helpers', () => {
|
|
350
|
+
const profileEntry = (overrides: Partial<AdminNavItem>): AdminNavItem => ({
|
|
351
|
+
group: 'Account',
|
|
352
|
+
groupId: 'profile.sections.account',
|
|
353
|
+
groupKey: 'profile.sections.account',
|
|
354
|
+
groupDefaultName: 'Account',
|
|
355
|
+
title: 'Notification Preferences',
|
|
356
|
+
defaultTitle: 'Notification Preferences',
|
|
357
|
+
titleKey: 'notifications.preferences.pageTitle',
|
|
358
|
+
href: '/backend/profile/notification-preferences',
|
|
359
|
+
enabled: true,
|
|
360
|
+
order: 30,
|
|
361
|
+
pageContext: 'profile',
|
|
362
|
+
...overrides,
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
const accountBaseline = (): SettingsSection[] => [
|
|
366
|
+
{
|
|
367
|
+
id: 'profile.sections.account',
|
|
368
|
+
label: 'Account',
|
|
369
|
+
labelKey: 'profile.sections.account',
|
|
370
|
+
order: 1,
|
|
371
|
+
items: [
|
|
372
|
+
{
|
|
373
|
+
id: 'change-password',
|
|
374
|
+
label: 'Change Password',
|
|
375
|
+
labelKey: 'auth.changePassword.title',
|
|
376
|
+
href: '/backend/profile/change-password',
|
|
377
|
+
order: 1,
|
|
378
|
+
},
|
|
379
|
+
],
|
|
380
|
+
},
|
|
381
|
+
]
|
|
382
|
+
|
|
383
|
+
it('includes only profile-context entries', () => {
|
|
384
|
+
const entries: AdminNavItem[] = [
|
|
385
|
+
profileEntry({}),
|
|
386
|
+
profileEntry({
|
|
387
|
+
group: 'System',
|
|
388
|
+
groupId: 'settings.sections.system',
|
|
389
|
+
groupKey: 'settings.sections.system',
|
|
390
|
+
title: 'Audit Logs',
|
|
391
|
+
defaultTitle: 'Audit Logs',
|
|
392
|
+
href: '/backend/audit-logs',
|
|
393
|
+
pageContext: 'settings',
|
|
394
|
+
}),
|
|
395
|
+
profileEntry({
|
|
396
|
+
group: 'Customers',
|
|
397
|
+
groupId: 'customers.nav.group',
|
|
398
|
+
groupKey: undefined,
|
|
399
|
+
title: 'People',
|
|
400
|
+
defaultTitle: 'People',
|
|
401
|
+
href: '/backend/customers/people',
|
|
402
|
+
pageContext: undefined,
|
|
403
|
+
}),
|
|
404
|
+
]
|
|
405
|
+
|
|
406
|
+
const sections = buildProfileSections(entries, { 'profile.sections.account': 1 })
|
|
407
|
+
|
|
408
|
+
expect(sections).toHaveLength(1)
|
|
409
|
+
expect(sections[0].id).toBe('profile.sections.account')
|
|
410
|
+
expect(sections[0].items.map((item) => item.href)).toEqual([
|
|
411
|
+
'/backend/profile/notification-preferences',
|
|
412
|
+
])
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
it('folds discovered profile pages into the matching baseline section, ordered by weight', () => {
|
|
416
|
+
const discovered = buildProfileSections(
|
|
417
|
+
[
|
|
418
|
+
profileEntry({}),
|
|
419
|
+
profileEntry({
|
|
420
|
+
title: 'My communication channels',
|
|
421
|
+
defaultTitle: 'My communication channels',
|
|
422
|
+
titleKey: 'communication_channels.profile.title',
|
|
423
|
+
href: '/backend/profile/communication-channels',
|
|
424
|
+
order: 20,
|
|
425
|
+
}),
|
|
426
|
+
],
|
|
427
|
+
{ 'profile.sections.account': 1 },
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
const merged = mergeSectionsWithDiscovered(accountBaseline(), discovered)
|
|
431
|
+
|
|
432
|
+
expect(merged).toHaveLength(1)
|
|
433
|
+
expect(merged[0].items.map((item) => item.href)).toEqual([
|
|
434
|
+
'/backend/profile/change-password',
|
|
435
|
+
'/backend/profile/communication-channels',
|
|
436
|
+
'/backend/profile/notification-preferences',
|
|
437
|
+
])
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
it('keeps the baseline entry when a discovered page repeats its href', () => {
|
|
441
|
+
const discovered = buildProfileSections(
|
|
442
|
+
[
|
|
443
|
+
profileEntry({
|
|
444
|
+
title: 'Password',
|
|
445
|
+
defaultTitle: 'Password',
|
|
446
|
+
titleKey: undefined,
|
|
447
|
+
href: '/backend/profile/change-password',
|
|
448
|
+
order: 99,
|
|
449
|
+
}),
|
|
450
|
+
],
|
|
451
|
+
{ 'profile.sections.account': 1 },
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
const merged = mergeSectionsWithDiscovered(accountBaseline(), discovered)
|
|
455
|
+
|
|
456
|
+
expect(merged[0].items).toHaveLength(1)
|
|
457
|
+
expect(merged[0].items[0].labelKey).toBe('auth.changePassword.title')
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
it('appends a section a module declares under its own group id', () => {
|
|
461
|
+
const discovered = buildProfileSections(
|
|
462
|
+
[
|
|
463
|
+
profileEntry({
|
|
464
|
+
group: 'Security',
|
|
465
|
+
groupId: 'security.profile.section',
|
|
466
|
+
groupKey: 'security.profile.section',
|
|
467
|
+
title: 'Security & MFA',
|
|
468
|
+
defaultTitle: 'Security & MFA',
|
|
469
|
+
titleKey: 'security.profile.pageTitle',
|
|
470
|
+
href: '/backend/profile/security',
|
|
471
|
+
order: 10,
|
|
472
|
+
}),
|
|
473
|
+
],
|
|
474
|
+
{ 'profile.sections.account': 1 },
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
const merged = mergeSectionsWithDiscovered(accountBaseline(), discovered)
|
|
478
|
+
|
|
479
|
+
expect(merged.map((section) => section.id)).toEqual([
|
|
480
|
+
'profile.sections.account',
|
|
481
|
+
'security.profile.section',
|
|
482
|
+
])
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
it('leaves the baseline untouched when nothing is discovered', () => {
|
|
486
|
+
const baseline = accountBaseline()
|
|
487
|
+
|
|
488
|
+
const merged = mergeSectionsWithDiscovered(baseline, [])
|
|
489
|
+
|
|
490
|
+
expect(merged).toEqual(baseline)
|
|
491
|
+
expect(merged[0]).not.toBe(baseline[0])
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
// The baseline is a module-level constant (`profileSections`) reused across every request, so
|
|
495
|
+
// sorting a nested `children` array in place would permanently reorder shared state.
|
|
496
|
+
it('sorts nested children into a copy rather than reordering the baseline array', () => {
|
|
497
|
+
const children = [
|
|
498
|
+
{ id: 'mfa', label: 'MFA', href: '/backend/profile/security/mfa', order: 20 },
|
|
499
|
+
{ id: 'sessions', label: 'Sessions', href: '/backend/profile/security/sessions', order: 10 },
|
|
500
|
+
]
|
|
501
|
+
const baseline: SettingsSection[] = [
|
|
502
|
+
{
|
|
503
|
+
id: 'profile.sections.account',
|
|
504
|
+
label: 'Account',
|
|
505
|
+
labelKey: 'profile.sections.account',
|
|
506
|
+
order: 1,
|
|
507
|
+
items: [
|
|
508
|
+
{
|
|
509
|
+
id: 'security',
|
|
510
|
+
label: 'Security',
|
|
511
|
+
href: '/backend/profile/security',
|
|
512
|
+
order: 1,
|
|
513
|
+
children,
|
|
514
|
+
},
|
|
515
|
+
],
|
|
516
|
+
},
|
|
517
|
+
]
|
|
518
|
+
|
|
519
|
+
const merged = mergeSectionsWithDiscovered(baseline, [])
|
|
520
|
+
|
|
521
|
+
expect(merged[0].items[0].children?.map((child) => child.id)).toEqual(['sessions', 'mfa'])
|
|
522
|
+
expect(children.map((child) => child.id)).toEqual(['mfa', 'sessions'])
|
|
523
|
+
})
|
|
524
|
+
})
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
import * as React from 'react'
|
|
4
4
|
import type { InjectionFieldDefinition, FieldContext } from '@open-mercato/shared/modules/widgets/injection'
|
|
5
5
|
import { evaluateInjectedVisibility } from './visibility-utils'
|
|
6
|
-
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
6
|
+
import { useT, useOptionalLocale } from '@open-mercato/shared/lib/i18n/context'
|
|
7
|
+
import { parseLocaleNumber } from '@open-mercato/shared/lib/number'
|
|
7
8
|
import { Input } from '../../primitives/input'
|
|
8
9
|
import { Checkbox } from '../../primitives/checkbox'
|
|
9
10
|
import { Textarea } from '../../primitives/textarea'
|
|
@@ -182,24 +183,83 @@ export function InjectedField({ field, value, onChange, context, formData, readO
|
|
|
182
183
|
)
|
|
183
184
|
}
|
|
184
185
|
|
|
186
|
+
if (field.type === 'number') {
|
|
187
|
+
return (
|
|
188
|
+
<div className="space-y-2" data-crud-field-id={field.id}>
|
|
189
|
+
<Label htmlFor={field.id}>{label}</Label>
|
|
190
|
+
<NumberFieldInput
|
|
191
|
+
id={field.id}
|
|
192
|
+
value={value}
|
|
193
|
+
disabled={disabled}
|
|
194
|
+
onChange={(next) => onChange(field.id, next)}
|
|
195
|
+
/>
|
|
196
|
+
</div>
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
|
|
185
200
|
return (
|
|
186
201
|
<div className="space-y-2" data-crud-field-id={field.id}>
|
|
187
202
|
<Label htmlFor={field.id}>{label}</Label>
|
|
188
203
|
<Input
|
|
189
204
|
id={field.id}
|
|
190
|
-
type={field.type === '
|
|
205
|
+
type={field.type === 'date' ? 'date' : 'text'}
|
|
191
206
|
value={typeof value === 'string' || typeof value === 'number' ? String(value) : ''}
|
|
192
207
|
disabled={disabled}
|
|
193
|
-
onChange={(event) =>
|
|
194
|
-
if (field.type === 'number') {
|
|
195
|
-
onChange(field.id, event.target.value === '' ? undefined : Number(event.target.value))
|
|
196
|
-
return
|
|
197
|
-
}
|
|
198
|
-
onChange(field.id, event.target.value)
|
|
199
|
-
}}
|
|
208
|
+
onChange={(event) => onChange(field.id, event.target.value)}
|
|
200
209
|
/>
|
|
201
210
|
</div>
|
|
202
211
|
)
|
|
203
212
|
}
|
|
204
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Buffers the raw string the user is typing so a value that is not YET a number —
|
|
216
|
+
* `110,` on the way to `110,70` — does not clear the box. Renders as text rather than
|
|
217
|
+
* type="number" because a type="number" value is sanitized against the BROWSER locale,
|
|
218
|
+
* which discards the separator the application locale displays (issue #5552).
|
|
219
|
+
*/
|
|
220
|
+
function NumberFieldInput({
|
|
221
|
+
id,
|
|
222
|
+
value,
|
|
223
|
+
disabled,
|
|
224
|
+
onChange,
|
|
225
|
+
}: {
|
|
226
|
+
id: string
|
|
227
|
+
value: unknown
|
|
228
|
+
disabled?: boolean
|
|
229
|
+
onChange: (value: number | undefined) => void
|
|
230
|
+
}) {
|
|
231
|
+
const locale = useOptionalLocale()
|
|
232
|
+
const serializedValue = typeof value === 'string' || typeof value === 'number' ? String(value) : ''
|
|
233
|
+
const [local, setLocal] = React.useState<string>(serializedValue)
|
|
234
|
+
const isFocusedRef = React.useRef(false)
|
|
235
|
+
|
|
236
|
+
React.useEffect(() => {
|
|
237
|
+
if (!isFocusedRef.current) setLocal(serializedValue)
|
|
238
|
+
}, [serializedValue])
|
|
239
|
+
|
|
240
|
+
return (
|
|
241
|
+
<Input
|
|
242
|
+
id={id}
|
|
243
|
+
type="text"
|
|
244
|
+
inputMode="decimal"
|
|
245
|
+
// A text input is eligible for autofill and spellcheck; a number field is not.
|
|
246
|
+
autoComplete="off"
|
|
247
|
+
spellCheck={false}
|
|
248
|
+
value={local}
|
|
249
|
+
disabled={disabled}
|
|
250
|
+
onFocus={() => {
|
|
251
|
+
isFocusedRef.current = true
|
|
252
|
+
}}
|
|
253
|
+
onBlur={() => {
|
|
254
|
+
isFocusedRef.current = false
|
|
255
|
+
}}
|
|
256
|
+
onChange={(event) => {
|
|
257
|
+
const raw = event.target.value
|
|
258
|
+
setLocal(raw)
|
|
259
|
+
onChange(raw === '' ? undefined : (parseLocaleNumber(raw, locale) ?? undefined))
|
|
260
|
+
}}
|
|
261
|
+
/>
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
|
|
205
265
|
export default InjectedField
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
import { fireEvent } from '@testing-library/react'
|
|
4
|
+
import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
|
|
5
|
+
import type { FieldContext, InjectionFieldDefinition } from '@open-mercato/shared/modules/widgets/injection'
|
|
6
|
+
import { InjectedField } from '../InjectedField'
|
|
7
|
+
|
|
8
|
+
const numberField: InjectionFieldDefinition = {
|
|
9
|
+
id: 'amount',
|
|
10
|
+
type: 'number',
|
|
11
|
+
label: 'Amount',
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const context = {} as FieldContext
|
|
15
|
+
|
|
16
|
+
// A stateful host, because that is how the field is really used: the value it reports
|
|
17
|
+
// flows straight back in as its `value` prop, so a transient parse failure that cleared
|
|
18
|
+
// the box would make the field impossible to type into.
|
|
19
|
+
function Harness({ onChange }: { onChange: (id: string, value: unknown) => void }) {
|
|
20
|
+
const [value, setValue] = React.useState<unknown>('')
|
|
21
|
+
return (
|
|
22
|
+
<InjectedField
|
|
23
|
+
field={numberField}
|
|
24
|
+
value={value}
|
|
25
|
+
onChange={(id, next) => {
|
|
26
|
+
setValue(next)
|
|
27
|
+
onChange(id, next)
|
|
28
|
+
}}
|
|
29
|
+
context={context}
|
|
30
|
+
formData={{}}
|
|
31
|
+
/>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The locale is pinned to one that does NOT use a dot decimal separator; an en-US pin
|
|
36
|
+
// would pass on the buggy implementation too.
|
|
37
|
+
function renderField(locale: string, onChange: (id: string, value: unknown) => void) {
|
|
38
|
+
const { container } = renderWithProviders(<Harness onChange={onChange} />, { locale })
|
|
39
|
+
const input = container.querySelector('input')
|
|
40
|
+
expect(input).not.toBeNull()
|
|
41
|
+
return input as HTMLInputElement
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('InjectedField number inputs accept the locale decimal separator (issue #5552)', () => {
|
|
45
|
+
it('does not render a type="number" input, whose value the browser sanitizes per BROWSER locale', () => {
|
|
46
|
+
const input = renderField('pl', () => {})
|
|
47
|
+
expect(input.getAttribute('type')).toBe('text')
|
|
48
|
+
expect(input.getAttribute('inputMode')).toBe('decimal')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('parses a comma-typed value under a Polish locale', () => {
|
|
52
|
+
const onChange = jest.fn()
|
|
53
|
+
const input = renderField('pl', onChange)
|
|
54
|
+
|
|
55
|
+
fireEvent.change(input, { target: { value: '110,70' } })
|
|
56
|
+
|
|
57
|
+
expect(onChange).toHaveBeenCalledWith('amount', 110.7)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('still parses a dot-typed value, and reports a cleared field as undefined', () => {
|
|
61
|
+
const onChange = jest.fn()
|
|
62
|
+
const input = renderField('pl', onChange)
|
|
63
|
+
|
|
64
|
+
fireEvent.change(input, { target: { value: '2.5' } })
|
|
65
|
+
expect(onChange).toHaveBeenLastCalledWith('amount', 2.5)
|
|
66
|
+
|
|
67
|
+
fireEvent.change(input, { target: { value: '' } })
|
|
68
|
+
expect(onChange).toHaveBeenLastCalledWith('amount', undefined)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('reports an unparseable value as undefined rather than NaN', () => {
|
|
72
|
+
const onChange = jest.fn()
|
|
73
|
+
const input = renderField('pl', onChange)
|
|
74
|
+
|
|
75
|
+
fireEvent.change(input, { target: { value: 'abc' } })
|
|
76
|
+
|
|
77
|
+
expect(onChange).toHaveBeenLastCalledWith('amount', undefined)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('keeps a half-typed decimal in the box on the way to a complete one', () => {
|
|
81
|
+
const onChange = jest.fn()
|
|
82
|
+
const input = renderField('pl', onChange)
|
|
83
|
+
|
|
84
|
+
fireEvent.focus(input)
|
|
85
|
+
// `110,` is not yet a number, but clearing the box here would make the value
|
|
86
|
+
// impossible to finish typing in a controlled host.
|
|
87
|
+
fireEvent.change(input, { target: { value: '110,' } })
|
|
88
|
+
expect(input.value).toBe('110,')
|
|
89
|
+
|
|
90
|
+
fireEvent.change(input, { target: { value: '110,70' } })
|
|
91
|
+
expect(input.value).toBe('110,70')
|
|
92
|
+
expect(onChange).toHaveBeenLastCalledWith('amount', 110.7)
|
|
93
|
+
})
|
|
94
|
+
})
|
package/src/backend/utils/nav.ts
CHANGED
|
@@ -113,18 +113,19 @@ function legacySettingsSectionSlug(groupLabel: string): string {
|
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
/**
|
|
116
|
-
* Groups the
|
|
116
|
+
* Groups the nav entries declaring `pageContext` into ordered sections for that context's sidebar.
|
|
117
117
|
*
|
|
118
118
|
* `sectionOrder` is keyed by the untranslated group id (`AdminNavItem.groupId`, i.e. the page's
|
|
119
119
|
* `pageGroupKey` when it declares one) — the same convention the main sidebar's `defaultGroupOrder`
|
|
120
120
|
* follows. Keying off the rendered label instead made every non-English deployment miss its weights
|
|
121
121
|
* and fall back to the catch-all bucket (#4843).
|
|
122
122
|
*/
|
|
123
|
-
|
|
123
|
+
function buildContextSections(
|
|
124
124
|
entries: AdminNavItem[],
|
|
125
|
-
sectionOrder: Record<string, number
|
|
125
|
+
sectionOrder: Record<string, number>,
|
|
126
|
+
pageContext: 'settings' | 'profile'
|
|
126
127
|
): SettingsSection[] {
|
|
127
|
-
const
|
|
128
|
+
const contextItems = entries.filter(e => e.pageContext === pageContext)
|
|
128
129
|
|
|
129
130
|
const sectionMap = new Map<string, SettingsSection>()
|
|
130
131
|
|
|
@@ -142,7 +143,7 @@ export function buildSettingsSections(
|
|
|
142
143
|
}
|
|
143
144
|
}
|
|
144
145
|
|
|
145
|
-
for (const item of
|
|
146
|
+
for (const item of contextItems) {
|
|
146
147
|
const sectionId = item.groupId
|
|
147
148
|
const order = sectionOrder[sectionId] ?? sectionOrder[legacySettingsSectionSlug(item.group)] ?? 999
|
|
148
149
|
|
|
@@ -175,6 +176,75 @@ export function buildSettingsSections(
|
|
|
175
176
|
return sections
|
|
176
177
|
}
|
|
177
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Groups the settings-context nav entries into ordered sections for the settings sidebar.
|
|
181
|
+
*/
|
|
182
|
+
export function buildSettingsSections(
|
|
183
|
+
entries: AdminNavItem[],
|
|
184
|
+
sectionOrder: Record<string, number>
|
|
185
|
+
): SettingsSection[] {
|
|
186
|
+
return buildContextSections(entries, sectionOrder, 'settings')
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Groups the profile-context nav entries into ordered sections for the profile sidebar.
|
|
191
|
+
*
|
|
192
|
+
* The profile sidebar used to render a hard-coded list, so per-user pages contributed by other
|
|
193
|
+
* modules (notification preferences, communication channels) were reachable from the profile
|
|
194
|
+
* dropdown but missing from the sidebar (#5594). Deriving them from `pageContext: 'profile'` keeps
|
|
195
|
+
* both surfaces in sync, including for third-party modules.
|
|
196
|
+
*/
|
|
197
|
+
export function buildProfileSections(
|
|
198
|
+
entries: AdminNavItem[],
|
|
199
|
+
sectionOrder: Record<string, number>
|
|
200
|
+
): SettingsSection[] {
|
|
201
|
+
return buildContextSections(entries, sectionOrder, 'profile')
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Folds discovered sections into a statically declared baseline, keyed by section id and item href.
|
|
206
|
+
*
|
|
207
|
+
* The baseline carries entries that route discovery cannot see — a `navHidden` page such as
|
|
208
|
+
* `/backend/profile/change-password` never reaches `buildAdminNav`'s output — so it wins on
|
|
209
|
+
* conflicts and keeps its declared position, while discovered items extend the matching section or
|
|
210
|
+
* append a new one.
|
|
211
|
+
*
|
|
212
|
+
* The baseline is never mutated: sections, their item arrays and any nested `children` are copied
|
|
213
|
+
* before sorting, because callers pass module-level constants that outlive a single request.
|
|
214
|
+
*/
|
|
215
|
+
export function mergeSectionsWithDiscovered(
|
|
216
|
+
baseline: SettingsSection[],
|
|
217
|
+
discovered: SettingsSection[]
|
|
218
|
+
): SettingsSection[] {
|
|
219
|
+
const merged = baseline.map((section) => ({ ...section, items: [...section.items] }))
|
|
220
|
+
const byId = new Map(merged.map((section) => [section.id, section]))
|
|
221
|
+
const knownHrefs = new Set(merged.flatMap((section) => section.items.map((item) => item.href)))
|
|
222
|
+
|
|
223
|
+
for (const section of discovered) {
|
|
224
|
+
const items = section.items.filter((item) => !knownHrefs.has(item.href))
|
|
225
|
+
items.forEach((item) => knownHrefs.add(item.href))
|
|
226
|
+
if (items.length === 0) continue
|
|
227
|
+
const existing = byId.get(section.id)
|
|
228
|
+
if (existing) {
|
|
229
|
+
existing.items.push(...items)
|
|
230
|
+
continue
|
|
231
|
+
}
|
|
232
|
+
const added = { ...section, items }
|
|
233
|
+
byId.set(added.id, added)
|
|
234
|
+
merged.push(added)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const sortItems = (items: SettingsSectionItem[]): SettingsSectionItem[] =>
|
|
238
|
+
[...items]
|
|
239
|
+
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
|
240
|
+
.map((item) => (item.children?.length ? { ...item, children: sortItems(item.children) } : item))
|
|
241
|
+
|
|
242
|
+
merged.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
|
243
|
+
for (const section of merged) section.items = sortItems(section.items)
|
|
244
|
+
|
|
245
|
+
return merged
|
|
246
|
+
}
|
|
247
|
+
|
|
178
248
|
export function computeSettingsPathPrefixes(sections: SettingsSection[]): string[] {
|
|
179
249
|
const prefixes = new Set<string>()
|
|
180
250
|
const visitItem = (item: SettingsSectionItem) => {
|