@open-mercato/ui 0.8.1-develop.7275.1.f772b944d4 → 0.8.1-develop.7294.1.0ef99fc518
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/DataTable.js +30 -11
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/detail/InlineEditors.js +10 -6
- package/dist/backend/detail/InlineEditors.js.map +2 -2
- package/dist/backend/inputs/PhoneNumberField.js +59 -5
- package/dist/backend/inputs/PhoneNumberField.js.map +2 -2
- package/dist/portal/PortalContext.js +2 -1
- package/dist/portal/PortalContext.js.map +2 -2
- package/dist/portal/hooks/useCustomerAuth.js +3 -4
- package/dist/portal/hooks/useCustomerAuth.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/DataTable.tsx +72 -13
- package/src/backend/__tests__/DataTable.metaHiddenLateColumns.test.tsx +94 -4
- package/src/backend/__tests__/DataTable.saveViewApi.test.tsx +129 -1
- package/src/backend/detail/InlineEditors.tsx +18 -6
- package/src/backend/detail/__tests__/InlineEditors.test.tsx +52 -0
- package/src/backend/inputs/PhoneNumberField.tsx +121 -6
- package/src/backend/inputs/__tests__/PhoneNumberField.test.tsx +92 -1
- package/src/portal/PortalContext.tsx +2 -1
- package/src/portal/__tests__/logoutPageReload.test.tsx +118 -0
- package/src/portal/hooks/useCustomerAuth.ts +3 -4
|
@@ -6,7 +6,7 @@ import { render, screen, waitFor } from '@testing-library/react'
|
|
|
6
6
|
import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
|
|
7
7
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
8
8
|
import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
|
|
9
|
-
import { DataTable } from '../DataTable'
|
|
9
|
+
import { DataTable, type DataTableViewApi } from '../DataTable'
|
|
10
10
|
|
|
11
11
|
jest.mock('next/navigation', () => ({
|
|
12
12
|
useRouter: () => ({ push: jest.fn(), replace: jest.fn(), prefetch: jest.fn(), refresh: jest.fn() }),
|
|
@@ -37,7 +37,11 @@ const COLUMNS_WITH_CUSTOM_FIELDS: ColumnDef<Row>[] = [
|
|
|
37
37
|
{ accessorKey: 'cf_active_note', header: 'Active note' },
|
|
38
38
|
]
|
|
39
39
|
|
|
40
|
-
function Harness({ columns, initialSettings }: {
|
|
40
|
+
function Harness({ columns, initialSettings, apiRef }: {
|
|
41
|
+
columns: ColumnDef<Row>[]
|
|
42
|
+
initialSettings?: unknown
|
|
43
|
+
apiRef?: React.MutableRefObject<DataTableViewApi | null>
|
|
44
|
+
}) {
|
|
41
45
|
const queryClient = React.useMemo(
|
|
42
46
|
() => new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }),
|
|
43
47
|
[],
|
|
@@ -48,6 +52,7 @@ function Harness({ columns, initialSettings }: { columns: ColumnDef<Row>[]; init
|
|
|
48
52
|
<DataTable
|
|
49
53
|
columns={columns as never}
|
|
50
54
|
data={ROWS as never}
|
|
55
|
+
viewApiRef={apiRef}
|
|
51
56
|
{...(initialSettings
|
|
52
57
|
? ({ perspective: { tableId: 'meta-hidden-late-columns', initialState: { initialSettings } } } as never)
|
|
53
58
|
: {})}
|
|
@@ -58,6 +63,17 @@ function Harness({ columns, initialSettings }: { columns: ColumnDef<Row>[]; init
|
|
|
58
63
|
}
|
|
59
64
|
|
|
60
65
|
describe('DataTable — meta.hidden on columns that arrive after the first render', () => {
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
// DataTable persists the applied view to localStorage and a cookie, and jsdom shares
|
|
68
|
+
// both across the cases in a file — clear them so a perspective set up by one case
|
|
69
|
+
// cannot hydrate into the next one and decide its columns.
|
|
70
|
+
window.localStorage.clear()
|
|
71
|
+
for (const entry of document.cookie.split(';')) {
|
|
72
|
+
const name = entry.split('=')[0]?.trim()
|
|
73
|
+
if (name) document.cookie = `${name}=; Max-Age=0; Path=/`
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
|
|
61
77
|
it('hides a meta.hidden column that only appears on a later render', async () => {
|
|
62
78
|
// The regression: the auto-hide pass used to be latched by a single boolean ref, so it
|
|
63
79
|
// ran on the first render — when no `cf_*` column existed yet — found nothing to hide,
|
|
@@ -91,8 +107,9 @@ describe('DataTable — meta.hidden on columns that arrive after the first rende
|
|
|
91
107
|
expect(screen.queryByText('Archived note')).toBeNull()
|
|
92
108
|
})
|
|
93
109
|
|
|
94
|
-
it('lets a stored perspective override the meta.hidden
|
|
95
|
-
// A saved perspective
|
|
110
|
+
it('lets a stored perspective override the meta.hidden default for a column it names', async () => {
|
|
111
|
+
// A saved perspective wins over the declared default — but only for the columns it
|
|
112
|
+
// actually names, which is what the two cases below pin down (#5117).
|
|
96
113
|
render(
|
|
97
114
|
<Harness
|
|
98
115
|
columns={COLUMNS_WITH_CUSTOM_FIELDS}
|
|
@@ -102,4 +119,77 @@ describe('DataTable — meta.hidden on columns that arrive after the first rende
|
|
|
102
119
|
|
|
103
120
|
await waitFor(() => expect(screen.getByText('Archived note')).toBeTruthy())
|
|
104
121
|
})
|
|
122
|
+
|
|
123
|
+
it('still applies meta.hidden to a column the stored perspective does not name', async () => {
|
|
124
|
+
// The regression from #5117: the pass used to be latched on "the stored view carried
|
|
125
|
+
// *some* visibility", so a view that only spoke about `name` silently suppressed the
|
|
126
|
+
// declared default of every other column. A view can only decide what it describes.
|
|
127
|
+
render(
|
|
128
|
+
<Harness
|
|
129
|
+
columns={COLUMNS_WITH_CUSTOM_FIELDS}
|
|
130
|
+
initialSettings={{ columnVisibility: { name: true } }}
|
|
131
|
+
/>,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
await waitFor(() => expect(screen.getByText('Active note')).toBeTruthy())
|
|
135
|
+
expect(screen.queryByText('Archived note')).toBeNull()
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('applies meta.hidden to a late column that a stored perspective could not have named', async () => {
|
|
139
|
+
// The reported scenario: the view was saved before the custom-field columns resolved,
|
|
140
|
+
// so it cannot carry a decision for them. They must still fall back to their declared
|
|
141
|
+
// default instead of appearing mid-hydration.
|
|
142
|
+
const { rerender } = render(
|
|
143
|
+
<Harness columns={BASE_COLUMNS} initialSettings={{ columnVisibility: { name: true } }} />,
|
|
144
|
+
)
|
|
145
|
+
await waitFor(() => expect(screen.getByText('Name')).toBeTruthy())
|
|
146
|
+
|
|
147
|
+
rerender(
|
|
148
|
+
<Harness columns={COLUMNS_WITH_CUSTOM_FIELDS} initialSettings={{ columnVisibility: { name: true } }} />,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
await waitFor(() => expect(screen.getByText('Active note')).toBeTruthy())
|
|
152
|
+
expect(screen.queryByText('Archived note')).toBeNull()
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('honours meta.hidden that a column def only gains on a later render', async () => {
|
|
156
|
+
// The per-column record must key on "a default was applied", not on "this id has been
|
|
157
|
+
// seen". Marking a column decided the first time it appears would strand a def whose
|
|
158
|
+
// `meta` resolves in a later wave — an injected column widget, say — as permanently
|
|
159
|
+
// undecided, and an undecided column renders visible.
|
|
160
|
+
const withoutMeta: ColumnDef<Row>[] = [
|
|
161
|
+
...BASE_COLUMNS,
|
|
162
|
+
{ accessorKey: 'cf_late_note', header: 'Late note' },
|
|
163
|
+
]
|
|
164
|
+
const withMeta: ColumnDef<Row>[] = [
|
|
165
|
+
...BASE_COLUMNS,
|
|
166
|
+
{ accessorKey: 'cf_late_note', header: 'Late note', meta: { hidden: true } },
|
|
167
|
+
]
|
|
168
|
+
const { rerender } = render(<Harness columns={withoutMeta} />)
|
|
169
|
+
await waitFor(() => expect(screen.getByText('Late note')).toBeTruthy())
|
|
170
|
+
|
|
171
|
+
rerender(<Harness columns={withMeta} />)
|
|
172
|
+
|
|
173
|
+
await waitFor(() => expect(screen.queryByText('Late note')).toBeNull())
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('describes every leaf column in the settings it hands back, including late arrivals', async () => {
|
|
177
|
+
// Root cause 1 of #5117: `getCurrentSettings` copied the sparse TanStack state, so a
|
|
178
|
+
// saved view stored no decision for columns that registered after the save and an
|
|
179
|
+
// absent key renders visible. The serialized map must be self-describing.
|
|
180
|
+
const apiRef = React.createRef<DataTableViewApi | null>() as React.MutableRefObject<DataTableViewApi | null>
|
|
181
|
+
const { rerender } = render(<Harness columns={BASE_COLUMNS} apiRef={apiRef} />)
|
|
182
|
+
await waitFor(() => expect(screen.getByText('Name')).toBeTruthy())
|
|
183
|
+
|
|
184
|
+
rerender(<Harness columns={COLUMNS_WITH_CUSTOM_FIELDS} apiRef={apiRef} />)
|
|
185
|
+
await waitFor(() => expect(screen.getByText('Active note')).toBeTruthy())
|
|
186
|
+
|
|
187
|
+
await waitFor(() => {
|
|
188
|
+
expect(apiRef.current?.getCurrentSettings().columnVisibility).toEqual({
|
|
189
|
+
name: true,
|
|
190
|
+
cf_archived_note: false,
|
|
191
|
+
cf_active_note: true,
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
})
|
|
105
195
|
})
|
|
@@ -140,6 +140,13 @@ const columns: ColumnDef<Row>[] = [
|
|
|
140
140
|
{ accessorKey: 'id', header: 'Id' },
|
|
141
141
|
]
|
|
142
142
|
|
|
143
|
+
// The shape the People / Companies / Deals lists actually render: a module column that
|
|
144
|
+
// declares its own default (`hidden: def.listVisible === false`) next to ordinary ones.
|
|
145
|
+
const COLUMNS_WITH_DECLARED_DEFAULT: ColumnDef<Row>[] = [
|
|
146
|
+
...columns,
|
|
147
|
+
{ accessorKey: 'cf_archived', header: 'Archived', meta: { hidden: true } } as ColumnDef<Row>,
|
|
148
|
+
]
|
|
149
|
+
|
|
143
150
|
type HarnessProps = {
|
|
144
151
|
apiRef?: React.MutableRefObject<DataTableViewApi | null>
|
|
145
152
|
onDirty?: (state: DataTableViewDirtyState) => void
|
|
@@ -149,6 +156,7 @@ type HarnessProps = {
|
|
|
149
156
|
savedViews?: PerspectivesIndexResponse['perspectives']
|
|
150
157
|
roleViews?: PerspectivesIndexResponse['rolePerspectives']
|
|
151
158
|
initialSettings?: PerspectiveSettings
|
|
159
|
+
tableColumns?: ColumnDef<Row>[]
|
|
152
160
|
/** `'granted'` seeds the permission check, `'denied'` seeds a refusal, `'pending'` leaves it in flight. */
|
|
153
161
|
perspectivesFeature?: 'granted' | 'denied' | 'pending'
|
|
154
162
|
}
|
|
@@ -162,6 +170,7 @@ function renderTable({
|
|
|
162
170
|
savedViews = [],
|
|
163
171
|
roleViews = [],
|
|
164
172
|
initialSettings,
|
|
173
|
+
tableColumns = columns,
|
|
165
174
|
perspectivesFeature = 'granted',
|
|
166
175
|
}: HarnessProps) {
|
|
167
176
|
const queryClient = new QueryClient({
|
|
@@ -183,7 +192,7 @@ function renderTable({
|
|
|
183
192
|
<QueryClientProvider client={queryClient}>
|
|
184
193
|
<I18nProvider locale="en" dict={{}}>
|
|
185
194
|
<DataTable<Row>
|
|
186
|
-
columns={
|
|
195
|
+
columns={tableColumns}
|
|
187
196
|
data={[]}
|
|
188
197
|
searchValue={searchValue}
|
|
189
198
|
onSearchChange={() => {}}
|
|
@@ -460,6 +469,125 @@ describe('DataTable public save-view API', () => {
|
|
|
460
469
|
await waitFor(() => expect(screen.getByTestId('save-view-trigger')).not.toBeDisabled())
|
|
461
470
|
})
|
|
462
471
|
|
|
472
|
+
it('describes every column in the settings it serializes, without reporting a change nobody made', async () => {
|
|
473
|
+
// #5117: the serialized visibility used to be a verbatim copy of the sparse TanStack
|
|
474
|
+
// state, so a saved view carried no decision for columns the user never toggled and an
|
|
475
|
+
// absent key renders visible. It is now seeded from the full leaf-column set — and the
|
|
476
|
+
// dense map must still compare equal to the sparse one the server stored, or every
|
|
477
|
+
// table with a saved view would report unsaved changes the moment it mounted.
|
|
478
|
+
const apiRef = React.createRef<DataTableViewApi | null>() as React.MutableRefObject<DataTableViewApi | null>
|
|
479
|
+
const states: DataTableViewDirtyState[] = []
|
|
480
|
+
renderTable({
|
|
481
|
+
apiRef,
|
|
482
|
+
onDirty: (state) => { states.push(state) },
|
|
483
|
+
initialSettings: { columnVisibility: { name: false } },
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
await waitFor(() => expect(apiRef.current).not.toBeNull())
|
|
487
|
+
expect(apiRef.current!.getCurrentSettings().columnVisibility).toEqual({ name: false, id: true })
|
|
488
|
+
|
|
489
|
+
await waitFor(() => expect(states.length).toBeGreaterThan(0))
|
|
490
|
+
expect(states.every((state) => !state.isDirty)).toBe(true)
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
it('stays clean on a table carrying a meta.hidden column the stored view never named', async () => {
|
|
494
|
+
// The case the density change can break, and the one the assertion above cannot see:
|
|
495
|
+
// its columns declare no default, so there is nothing for the live state to diverge on.
|
|
496
|
+
// Once the `meta.hidden` pass is allowed to run underneath a stored view it writes a
|
|
497
|
+
// `false` the server's sparse map does not carry, and `normalizeVisibility` keeps every
|
|
498
|
+
// `false` — so without folding the declared default into the baseline as well, every
|
|
499
|
+
// People / Companies / Deals view would mount showing "1 unsaved change" nobody made.
|
|
500
|
+
const apiRef = React.createRef<DataTableViewApi | null>() as React.MutableRefObject<DataTableViewApi | null>
|
|
501
|
+
const states: DataTableViewDirtyState[] = []
|
|
502
|
+
renderTable({
|
|
503
|
+
apiRef,
|
|
504
|
+
onDirty: (state) => { states.push(state) },
|
|
505
|
+
tableColumns: COLUMNS_WITH_DECLARED_DEFAULT,
|
|
506
|
+
initialSettings: { columnVisibility: { name: false } },
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
await waitFor(() => expect(apiRef.current).not.toBeNull())
|
|
510
|
+
await waitFor(() => expect(apiRef.current!.getCurrentSettings().columnVisibility)
|
|
511
|
+
.toEqual({ name: false, id: true, cf_archived: false }))
|
|
512
|
+
|
|
513
|
+
await waitFor(() => expect(states.length).toBeGreaterThan(0))
|
|
514
|
+
expect(states.every((state) => !state.isDirty)).toBe(true)
|
|
515
|
+
expect(apiRef.current!.getDirtyState().isDirty).toBe(false)
|
|
516
|
+
})
|
|
517
|
+
|
|
518
|
+
it('stays clean on first mount of a table with a meta.hidden column and no stored view at all', async () => {
|
|
519
|
+
// The case neither test above can see: with no `initialSettings`, the baseline is not
|
|
520
|
+
// seeded from `useState`'s initializer — it is seeded later by the mount-time effect
|
|
521
|
+
// from `currentViewSettings`, which closes over the render that *scheduled* the
|
|
522
|
+
// meta.hidden hide, not the one where it took effect. `setViewBaseline` must let the
|
|
523
|
+
// auto-hidden default win over that stale snapshot, or the baseline records the column
|
|
524
|
+
// visible while the live state (correctly) goes hidden on the next render, reporting a
|
|
525
|
+
// change nobody made the moment the table mounts.
|
|
526
|
+
const apiRef = React.createRef<DataTableViewApi | null>() as React.MutableRefObject<DataTableViewApi | null>
|
|
527
|
+
const states: DataTableViewDirtyState[] = []
|
|
528
|
+
renderTable({
|
|
529
|
+
apiRef,
|
|
530
|
+
onDirty: (state) => { states.push(state) },
|
|
531
|
+
tableColumns: COLUMNS_WITH_DECLARED_DEFAULT,
|
|
532
|
+
showSaveViewButton: true,
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
await waitFor(() => expect(apiRef.current).not.toBeNull())
|
|
536
|
+
await waitFor(() => expect(apiRef.current!.getCurrentSettings().columnVisibility)
|
|
537
|
+
.toEqual({ name: true, id: true, cf_archived: false }))
|
|
538
|
+
|
|
539
|
+
await waitFor(() => expect(states.length).toBeGreaterThan(0))
|
|
540
|
+
expect(states.every((state) => !state.isDirty)).toBe(true)
|
|
541
|
+
expect(apiRef.current!.getDirtyState().isDirty).toBe(false)
|
|
542
|
+
})
|
|
543
|
+
|
|
544
|
+
it('keeps a stored visibility decision for a column that has not registered yet', async () => {
|
|
545
|
+
// Serializing from the live column set alone drops any stored key whose column is not
|
|
546
|
+
// in the table — every `cf_*` decision, during the whole custom-field hydration window.
|
|
547
|
+
// Transiently that reports the view dirty; permanently, a save inside that window
|
|
548
|
+
// writes the user's "hide this custom field" away for good.
|
|
549
|
+
const apiRef = React.createRef<DataTableViewApi | null>() as React.MutableRefObject<DataTableViewApi | null>
|
|
550
|
+
const states: DataTableViewDirtyState[] = []
|
|
551
|
+
renderTable({
|
|
552
|
+
apiRef,
|
|
553
|
+
onDirty: (state) => { states.push(state) },
|
|
554
|
+
initialSettings: { columnVisibility: { name: false, cf_not_loaded_yet: false } },
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
await waitFor(() => expect(apiRef.current).not.toBeNull())
|
|
558
|
+
expect(apiRef.current!.getCurrentSettings().columnVisibility)
|
|
559
|
+
.toEqual({ name: false, cf_not_loaded_yet: false, id: true })
|
|
560
|
+
|
|
561
|
+
await waitFor(() => expect(states.length).toBeGreaterThan(0))
|
|
562
|
+
expect(states.every((state) => !state.isDirty)).toBe(true)
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
it('restores a declared default when the view that overrode it is switched away from', async () => {
|
|
566
|
+
// `applyPerspectiveSettings` re-seeds the per-column record and bumps the pass counter
|
|
567
|
+
// so the defaults are re-evaluated for the newly applied view. A view with no
|
|
568
|
+
// visibility of its own is exactly what "— No view —" applies, so this pins the clear
|
|
569
|
+
// path too: the column must go back to its declared default rather than staying
|
|
570
|
+
// visible for the rest of the session because an earlier view once un-hid it.
|
|
571
|
+
const showsHidden = {
|
|
572
|
+
...SAVED_VIEW,
|
|
573
|
+
id: 'persp-shows-hidden',
|
|
574
|
+
settings: { columnVisibility: { cf_archived: true } },
|
|
575
|
+
}
|
|
576
|
+
const saysNothing = { ...SAVED_VIEW, id: 'persp-says-nothing', settings: {} }
|
|
577
|
+
renderTable({
|
|
578
|
+
tableColumns: COLUMNS_WITH_DECLARED_DEFAULT,
|
|
579
|
+
savedViews: [showsHidden, saysNothing],
|
|
580
|
+
})
|
|
581
|
+
|
|
582
|
+
// The first saved view auto-activates on load, so the column starts overridden.
|
|
583
|
+
await waitFor(() => expect(screen.getByText('Archived')).toBeTruthy())
|
|
584
|
+
|
|
585
|
+
const activateSaysNothing = await screen.findByTestId('activate-view-persp-says-nothing')
|
|
586
|
+
await act(async () => { fireEvent.click(activateSaysNothing) })
|
|
587
|
+
|
|
588
|
+
await waitFor(() => expect(screen.queryByText('Archived')).toBeNull())
|
|
589
|
+
})
|
|
590
|
+
|
|
463
591
|
it('sends an unnamed save to the sidebar instead of inventing a name', async () => {
|
|
464
592
|
const { setSearchValue } = renderTable({ showSaveViewButton: true })
|
|
465
593
|
const trigger = await screen.findByTestId('save-view-trigger')
|
|
@@ -24,7 +24,19 @@ import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
|
24
24
|
|
|
25
25
|
const logger = createLogger('ui')
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
// Server validation errors arrive as stable i18n keys (e.g. `customers.people.form.*`), the
|
|
28
|
+
// same contract CrudForm resolves via translateValidationMessage; without this they render
|
|
29
|
+
// raw. `t(msg, msg)` is a no-op for already-localized strings.
|
|
30
|
+
function resolveInlineErrorMessage(
|
|
31
|
+
err: unknown,
|
|
32
|
+
fallbackMessage: string,
|
|
33
|
+
translate: (key: string, fallback: string) => string,
|
|
34
|
+
): string {
|
|
35
|
+
const raw = resolveRawInlineErrorMessage(err, fallbackMessage)
|
|
36
|
+
return translate(raw, raw)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function resolveRawInlineErrorMessage(err: unknown, fallbackMessage: string): string {
|
|
28
40
|
const { message, fieldErrors } = mapCrudServerErrorToFormErrors(err)
|
|
29
41
|
const firstFieldError = fieldErrors
|
|
30
42
|
? Object.values(fieldErrors).find((text) => typeof text === 'string' && text.trim().length)
|
|
@@ -223,11 +235,11 @@ export function InlineTextEditor({
|
|
|
223
235
|
await onSave(trimmed.length ? trimmed : null)
|
|
224
236
|
setEditingSafe(false)
|
|
225
237
|
} catch (err) {
|
|
226
|
-
setError(resolveInlineErrorMessage(err, fallbackError))
|
|
238
|
+
setError(resolveInlineErrorMessage(err, fallbackError, t))
|
|
227
239
|
} finally {
|
|
228
240
|
setSaving(false)
|
|
229
241
|
}
|
|
230
|
-
}, [draft, fallbackError, onSave, setEditingSafe, validator])
|
|
242
|
+
}, [draft, fallbackError, onSave, setEditingSafe, t, validator])
|
|
231
243
|
|
|
232
244
|
const interactiveProps: React.HTMLAttributes<HTMLDivElement> =
|
|
233
245
|
activateOnClick && !editing
|
|
@@ -363,7 +375,7 @@ export function InlineTextEditor({
|
|
|
363
375
|
await onSave(formatted.length ? formatted : null)
|
|
364
376
|
setEditingSafe(false)
|
|
365
377
|
} catch (err) {
|
|
366
|
-
setError(resolveInlineErrorMessage(err, fallbackError))
|
|
378
|
+
setError(resolveInlineErrorMessage(err, fallbackError, t))
|
|
367
379
|
} finally {
|
|
368
380
|
setSaving(false)
|
|
369
381
|
}
|
|
@@ -601,11 +613,11 @@ export function InlineMultilineEditor({
|
|
|
601
613
|
await onSave(trimmed.length ? trimmed : null)
|
|
602
614
|
setEditing(false)
|
|
603
615
|
} catch (err) {
|
|
604
|
-
setError(resolveInlineErrorMessage(err, fallbackError))
|
|
616
|
+
setError(resolveInlineErrorMessage(err, fallbackError, t))
|
|
605
617
|
} finally {
|
|
606
618
|
setSaving(false)
|
|
607
619
|
}
|
|
608
|
-
}, [adjustError, draft, fallbackError, onSave])
|
|
620
|
+
}, [adjustError, draft, fallbackError, onSave, t])
|
|
609
621
|
|
|
610
622
|
return (
|
|
611
623
|
<div className={containerClasses} onClick={handleInteractiveClick}>
|
|
@@ -137,3 +137,55 @@ describe('resolveSafeInlineUrlHref', () => {
|
|
|
137
137
|
},
|
|
138
138
|
)
|
|
139
139
|
})
|
|
140
|
+
|
|
141
|
+
describe('InlineTextEditor server validation errors are localized (#5925)', () => {
|
|
142
|
+
const EMAIL_INVALID_KEY = 'customers.people.form.primaryEmail.invalid'
|
|
143
|
+
const TRANSLATED = 'Wprowadź prawidłowy adres e-mail'
|
|
144
|
+
|
|
145
|
+
function renderEditorRejectingWith(err: unknown, dict: Record<string, string>) {
|
|
146
|
+
renderWithProviders(
|
|
147
|
+
<InlineTextEditor
|
|
148
|
+
label="Primary email"
|
|
149
|
+
value="ada@example.com"
|
|
150
|
+
emptyLabel="No email"
|
|
151
|
+
type="email"
|
|
152
|
+
onSave={jest.fn().mockRejectedValue(err)}
|
|
153
|
+
/>,
|
|
154
|
+
{ dict },
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
const buttons = document.querySelectorAll('[data-slot="button"]')
|
|
158
|
+
const editToggle = buttons[buttons.length - 1] as HTMLButtonElement
|
|
159
|
+
act(() => {
|
|
160
|
+
fireEvent.click(editToggle)
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function submit() {
|
|
165
|
+
const saveButton = screen.getByRole('button', { name: /Save|⌘/ })
|
|
166
|
+
await act(async () => {
|
|
167
|
+
fireEvent.click(saveButton)
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
it('renders the translation for a server field error carrying an i18n key', async () => {
|
|
172
|
+
const err = Object.assign(new Error('Invalid input'), {
|
|
173
|
+
details: [{ path: ['primaryEmail'], message: EMAIL_INVALID_KEY }],
|
|
174
|
+
})
|
|
175
|
+
renderEditorRejectingWith(err, { [EMAIL_INVALID_KEY]: TRANSLATED })
|
|
176
|
+
await submit()
|
|
177
|
+
|
|
178
|
+
expect(screen.getByText(TRANSLATED)).toBeInTheDocument()
|
|
179
|
+
expect(screen.queryByText(EMAIL_INVALID_KEY)).not.toBeInTheDocument()
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('leaves an already-localized server message untouched', async () => {
|
|
183
|
+
const err = Object.assign(new Error('Invalid input'), {
|
|
184
|
+
details: [{ path: ['primaryEmail'], message: 'Enter a valid email address' }],
|
|
185
|
+
})
|
|
186
|
+
renderEditorRejectingWith(err, { [EMAIL_INVALID_KEY]: TRANSLATED })
|
|
187
|
+
await submit()
|
|
188
|
+
|
|
189
|
+
expect(screen.getByText('Enter a valid email address')).toBeInTheDocument()
|
|
190
|
+
})
|
|
191
|
+
})
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import * as React from 'react'
|
|
4
|
-
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
4
|
+
import { useOptionalLocale, useT, type TranslateFn } from '@open-mercato/shared/lib/i18n/context'
|
|
5
5
|
import { extractPhoneDigits, validatePhoneNumber } from '@open-mercato/shared/lib/phone'
|
|
6
6
|
import { cn } from '@open-mercato/shared/lib/utils'
|
|
7
7
|
import {
|
|
@@ -23,7 +23,11 @@ export type PhoneCountry = {
|
|
|
23
23
|
iso2: string
|
|
24
24
|
/** International dial code (with `+`). */
|
|
25
25
|
dialCode: string
|
|
26
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* Human-readable country name (English). Used as the last-resort fallback
|
|
28
|
+
* when neither a locale file nor the platform's region data can name the
|
|
29
|
+
* country in the active locale.
|
|
30
|
+
*/
|
|
27
31
|
label: string
|
|
28
32
|
/** Emoji flag (no asset dependency). */
|
|
29
33
|
flag: string
|
|
@@ -311,6 +315,109 @@ export const PHONE_COUNTRIES: PhoneCountry[] = RAW_PHONE_COUNTRIES
|
|
|
311
315
|
.map((country) => ({ ...country, flag: iso2ToFlagEmoji(country.iso2) }))
|
|
312
316
|
.sort((a, b) => a.label.localeCompare(b.label, 'en', { sensitivity: 'base' }))
|
|
313
317
|
|
|
318
|
+
/**
|
|
319
|
+
* Translation key holding a country's display name, e.g.
|
|
320
|
+
* `ui.inputs.phoneNumberField.countryName.PL`. Locale files only need entries
|
|
321
|
+
* for the countries whose platform-provided name a deployment wants to
|
|
322
|
+
* change. This is a distinct namespace from
|
|
323
|
+
* `ui.inputs.phoneNumberField.country` (the picker's trigger label) so a
|
|
324
|
+
* nested locale file can hold both.
|
|
325
|
+
*/
|
|
326
|
+
export function phoneCountryLabelKey(iso2: string): string {
|
|
327
|
+
return `ui.inputs.phoneNumberField.countryName.${iso2.toUpperCase()}`
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const regionDisplayNamesByLocale = new Map<string, Intl.DisplayNames | null>()
|
|
331
|
+
|
|
332
|
+
function getRegionDisplayNames(locale: string): Intl.DisplayNames | null {
|
|
333
|
+
const cached = regionDisplayNamesByLocale.get(locale)
|
|
334
|
+
if (cached !== undefined) return cached
|
|
335
|
+
let displayNames: Intl.DisplayNames | null = null
|
|
336
|
+
try {
|
|
337
|
+
displayNames = new Intl.DisplayNames([locale], { type: 'region' })
|
|
338
|
+
} catch {
|
|
339
|
+
displayNames = null
|
|
340
|
+
}
|
|
341
|
+
regionDisplayNamesByLocale.set(locale, displayNames)
|
|
342
|
+
return displayNames
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Country name in `locale` from the platform's own region data, or `null` when
|
|
347
|
+
* the runtime cannot name that region (unknown code, trimmed ICU data).
|
|
348
|
+
*/
|
|
349
|
+
function regionDisplayName(iso2: string, locale: string | undefined): string | null {
|
|
350
|
+
if (!locale) return null
|
|
351
|
+
const code = iso2.toUpperCase()
|
|
352
|
+
if (!/^[A-Z]{2}$/.test(code)) return null
|
|
353
|
+
const displayNames = getRegionDisplayNames(locale)
|
|
354
|
+
if (!displayNames) return null
|
|
355
|
+
try {
|
|
356
|
+
const name = displayNames.of(code)
|
|
357
|
+
return name && name !== code ? name : null
|
|
358
|
+
} catch {
|
|
359
|
+
return null
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function isEnglishLocale(locale: string | undefined): boolean {
|
|
364
|
+
return !locale || locale.toLowerCase().split(/[-_]/)[0] === 'en'
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Display name for a built-in country in the active locale. A locale-file entry
|
|
369
|
+
* wins so a deployment can correct or override any name; otherwise the
|
|
370
|
+
* platform's region data localizes it; the English label is the last resort.
|
|
371
|
+
*
|
|
372
|
+
* English deliberately skips the region data: the dictionary already holds
|
|
373
|
+
* curated English names, and CLDR spells several of them differently
|
|
374
|
+
* ("Antigua & Barbuda" for "Antigua and Barbuda"), so consulting it would
|
|
375
|
+
* silently rewrite existing English copy for no gain.
|
|
376
|
+
*/
|
|
377
|
+
export function resolvePhoneCountryLabel(
|
|
378
|
+
country: PhoneCountry,
|
|
379
|
+
t: TranslateFn,
|
|
380
|
+
locale: string | undefined,
|
|
381
|
+
): string {
|
|
382
|
+
const translated = t(phoneCountryLabelKey(country.iso2), '')
|
|
383
|
+
if (translated) return translated
|
|
384
|
+
if (isEnglishLocale(locale)) return country.label
|
|
385
|
+
return regionDisplayName(country.iso2, locale) ?? country.label
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export type PhoneCountryOption = {
|
|
389
|
+
country: PhoneCountry
|
|
390
|
+
label: string
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Locale safe to hand to `localeCompare`. A tag `Intl` cannot construct would
|
|
395
|
+
* throw there just as it does in `getRegionDisplayNames`, so both fall back to
|
|
396
|
+
* English together.
|
|
397
|
+
*/
|
|
398
|
+
function resolveCollationLocale(locale: string | undefined): string {
|
|
399
|
+
if (!locale) return 'en'
|
|
400
|
+
return getRegionDisplayNames(locale) ? locale : 'en'
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Options rendered in the country dropdown. A caller-supplied list is
|
|
405
|
+
* authoritative — its labels and its order are preserved verbatim — while the
|
|
406
|
+
* built-in dictionary is localized and re-sorted for the active locale, since
|
|
407
|
+
* its module-level ordering is alphabetical by the English name.
|
|
408
|
+
*/
|
|
409
|
+
export function buildPhoneCountryOptions(
|
|
410
|
+
countries: PhoneCountry[] | undefined,
|
|
411
|
+
t: TranslateFn,
|
|
412
|
+
locale: string | undefined,
|
|
413
|
+
): PhoneCountryOption[] {
|
|
414
|
+
if (countries) return countries.map((country) => ({ country, label: country.label }))
|
|
415
|
+
const collationLocale = resolveCollationLocale(locale)
|
|
416
|
+
return PHONE_COUNTRIES
|
|
417
|
+
.map((country) => ({ country, label: resolvePhoneCountryLabel(country, t, locale) }))
|
|
418
|
+
.sort((a, b) => a.label.localeCompare(b.label, collationLocale, { sensitivity: 'base' }))
|
|
419
|
+
}
|
|
420
|
+
|
|
314
421
|
/**
|
|
315
422
|
* Sovereign/primary country that wins auto-detection for a calling code shared
|
|
316
423
|
* by several territories. US vs. Canada is the one irreducible ambiguity — both
|
|
@@ -386,7 +493,11 @@ export type PhoneNumberFieldProps = {
|
|
|
386
493
|
duplicateLinkLabel?: string
|
|
387
494
|
invalidLabel?: string
|
|
388
495
|
onDuplicateLookup?: (normalizedValue: string) => Promise<PhoneDuplicateMatch | null>
|
|
389
|
-
/**
|
|
496
|
+
/**
|
|
497
|
+
* Override the static country list (e.g. limit to specific markets, or supply
|
|
498
|
+
* names already translated by the host). Both the labels and the order of the
|
|
499
|
+
* supplied list are rendered verbatim.
|
|
500
|
+
*/
|
|
390
501
|
countries?: PhoneCountry[]
|
|
391
502
|
/** Initial country shown when `value` is empty / unparseable. Defaults to US. */
|
|
392
503
|
defaultCountryIso2?: string
|
|
@@ -422,6 +533,7 @@ export function PhoneNumberField({
|
|
|
422
533
|
renderCountryIcon,
|
|
423
534
|
}: PhoneNumberFieldProps) {
|
|
424
535
|
const t = useT()
|
|
536
|
+
const locale = useOptionalLocale()
|
|
425
537
|
const resolvedInvalidLabel = invalidLabel ?? t(
|
|
426
538
|
'ui.inputs.phoneNumberField.invalid',
|
|
427
539
|
'Enter a valid phone number with country code (e.g. +1 212 555 1234)'
|
|
@@ -435,7 +547,10 @@ export function PhoneNumberField({
|
|
|
435
547
|
'Open record'
|
|
436
548
|
)
|
|
437
549
|
const resolvedPlaceholder = placeholder ?? DEFAULT_PLACEHOLDER
|
|
438
|
-
const
|
|
550
|
+
const countryOptions = React.useMemo(
|
|
551
|
+
() => buildPhoneCountryOptions(countriesProp, t, locale),
|
|
552
|
+
[countriesProp, t, locale],
|
|
553
|
+
)
|
|
439
554
|
const fallbackCountry = React.useMemo(
|
|
440
555
|
() => (defaultCountryIso2 && findCountryByIso(defaultCountryIso2)) || DEFAULT_COUNTRY,
|
|
441
556
|
[defaultCountryIso2],
|
|
@@ -609,12 +724,12 @@ export function PhoneNumberField({
|
|
|
609
724
|
<span className={cn("text-sm text-foreground tabular-nums", disabled && "text-text-disabled")}>{country.dialCode}</span>
|
|
610
725
|
</SelectTrigger>
|
|
611
726
|
<SelectContent align="start">
|
|
612
|
-
{
|
|
727
|
+
{countryOptions.map(({ country: c, label }) => (
|
|
613
728
|
<SelectItem key={`${c.iso2}-${c.dialCode}`} value={c.iso2}>
|
|
614
729
|
<SelectItemLeading>
|
|
615
730
|
<span className="text-base leading-none">{c.flag}</span>
|
|
616
731
|
</SelectItemLeading>
|
|
617
|
-
<span className="flex-1 truncate">{
|
|
732
|
+
<span className="flex-1 truncate">{label}</span>
|
|
618
733
|
<span className="ml-2 text-xs text-muted-foreground tabular-nums">{c.dialCode}</span>
|
|
619
734
|
</SelectItem>
|
|
620
735
|
))}
|