@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
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
jest.mock('@open-mercato/shared/lib/i18n/context', () => ({
|
|
4
4
|
useT: () => (_key: string, fallback: string) => fallback,
|
|
5
|
+
useOptionalLocale: () => undefined,
|
|
5
6
|
}))
|
|
6
7
|
|
|
7
8
|
jest.mock('@open-mercato/shared/lib/phone', () => ({
|
|
@@ -22,7 +23,15 @@ jest.mock('@open-mercato/shared/lib/phone', () => ({
|
|
|
22
23
|
|
|
23
24
|
import * as React from 'react'
|
|
24
25
|
import { fireEvent, render, screen } from '@testing-library/react'
|
|
25
|
-
import {
|
|
26
|
+
import type { TranslateFn } from '@open-mercato/shared/lib/i18n/context'
|
|
27
|
+
import {
|
|
28
|
+
PHONE_COUNTRIES,
|
|
29
|
+
PhoneNumberField,
|
|
30
|
+
buildPhoneCountryOptions,
|
|
31
|
+
phoneCountryLabelKey,
|
|
32
|
+
resolvePhoneCountryLabel,
|
|
33
|
+
type PhoneCountry,
|
|
34
|
+
} from '../PhoneNumberField'
|
|
26
35
|
|
|
27
36
|
function PhoneFieldHarness(props: Partial<React.ComponentProps<typeof PhoneNumberField>>) {
|
|
28
37
|
const [value, setValue] = React.useState<string | undefined>(undefined)
|
|
@@ -148,6 +157,88 @@ describe('PhoneNumberField initial country detection', () => {
|
|
|
148
157
|
})
|
|
149
158
|
})
|
|
150
159
|
|
|
160
|
+
const englishOnly: TranslateFn = (key, fallback) => (typeof fallback === 'string' ? fallback : key)
|
|
161
|
+
|
|
162
|
+
describe('PhoneNumberField country name localization', () => {
|
|
163
|
+
const poland = PHONE_COUNTRIES.find((country) => country.iso2 === 'PL') as PhoneCountry
|
|
164
|
+
|
|
165
|
+
it('builds the translation key from the ISO 3166-1 alpha-2 code', () => {
|
|
166
|
+
expect(phoneCountryLabelKey('pl')).toBe('ui.inputs.phoneNumberField.countryName.PL')
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('prefers a locale-file entry over every other source', () => {
|
|
170
|
+
const withOverride: TranslateFn = (key, fallback) =>
|
|
171
|
+
key === 'ui.inputs.phoneNumberField.countryName.PL' ? 'Rzeczpospolita Polska' : englishOnly(key, fallback)
|
|
172
|
+
|
|
173
|
+
expect(resolvePhoneCountryLabel(poland, withOverride, 'pl')).toBe('Rzeczpospolita Polska')
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('localizes an untranslated country through the active locale region data', () => {
|
|
177
|
+
expect(resolvePhoneCountryLabel(poland, englishOnly, 'pl')).toBe('Polska')
|
|
178
|
+
expect(resolvePhoneCountryLabel(poland, englishOnly, 'de')).toBe('Polen')
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('falls back to the English label when no locale is in scope', () => {
|
|
182
|
+
expect(resolvePhoneCountryLabel(poland, englishOnly, undefined)).toBe('Poland')
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('keeps the curated English dictionary names under an English locale', () => {
|
|
186
|
+
const antigua = PHONE_COUNTRIES.find((country) => country.iso2 === 'AG') as PhoneCountry
|
|
187
|
+
|
|
188
|
+
expect(resolvePhoneCountryLabel(poland, englishOnly, 'en')).toBe('Poland')
|
|
189
|
+
// CLDR spells this "Antigua & Barbuda"; the dictionary wins so existing
|
|
190
|
+
// English copy is never silently rewritten.
|
|
191
|
+
expect(antigua.label).toBe('Antigua and Barbuda')
|
|
192
|
+
expect(resolvePhoneCountryLabel(antigua, englishOnly, 'en')).toBe('Antigua and Barbuda')
|
|
193
|
+
expect(resolvePhoneCountryLabel(antigua, englishOnly, 'en-GB')).toBe('Antigua and Barbuda')
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('falls back to the English label for a code the runtime cannot name', () => {
|
|
197
|
+
const unassigned: PhoneCountry = { iso2: 'QQ', dialCode: '+999', label: 'Nowhere', flag: '' }
|
|
198
|
+
const malformed: PhoneCountry = { iso2: 'QQQ', dialCode: '+999', label: 'Nowhere', flag: '' }
|
|
199
|
+
|
|
200
|
+
expect(resolvePhoneCountryLabel(unassigned, englishOnly, 'pl')).toBe('Nowhere')
|
|
201
|
+
expect(resolvePhoneCountryLabel(malformed, englishOnly, 'pl')).toBe('Nowhere')
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
describe('PhoneNumberField country option list', () => {
|
|
206
|
+
it('localizes the built-in list and re-sorts it for the active locale', () => {
|
|
207
|
+
const options = buildPhoneCountryOptions(undefined, englishOnly, 'pl')
|
|
208
|
+
|
|
209
|
+
expect(options).toHaveLength(PHONE_COUNTRIES.length)
|
|
210
|
+
expect(options.find((option) => option.country.iso2 === 'DE')?.label).toBe('Niemcy')
|
|
211
|
+
|
|
212
|
+
const labels = options.map((option) => option.label)
|
|
213
|
+
const sorted = [...labels].sort((a, b) => a.localeCompare(b, 'pl', { sensitivity: 'base' }))
|
|
214
|
+
expect(labels).toEqual(sorted)
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('keeps a caller-supplied list authoritative in both label and order', () => {
|
|
218
|
+
const supplied: PhoneCountry[] = [
|
|
219
|
+
{ iso2: 'DE', dialCode: '+49', label: 'Deutschland', flag: '🇩🇪' },
|
|
220
|
+
{ iso2: 'PL', dialCode: '+48', label: 'Rzeczpospolita', flag: '🇵🇱' },
|
|
221
|
+
]
|
|
222
|
+
|
|
223
|
+
expect(buildPhoneCountryOptions(supplied, englishOnly, 'pl')).toEqual([
|
|
224
|
+
{ country: supplied[0], label: 'Deutschland' },
|
|
225
|
+
{ country: supplied[1], label: 'Rzeczpospolita' },
|
|
226
|
+
])
|
|
227
|
+
})
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
describe('PhoneNumberField option list under an unusable locale', () => {
|
|
231
|
+
it('falls back to English names and English collation instead of throwing', () => {
|
|
232
|
+
const options = buildPhoneCountryOptions(undefined, englishOnly, 'not a locale tag')
|
|
233
|
+
|
|
234
|
+
expect(options.find((option) => option.country.iso2 === 'DE')?.label).toBe('Germany')
|
|
235
|
+
|
|
236
|
+
const labels = options.map((option) => option.label)
|
|
237
|
+
const sorted = [...labels].sort((a, b) => a.localeCompare(b, 'en', { sensitivity: 'base' }))
|
|
238
|
+
expect(labels).toEqual(sorted)
|
|
239
|
+
})
|
|
240
|
+
})
|
|
241
|
+
|
|
151
242
|
describe('PhoneNumberField source options', () => {
|
|
152
243
|
it('uses the translated country picker name and optional country asset', () => {
|
|
153
244
|
render(<PhoneFieldHarness size={40} countryLabel="Numer kierunkowy kraju" renderCountryIcon={country => <img src={`${country.iso2}.svg`} alt="" />} />)
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
} from 'react'
|
|
11
11
|
import type { CustomerAuthResult } from '@open-mercato/shared/modules/customer-auth'
|
|
12
12
|
import type { CustomerAuthContext } from '@open-mercato/shared/modules/customer-auth'
|
|
13
|
+
import { navigateWithPageReload } from '@open-mercato/shared/lib/navigation/pageReload'
|
|
13
14
|
import { apiCall } from '../backend/utils/apiCall'
|
|
14
15
|
|
|
15
16
|
/* ------------------------------------------------------------------ */
|
|
@@ -155,7 +156,7 @@ export function PortalProvider({ orgSlug, children, initialAuth, initialTenant }
|
|
|
155
156
|
}
|
|
156
157
|
profileEnrichedRef.current = false
|
|
157
158
|
setAuthState({ user: null, roles: [], resolvedFeatures: [], isPortalAdmin: false, loading: false, error: null })
|
|
158
|
-
|
|
159
|
+
navigateWithPageReload(`/${orgSlug}/portal/login`)
|
|
159
160
|
}, [orgSlug])
|
|
160
161
|
|
|
161
162
|
/* ---- Tenant state ---- */
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as React from 'react'
|
|
6
|
+
import { act, render, waitFor } from '@testing-library/react'
|
|
7
|
+
import type { CustomerAuthContext } from '@open-mercato/shared/modules/customer-auth'
|
|
8
|
+
import { PortalProvider, usePortalContext } from '../PortalContext'
|
|
9
|
+
import { useCustomerAuth } from '../hooks/useCustomerAuth'
|
|
10
|
+
|
|
11
|
+
const apiCallMock = jest.fn()
|
|
12
|
+
const navigateWithPageReloadMock = jest.fn()
|
|
13
|
+
const routerPushMock = jest.fn()
|
|
14
|
+
const routerReplaceMock = jest.fn()
|
|
15
|
+
|
|
16
|
+
jest.mock('../../backend/utils/apiCall', () => ({
|
|
17
|
+
apiCall: (...args: unknown[]) => apiCallMock(...args),
|
|
18
|
+
}))
|
|
19
|
+
|
|
20
|
+
jest.mock('@open-mercato/shared/lib/navigation/pageReload', () => ({
|
|
21
|
+
navigateWithPageReload: (...args: unknown[]) => navigateWithPageReloadMock(...args),
|
|
22
|
+
}))
|
|
23
|
+
|
|
24
|
+
jest.mock('next/navigation', () => ({
|
|
25
|
+
useRouter: () => ({ push: routerPushMock, replace: routerReplaceMock }),
|
|
26
|
+
}))
|
|
27
|
+
|
|
28
|
+
function makeAuth(): CustomerAuthContext {
|
|
29
|
+
return {
|
|
30
|
+
sub: 'user-1',
|
|
31
|
+
email: 'user@example.com',
|
|
32
|
+
displayName: 'User One',
|
|
33
|
+
tenantId: 't-1',
|
|
34
|
+
orgId: 'o-1',
|
|
35
|
+
resolvedFeatures: ['portal.view'],
|
|
36
|
+
customerEntityId: null,
|
|
37
|
+
personEntityId: null,
|
|
38
|
+
} as unknown as CustomerAuthContext
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The portal layout that computes `authenticated` and the customer session sits above the
|
|
43
|
+
* `[...slug]` segment, so it only re-runs on a document load. Both logout paths must therefore
|
|
44
|
+
* leave the authenticated side with a full page load; a client-side navigation would strand
|
|
45
|
+
* authenticated chrome over the login page. See `navigateWithPageReload`.
|
|
46
|
+
*/
|
|
47
|
+
describe('portal logout crosses the auth boundary with a full page load', () => {
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
apiCallMock.mockReset()
|
|
50
|
+
apiCallMock.mockResolvedValue({ ok: true, status: 200, result: { ok: true } })
|
|
51
|
+
navigateWithPageReloadMock.mockReset()
|
|
52
|
+
routerPushMock.mockReset()
|
|
53
|
+
routerReplaceMock.mockReset()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('useCustomerAuth().logout', () => {
|
|
57
|
+
function LogoutProbe({ orgSlug }: { orgSlug?: string }) {
|
|
58
|
+
const { logout } = useCustomerAuth(orgSlug)
|
|
59
|
+
return <button type="button" onClick={() => { void logout() }}>logout</button>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
it('reloads the page onto the org-scoped login route', async () => {
|
|
63
|
+
const { getByText } = render(<LogoutProbe orgSlug="acme" />)
|
|
64
|
+
|
|
65
|
+
await act(async () => { getByText('logout').click() })
|
|
66
|
+
|
|
67
|
+
await waitFor(() => {
|
|
68
|
+
expect(navigateWithPageReloadMock).toHaveBeenCalledWith('/acme/portal/login')
|
|
69
|
+
})
|
|
70
|
+
expect(routerPushMock).not.toHaveBeenCalled()
|
|
71
|
+
expect(routerReplaceMock).not.toHaveBeenCalled()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('reloads the page onto the unscoped login route when no org slug is known', async () => {
|
|
75
|
+
const { getByText } = render(<LogoutProbe />)
|
|
76
|
+
|
|
77
|
+
await act(async () => { getByText('logout').click() })
|
|
78
|
+
|
|
79
|
+
await waitFor(() => {
|
|
80
|
+
expect(navigateWithPageReloadMock).toHaveBeenCalledWith('/portal/login')
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('still reloads when the logout request fails', async () => {
|
|
85
|
+
apiCallMock.mockRejectedValue(new Error('[internal] network down'))
|
|
86
|
+
const { getByText } = render(<LogoutProbe orgSlug="acme" />)
|
|
87
|
+
|
|
88
|
+
await act(async () => { getByText('logout').click() })
|
|
89
|
+
|
|
90
|
+
await waitFor(() => {
|
|
91
|
+
expect(navigateWithPageReloadMock).toHaveBeenCalledWith('/acme/portal/login')
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
describe('PortalContext logout', () => {
|
|
97
|
+
function ContextLogoutProbe() {
|
|
98
|
+
const { auth } = usePortalContext()
|
|
99
|
+
return <button type="button" onClick={() => { void auth.logout() }}>logout</button>
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
it('reloads the page onto the login route', async () => {
|
|
103
|
+
const { getByText } = render(
|
|
104
|
+
<PortalProvider orgSlug="acme" initialAuth={makeAuth()}>
|
|
105
|
+
<ContextLogoutProbe />
|
|
106
|
+
</PortalProvider>,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
await act(async () => { getByText('logout').click() })
|
|
110
|
+
|
|
111
|
+
await waitFor(() => {
|
|
112
|
+
expect(navigateWithPageReloadMock).toHaveBeenCalledWith('/acme/portal/login')
|
|
113
|
+
})
|
|
114
|
+
expect(routerPushMock).not.toHaveBeenCalled()
|
|
115
|
+
expect(routerReplaceMock).not.toHaveBeenCalled()
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
})
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
import { useCallback, useEffect, useState } from 'react'
|
|
3
|
-
import { useRouter } from 'next/navigation'
|
|
4
3
|
import type { CustomerUser, CustomerRole, CustomerAuthResult } from '@open-mercato/shared/modules/customer-auth'
|
|
4
|
+
import { navigateWithPageReload } from '@open-mercato/shared/lib/navigation/pageReload'
|
|
5
5
|
import { apiCall } from '../../backend/utils/apiCall'
|
|
6
6
|
|
|
7
7
|
export type { CustomerUser, CustomerRole, CustomerAuthResult }
|
|
@@ -27,7 +27,6 @@ export type { CustomerUser, CustomerRole, CustomerAuthResult }
|
|
|
27
27
|
* ```
|
|
28
28
|
*/
|
|
29
29
|
export function useCustomerAuth(orgSlug?: string) {
|
|
30
|
-
const router = useRouter()
|
|
31
30
|
const [state, setState] = useState<CustomerAuthResult>({
|
|
32
31
|
user: null,
|
|
33
32
|
roles: [],
|
|
@@ -90,8 +89,8 @@ export function useCustomerAuth(orgSlug?: string) {
|
|
|
90
89
|
loading: false,
|
|
91
90
|
error: null,
|
|
92
91
|
})
|
|
93
|
-
|
|
94
|
-
}, [
|
|
92
|
+
navigateWithPageReload(loginPath)
|
|
93
|
+
}, [loginPath])
|
|
95
94
|
|
|
96
95
|
return { ...state, logout }
|
|
97
96
|
}
|