@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.
@@ -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 === 'number' ? 'number' : field.type === 'date' ? 'date' : 'text'}
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
+ })
@@ -113,18 +113,19 @@ function legacySettingsSectionSlug(groupLabel: string): string {
113
113
  }
114
114
 
115
115
  /**
116
- * Groups the settings-context nav entries into ordered sections.
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
- export function buildSettingsSections(
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 settingsItems = entries.filter(e => e.pageContext === 'settings')
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 settingsItems) {
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) => {