@open-mercato/shared 0.8.1-develop.7274.1.21a0083828 → 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/.turbo/turbo-build.log +2 -1
- package/build.mjs +4 -1
- package/dist/lib/crud/errors.js +6 -1
- package/dist/lib/crud/errors.js.map +2 -2
- package/dist/lib/crud/factory.js +22 -13
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/custom-fields/kinds.js +113 -0
- package/dist/lib/custom-fields/kinds.js.map +7 -0
- package/dist/lib/encryption/indexDoc.js +12 -9
- package/dist/lib/encryption/indexDoc.js.map +2 -2
- package/dist/lib/i18n/server.js +1 -1
- package/dist/lib/i18n/server.js.map +2 -2
- package/dist/lib/location/countries.generated.js +265 -0
- package/dist/lib/location/countries.generated.js.map +7 -0
- package/dist/lib/location/countries.js +2 -13
- package/dist/lib/location/countries.js.map +2 -2
- package/dist/lib/navigation/pageReload.js +11 -0
- package/dist/lib/navigation/pageReload.js.map +7 -0
- package/dist/lib/schedule/interval.js +33 -0
- package/dist/lib/schedule/interval.js.map +7 -0
- package/dist/lib/schedule/invalidScheduleValue.js +19 -0
- package/dist/lib/schedule/invalidScheduleValue.js.map +7 -0
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +3 -2
- package/scripts/generate-countries.mjs +75 -0
- package/src/lib/crud/__tests__/crud-factory.test.ts +177 -0
- package/src/lib/crud/__tests__/errors.test.ts +31 -1
- package/src/lib/crud/errors.ts +16 -0
- package/src/lib/crud/factory.ts +37 -15
- package/src/lib/custom-fields/__tests__/kinds.test.ts +208 -0
- package/src/lib/custom-fields/kinds.ts +205 -0
- package/src/lib/encryption/__tests__/indexDoc.custom-field-kinds.test.ts +127 -0
- package/src/lib/encryption/__tests__/indexDoc.test.ts +39 -0
- package/src/lib/encryption/indexDoc.ts +16 -7
- package/src/lib/i18n/__tests__/dictionary-locale-fallback.test.ts +49 -0
- package/src/lib/i18n/server.ts +13 -5
- package/src/lib/location/__tests__/countries.test.ts +25 -0
- package/src/lib/location/countries.generated.ts +264 -0
- package/src/lib/location/countries.ts +2 -23
- package/src/lib/navigation/__tests__/pageReload.test.ts +58 -0
- package/src/lib/navigation/pageReload.ts +43 -0
- package/src/lib/schedule/__tests__/interval.test.ts +37 -0
- package/src/lib/schedule/interval.ts +45 -0
- package/src/lib/schedule/invalidScheduleValue.ts +33 -0
|
@@ -39,6 +39,45 @@ describe('encryption/indexDoc', () => {
|
|
|
39
39
|
expect(decryptCustomFieldValueMock).toHaveBeenCalled()
|
|
40
40
|
})
|
|
41
41
|
|
|
42
|
+
test('decryptIndexDocCustomFields passes each key its resolved kind (#5968)', async () => {
|
|
43
|
+
const doc = { id: '1', 'cf:note': 'enc', 'cf:count': 'enc', cf_order_ref: 'enc' }
|
|
44
|
+
const kinds = { note: 'text', count: 'integer', 'order-ref': 'text', order_ref: 'text' }
|
|
45
|
+
|
|
46
|
+
await decryptIndexDocCustomFields(doc, { tenantId: 't1', organizationId: 'org1' }, {} as any, undefined, kinds)
|
|
47
|
+
|
|
48
|
+
expect(decryptCustomFieldValueMock).toHaveBeenCalledTimes(3)
|
|
49
|
+
const kindsPassed = decryptCustomFieldValueMock.mock.calls.map((call) => call[4]?.kind).sort()
|
|
50
|
+
expect(kindsPassed).toEqual(['integer', 'text', 'text'])
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('decryptIndexDocCustomFields passes a null kind when no map is supplied (#5968)', async () => {
|
|
54
|
+
const doc = { id: '1', 'cf:note': 'enc' }
|
|
55
|
+
|
|
56
|
+
await decryptIndexDocCustomFields(doc, { tenantId: 't1', organizationId: 'org1' }, {} as any)
|
|
57
|
+
|
|
58
|
+
expect(decryptCustomFieldValueMock).toHaveBeenCalledWith('enc', 't1', {}, undefined, { kind: null })
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('decryptIndexDocForSearch forwards the kind map to the cf decryption (#5968)', async () => {
|
|
62
|
+
const service = {
|
|
63
|
+
isEnabled: () => true,
|
|
64
|
+
decryptEntityPayload: jest.fn(async () => ({})),
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
await decryptIndexDocForSearch(
|
|
68
|
+
'example:todo',
|
|
69
|
+
{ id: '1', 'cf:note': 'enc' },
|
|
70
|
+
{ tenantId: 't1', organizationId: 'org1' },
|
|
71
|
+
service as any,
|
|
72
|
+
undefined,
|
|
73
|
+
{ note: 'multiline' },
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
expect(decryptCustomFieldValueMock).toHaveBeenCalledWith(
|
|
77
|
+
'enc', 't1', service, undefined, { kind: 'multiline' },
|
|
78
|
+
)
|
|
79
|
+
})
|
|
80
|
+
|
|
42
81
|
test('decryptIndexDocForSearch merges decrypted entity payload and decrypts cf keys', async () => {
|
|
43
82
|
decryptCustomFieldValueMock.mockImplementation(async (value: unknown) => (value === 'enc' ? 'dec' : value))
|
|
44
83
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { decryptCustomFieldValue } from './customFieldValues'
|
|
2
|
+
import { resolveCustomFieldKind, type CustomFieldKindMap } from '../custom-fields/kinds'
|
|
2
3
|
import type { TenantDataEncryptionService } from './tenantDataEncryptionService'
|
|
3
4
|
|
|
4
5
|
export type IndexDocScope = {
|
|
@@ -10,12 +11,15 @@ async function decryptValue(
|
|
|
10
11
|
value: unknown,
|
|
11
12
|
scope: IndexDocScope,
|
|
12
13
|
service: TenantDataEncryptionService | null,
|
|
13
|
-
cache
|
|
14
|
+
cache: Map<string | null, string | null> | undefined,
|
|
15
|
+
kind: string | null,
|
|
14
16
|
): Promise<unknown> {
|
|
15
17
|
if (Array.isArray(value)) {
|
|
16
|
-
return Promise.all(
|
|
18
|
+
return Promise.all(
|
|
19
|
+
value.map((entry) => decryptCustomFieldValue(entry, scope.tenantId, service, cache, { kind })),
|
|
20
|
+
)
|
|
17
21
|
}
|
|
18
|
-
return decryptCustomFieldValue(value, scope.tenantId, service, cache)
|
|
22
|
+
return decryptCustomFieldValue(value, scope.tenantId, service, cache, { kind })
|
|
19
23
|
}
|
|
20
24
|
|
|
21
25
|
export async function decryptIndexDocCustomFields(
|
|
@@ -23,6 +27,7 @@ export async function decryptIndexDocCustomFields(
|
|
|
23
27
|
scope: IndexDocScope,
|
|
24
28
|
service: TenantDataEncryptionService | null,
|
|
25
29
|
cache?: Map<string | null, string | null>,
|
|
30
|
+
kinds?: CustomFieldKindMap | null,
|
|
26
31
|
): Promise<Record<string, unknown>> {
|
|
27
32
|
// HybridQueryEngine aliases cf keys as `cf_<key>` (sanitized), while index docs use `cf:<key>`.
|
|
28
33
|
// Support both shapes to keep decryption consistent across query paths.
|
|
@@ -33,7 +38,10 @@ export async function decryptIndexDocCustomFields(
|
|
|
33
38
|
await Promise.all(
|
|
34
39
|
keys.map(async (key) => {
|
|
35
40
|
try {
|
|
36
|
-
|
|
41
|
+
// Without the field's kind, `decryptCustomFieldValue` falls back to `JSON.parse` and
|
|
42
|
+
// retypes string-typed values whose plaintext looks like JSON — `"123"` becomes 123
|
|
43
|
+
// and `"true"` becomes true (issue #5968). An unresolved kind keeps the legacy path.
|
|
44
|
+
working[key] = await decryptValue(working[key], scope, service, cache, resolveCustomFieldKind(kinds, key))
|
|
37
45
|
} catch {
|
|
38
46
|
// ignore; keep original value
|
|
39
47
|
}
|
|
@@ -48,12 +56,13 @@ export async function decryptIndexDocForSearch(
|
|
|
48
56
|
scope: IndexDocScope,
|
|
49
57
|
service: TenantDataEncryptionService | null,
|
|
50
58
|
cache?: Map<string | null, string | null>,
|
|
59
|
+
kinds?: CustomFieldKindMap | null,
|
|
51
60
|
): Promise<Record<string, unknown>> {
|
|
52
61
|
if (!service || typeof service.decryptEntityPayload !== 'function') {
|
|
53
|
-
return decryptIndexDocCustomFields(doc, scope, service, cache)
|
|
62
|
+
return decryptIndexDocCustomFields(doc, scope, service, cache, kinds)
|
|
54
63
|
}
|
|
55
64
|
if (service.isEnabled?.() === false) {
|
|
56
|
-
return decryptIndexDocCustomFields(doc, scope, service, cache)
|
|
65
|
+
return decryptIndexDocCustomFields(doc, scope, service, cache, kinds)
|
|
57
66
|
}
|
|
58
67
|
|
|
59
68
|
let working: Record<string, unknown> = doc
|
|
@@ -72,7 +81,7 @@ export async function decryptIndexDocForSearch(
|
|
|
72
81
|
await decryptEntity('customers:customer_entity')
|
|
73
82
|
}
|
|
74
83
|
|
|
75
|
-
return decryptIndexDocCustomFields(working, scope, service, cache)
|
|
84
|
+
return decryptIndexDocCustomFields(working, scope, service, cache, kinds)
|
|
76
85
|
}
|
|
77
86
|
|
|
78
87
|
export async function encryptIndexDocForStorage(
|
|
@@ -50,6 +50,55 @@ describe('dictionary fallback for locales the platform does not ship', () => {
|
|
|
50
50
|
})
|
|
51
51
|
})
|
|
52
52
|
|
|
53
|
+
describe('host app override precedence', () => {
|
|
54
|
+
it('lets the host app dictionary override a module-defined key', async () => {
|
|
55
|
+
registerModules([{ translations: { en: { greeting: 'Hello from module' } } }] as any)
|
|
56
|
+
|
|
57
|
+
const en = await loadDictionary('en')
|
|
58
|
+
|
|
59
|
+
// The host's own locale file must win — a module MUST NOT be able to
|
|
60
|
+
// silently shadow a key the host app explicitly set (issue #5995).
|
|
61
|
+
expect(en.greeting).toBe('Hello')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('still falls back to a module key the host app does not define', async () => {
|
|
65
|
+
registerModules([{ translations: { en: { moduleOnly: 'Module value' } } }] as any)
|
|
66
|
+
|
|
67
|
+
const en = await loadDictionary('en')
|
|
68
|
+
|
|
69
|
+
expect(en.moduleOnly).toBe('Module value')
|
|
70
|
+
expect(en.greeting).toBe('Hello')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('sees a module registered as a side effect of resolving the app loader on the first load of a locale', async () => {
|
|
74
|
+
// Mirrors apps/mercato/src/lib/i18n/register-dictionary-loader.ts: the
|
|
75
|
+
// registered loader lazily imports the locale's per-module translation
|
|
76
|
+
// bundle and calls registerModules() as a side effect, after an internal
|
|
77
|
+
// await. loadDictionary() MUST await loadAppDictionary() before reading
|
|
78
|
+
// tryGetModules() — reading the registry first would race the locale's
|
|
79
|
+
// first-ever load and permanently cache a dictionary missing this
|
|
80
|
+
// lazily-registered module's translations (regression fixed in
|
|
81
|
+
// 4f3a8e255).
|
|
82
|
+
registerAppDictionaryLoader(async (locale: Locale) => {
|
|
83
|
+
if (locale === 'pl') {
|
|
84
|
+
await Promise.resolve()
|
|
85
|
+
registerModules([
|
|
86
|
+
{ translations: { pl: { greeting: 'Cześć (module)', fromModule: 'Z modułu' } } },
|
|
87
|
+
] as any)
|
|
88
|
+
}
|
|
89
|
+
return APP_DICTIONARIES[locale] ?? {}
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
const pl = await loadDictionary('pl')
|
|
93
|
+
|
|
94
|
+
// The module key is present on the very first load, proving the module
|
|
95
|
+
// registered inside the loader was read after it resolved.
|
|
96
|
+
expect(pl.fromModule).toBe('Z modułu')
|
|
97
|
+
// The app dictionary still wins the collision (issue #5995).
|
|
98
|
+
expect(pl.greeting).toBe('Cześć')
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
53
102
|
describe('an app-registered locale', () => {
|
|
54
103
|
it('falls back to the default locale instead of rendering raw keys', async () => {
|
|
55
104
|
registerLocales(['cs'])
|
package/src/lib/i18n/server.ts
CHANGED
|
@@ -94,18 +94,26 @@ export async function loadDictionary(locale: Locale): Promise<Dict> {
|
|
|
94
94
|
const needsDefaultLocaleBase =
|
|
95
95
|
locale !== defaultLocale && !(locales as readonly string[]).includes(locale)
|
|
96
96
|
const merged: Dict = needsDefaultLocaleBase ? { ...(await loadDictionary(defaultLocale)) } : {}
|
|
97
|
-
// Load from registry instead of @/ import (works in standalone packages)
|
|
97
|
+
// Load from registry instead of @/ import (works in standalone packages).
|
|
98
|
+
// Awaited BEFORE tryGetModules() below: the host app's dictionary loader
|
|
99
|
+
// (see apps/mercato/src/lib/i18n/register-dictionary-loader.ts) lazily
|
|
100
|
+
// imports this locale's per-module translation bundle and registers it via
|
|
101
|
+
// `registerModules()` as a side effect of resolving — reading the module
|
|
102
|
+
// registry first would race a locale's first-ever load and permanently
|
|
103
|
+
// cache a dictionary missing every lazily-registered module's translations
|
|
104
|
+
// for it (the registration event that would normally invalidate the cache
|
|
105
|
+
// fires too early, before this call has anything cached to invalidate).
|
|
98
106
|
const baseRaw = await loadAppDictionary(locale)
|
|
99
|
-
Object.assign(merged, flattenDictionary(baseRaw))
|
|
100
107
|
// Route handlers translate their responses, so they resolve a dictionary even
|
|
101
|
-
// when they are exercised in isolation without a bootstrapped registry.
|
|
102
|
-
//
|
|
103
|
-
//
|
|
108
|
+
// when they are exercised in isolation without a bootstrapped registry. Module
|
|
109
|
+
// dictionaries are layered first so the host app dictionary — merged last,
|
|
110
|
+
// below — is always the final word and can override any module-defined key.
|
|
104
111
|
const modules = tryGetModules() ?? []
|
|
105
112
|
for (const m of modules) {
|
|
106
113
|
const dict = m.translations?.[locale]
|
|
107
114
|
if (dict) Object.assign(merged, flattenDictionary(dict))
|
|
108
115
|
}
|
|
116
|
+
Object.assign(merged, flattenDictionary(baseRaw))
|
|
109
117
|
setCachedDictionary(locale, merged)
|
|
110
118
|
return merged
|
|
111
119
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
1
3
|
import {
|
|
2
4
|
COUNTRY_PRIORITY,
|
|
3
5
|
ISO_COUNTRIES,
|
|
@@ -6,7 +8,30 @@ import {
|
|
|
6
8
|
resolveCountryName,
|
|
7
9
|
} from '../countries'
|
|
8
10
|
|
|
11
|
+
const PACKAGE_ROOT = join(__dirname, '..', '..', '..', '..')
|
|
12
|
+
const DIST_COUNTRIES = join(PACKAGE_ROOT, 'dist/lib/location/countries.js')
|
|
13
|
+
const DIST_GENERATED = join(PACKAGE_ROOT, 'dist/lib/location/countries.generated.js')
|
|
14
|
+
|
|
9
15
|
describe('ISO_COUNTRIES', () => {
|
|
16
|
+
it('does not import language-subtag-registry at runtime (Node ESM / production)', () => {
|
|
17
|
+
const source = readFileSync(join(__dirname, '../countries.ts'), 'utf8')
|
|
18
|
+
expect(source).not.toMatch(/language-subtag-registry/)
|
|
19
|
+
const generated = readFileSync(join(__dirname, '../countries.generated.ts'), 'utf8')
|
|
20
|
+
expect(generated).toMatch(/AUTO-GENERATED/)
|
|
21
|
+
expect(generated).toMatch(/code: "PL"/)
|
|
22
|
+
expect(generated).toMatch(/code: "DE"/)
|
|
23
|
+
|
|
24
|
+
expect(existsSync(DIST_COUNTRIES)).toBe(true)
|
|
25
|
+
expect(existsSync(DIST_GENERATED)).toBe(true)
|
|
26
|
+
const distCountries = readFileSync(DIST_COUNTRIES, 'utf8')
|
|
27
|
+
const distGenerated = readFileSync(DIST_GENERATED, 'utf8')
|
|
28
|
+
expect(distCountries).not.toMatch(/language-subtag-registry/)
|
|
29
|
+
expect(distGenerated).not.toMatch(/language-subtag-registry/)
|
|
30
|
+
expect(distCountries).toMatch(/from "\.\/countries\.generated\.js"/)
|
|
31
|
+
expect(distGenerated).toMatch(/code: "PL"/)
|
|
32
|
+
expect(distGenerated).toMatch(/code: "DE"/)
|
|
33
|
+
})
|
|
34
|
+
|
|
10
35
|
it('includes Kosovo, which the language-subtag registry does not list', () => {
|
|
11
36
|
const kosovo = ISO_COUNTRIES.filter((entry) => entry.code === 'XK')
|
|
12
37
|
expect(kosovo).toEqual([{ code: 'XK', name: 'Kosovo' }])
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// AUTO-GENERATED by scripts/generate-countries.mjs — do not edit by hand.
|
|
2
|
+
// Regenerate via `node packages/shared/scripts/generate-countries.mjs` or the shared package build.
|
|
3
|
+
|
|
4
|
+
export const REGISTRY_COUNTRIES: Array<{ code: string; name: string }> = [
|
|
5
|
+
{ code: "AC", name: "Ascension Island" },
|
|
6
|
+
{ code: "AD", name: "Andorra" },
|
|
7
|
+
{ code: "AE", name: "United Arab Emirates" },
|
|
8
|
+
{ code: "AF", name: "Afghanistan" },
|
|
9
|
+
{ code: "AG", name: "Antigua and Barbuda" },
|
|
10
|
+
{ code: "AI", name: "Anguilla" },
|
|
11
|
+
{ code: "AL", name: "Albania" },
|
|
12
|
+
{ code: "AM", name: "Armenia" },
|
|
13
|
+
{ code: "AO", name: "Angola" },
|
|
14
|
+
{ code: "AQ", name: "Antarctica" },
|
|
15
|
+
{ code: "AR", name: "Argentina" },
|
|
16
|
+
{ code: "AS", name: "American Samoa" },
|
|
17
|
+
{ code: "AT", name: "Austria" },
|
|
18
|
+
{ code: "AU", name: "Australia" },
|
|
19
|
+
{ code: "AW", name: "Aruba" },
|
|
20
|
+
{ code: "AX", name: "Åland Islands" },
|
|
21
|
+
{ code: "AZ", name: "Azerbaijan" },
|
|
22
|
+
{ code: "BA", name: "Bosnia and Herzegovina" },
|
|
23
|
+
{ code: "BB", name: "Barbados" },
|
|
24
|
+
{ code: "BD", name: "Bangladesh" },
|
|
25
|
+
{ code: "BE", name: "Belgium" },
|
|
26
|
+
{ code: "BF", name: "Burkina Faso" },
|
|
27
|
+
{ code: "BG", name: "Bulgaria" },
|
|
28
|
+
{ code: "BH", name: "Bahrain" },
|
|
29
|
+
{ code: "BI", name: "Burundi" },
|
|
30
|
+
{ code: "BJ", name: "Benin" },
|
|
31
|
+
{ code: "BL", name: "Saint Barthélemy" },
|
|
32
|
+
{ code: "BM", name: "Bermuda" },
|
|
33
|
+
{ code: "BN", name: "Brunei Darussalam" },
|
|
34
|
+
{ code: "BO", name: "Bolivia" },
|
|
35
|
+
{ code: "BQ", name: "Bonaire, Sint Eustatius and Saba" },
|
|
36
|
+
{ code: "BR", name: "Brazil" },
|
|
37
|
+
{ code: "BS", name: "Bahamas" },
|
|
38
|
+
{ code: "BT", name: "Bhutan" },
|
|
39
|
+
{ code: "BV", name: "Bouvet Island" },
|
|
40
|
+
{ code: "BW", name: "Botswana" },
|
|
41
|
+
{ code: "BY", name: "Belarus" },
|
|
42
|
+
{ code: "BZ", name: "Belize" },
|
|
43
|
+
{ code: "CA", name: "Canada" },
|
|
44
|
+
{ code: "CC", name: "Cocos (Keeling) Islands" },
|
|
45
|
+
{ code: "CD", name: "The Democratic Republic of the Congo" },
|
|
46
|
+
{ code: "CF", name: "Central African Republic" },
|
|
47
|
+
{ code: "CG", name: "Congo" },
|
|
48
|
+
{ code: "CH", name: "Switzerland" },
|
|
49
|
+
{ code: "CI", name: "Côte d'Ivoire" },
|
|
50
|
+
{ code: "CK", name: "Cook Islands" },
|
|
51
|
+
{ code: "CL", name: "Chile" },
|
|
52
|
+
{ code: "CM", name: "Cameroon" },
|
|
53
|
+
{ code: "CN", name: "China" },
|
|
54
|
+
{ code: "CO", name: "Colombia" },
|
|
55
|
+
{ code: "CP", name: "Clipperton Island" },
|
|
56
|
+
{ code: "CQ", name: "Sark" },
|
|
57
|
+
{ code: "CR", name: "Costa Rica" },
|
|
58
|
+
{ code: "CU", name: "Cuba" },
|
|
59
|
+
{ code: "CV", name: "Cabo Verde, Cape Verde" },
|
|
60
|
+
{ code: "CW", name: "Curaçao" },
|
|
61
|
+
{ code: "CX", name: "Christmas Island" },
|
|
62
|
+
{ code: "CY", name: "Cyprus" },
|
|
63
|
+
{ code: "CZ", name: "Czechia, Czech Republic" },
|
|
64
|
+
{ code: "DE", name: "Germany" },
|
|
65
|
+
{ code: "DG", name: "Diego Garcia" },
|
|
66
|
+
{ code: "DJ", name: "Djibouti" },
|
|
67
|
+
{ code: "DK", name: "Denmark" },
|
|
68
|
+
{ code: "DM", name: "Dominica" },
|
|
69
|
+
{ code: "DO", name: "Dominican Republic" },
|
|
70
|
+
{ code: "DZ", name: "Algeria" },
|
|
71
|
+
{ code: "EA", name: "Ceuta, Melilla" },
|
|
72
|
+
{ code: "EC", name: "Ecuador" },
|
|
73
|
+
{ code: "EE", name: "Estonia" },
|
|
74
|
+
{ code: "EG", name: "Egypt" },
|
|
75
|
+
{ code: "EH", name: "Western Sahara" },
|
|
76
|
+
{ code: "ER", name: "Eritrea" },
|
|
77
|
+
{ code: "ES", name: "Spain" },
|
|
78
|
+
{ code: "ET", name: "Ethiopia" },
|
|
79
|
+
{ code: "EU", name: "European Union" },
|
|
80
|
+
{ code: "EZ", name: "Eurozone" },
|
|
81
|
+
{ code: "FI", name: "Finland" },
|
|
82
|
+
{ code: "FJ", name: "Fiji" },
|
|
83
|
+
{ code: "FK", name: "Falkland Islands (Malvinas)" },
|
|
84
|
+
{ code: "FM", name: "Federated States of Micronesia" },
|
|
85
|
+
{ code: "FO", name: "Faroe Islands" },
|
|
86
|
+
{ code: "FR", name: "France" },
|
|
87
|
+
{ code: "GA", name: "Gabon" },
|
|
88
|
+
{ code: "GB", name: "United Kingdom" },
|
|
89
|
+
{ code: "GD", name: "Grenada" },
|
|
90
|
+
{ code: "GE", name: "Georgia" },
|
|
91
|
+
{ code: "GF", name: "French Guiana" },
|
|
92
|
+
{ code: "GG", name: "Guernsey" },
|
|
93
|
+
{ code: "GH", name: "Ghana" },
|
|
94
|
+
{ code: "GI", name: "Gibraltar" },
|
|
95
|
+
{ code: "GL", name: "Greenland" },
|
|
96
|
+
{ code: "GM", name: "Gambia" },
|
|
97
|
+
{ code: "GN", name: "Guinea" },
|
|
98
|
+
{ code: "GP", name: "Guadeloupe" },
|
|
99
|
+
{ code: "GQ", name: "Equatorial Guinea" },
|
|
100
|
+
{ code: "GR", name: "Greece" },
|
|
101
|
+
{ code: "GS", name: "South Georgia and the South Sandwich Islands" },
|
|
102
|
+
{ code: "GT", name: "Guatemala" },
|
|
103
|
+
{ code: "GU", name: "Guam" },
|
|
104
|
+
{ code: "GW", name: "Guinea-Bissau" },
|
|
105
|
+
{ code: "GY", name: "Guyana" },
|
|
106
|
+
{ code: "HK", name: "Hong Kong" },
|
|
107
|
+
{ code: "HM", name: "Heard Island and McDonald Islands" },
|
|
108
|
+
{ code: "HN", name: "Honduras" },
|
|
109
|
+
{ code: "HR", name: "Croatia" },
|
|
110
|
+
{ code: "HT", name: "Haiti" },
|
|
111
|
+
{ code: "HU", name: "Hungary" },
|
|
112
|
+
{ code: "IC", name: "Canary Islands" },
|
|
113
|
+
{ code: "ID", name: "Indonesia" },
|
|
114
|
+
{ code: "IE", name: "Ireland" },
|
|
115
|
+
{ code: "IL", name: "Israel" },
|
|
116
|
+
{ code: "IM", name: "Isle of Man" },
|
|
117
|
+
{ code: "IN", name: "India" },
|
|
118
|
+
{ code: "IO", name: "British Indian Ocean Territory" },
|
|
119
|
+
{ code: "IQ", name: "Iraq" },
|
|
120
|
+
{ code: "IR", name: "Islamic Republic of Iran" },
|
|
121
|
+
{ code: "IS", name: "Iceland" },
|
|
122
|
+
{ code: "IT", name: "Italy" },
|
|
123
|
+
{ code: "JE", name: "Jersey" },
|
|
124
|
+
{ code: "JM", name: "Jamaica" },
|
|
125
|
+
{ code: "JO", name: "Jordan" },
|
|
126
|
+
{ code: "JP", name: "Japan" },
|
|
127
|
+
{ code: "KE", name: "Kenya" },
|
|
128
|
+
{ code: "KG", name: "Kyrgyzstan" },
|
|
129
|
+
{ code: "KH", name: "Cambodia" },
|
|
130
|
+
{ code: "KI", name: "Kiribati" },
|
|
131
|
+
{ code: "KM", name: "Comoros" },
|
|
132
|
+
{ code: "KN", name: "Saint Kitts and Nevis" },
|
|
133
|
+
{ code: "KP", name: "Democratic People's Republic of Korea" },
|
|
134
|
+
{ code: "KR", name: "Republic of Korea" },
|
|
135
|
+
{ code: "KW", name: "Kuwait" },
|
|
136
|
+
{ code: "KY", name: "Cayman Islands" },
|
|
137
|
+
{ code: "KZ", name: "Kazakhstan" },
|
|
138
|
+
{ code: "LA", name: "Lao People's Democratic Republic" },
|
|
139
|
+
{ code: "LB", name: "Lebanon" },
|
|
140
|
+
{ code: "LC", name: "Saint Lucia" },
|
|
141
|
+
{ code: "LI", name: "Liechtenstein" },
|
|
142
|
+
{ code: "LK", name: "Sri Lanka" },
|
|
143
|
+
{ code: "LR", name: "Liberia" },
|
|
144
|
+
{ code: "LS", name: "Lesotho" },
|
|
145
|
+
{ code: "LT", name: "Lithuania" },
|
|
146
|
+
{ code: "LU", name: "Luxembourg" },
|
|
147
|
+
{ code: "LV", name: "Latvia" },
|
|
148
|
+
{ code: "LY", name: "Libya" },
|
|
149
|
+
{ code: "MA", name: "Morocco" },
|
|
150
|
+
{ code: "MC", name: "Monaco" },
|
|
151
|
+
{ code: "MD", name: "Moldova" },
|
|
152
|
+
{ code: "ME", name: "Montenegro" },
|
|
153
|
+
{ code: "MF", name: "Saint Martin (French part)" },
|
|
154
|
+
{ code: "MG", name: "Madagascar" },
|
|
155
|
+
{ code: "MH", name: "Marshall Islands" },
|
|
156
|
+
{ code: "MK", name: "North Macedonia" },
|
|
157
|
+
{ code: "ML", name: "Mali" },
|
|
158
|
+
{ code: "MM", name: "Myanmar" },
|
|
159
|
+
{ code: "MN", name: "Mongolia" },
|
|
160
|
+
{ code: "MO", name: "Macao" },
|
|
161
|
+
{ code: "MP", name: "Northern Mariana Islands" },
|
|
162
|
+
{ code: "MQ", name: "Martinique" },
|
|
163
|
+
{ code: "MR", name: "Mauritania" },
|
|
164
|
+
{ code: "MS", name: "Montserrat" },
|
|
165
|
+
{ code: "MT", name: "Malta" },
|
|
166
|
+
{ code: "MU", name: "Mauritius" },
|
|
167
|
+
{ code: "MV", name: "Maldives" },
|
|
168
|
+
{ code: "MW", name: "Malawi" },
|
|
169
|
+
{ code: "MX", name: "Mexico" },
|
|
170
|
+
{ code: "MY", name: "Malaysia" },
|
|
171
|
+
{ code: "MZ", name: "Mozambique" },
|
|
172
|
+
{ code: "NA", name: "Namibia" },
|
|
173
|
+
{ code: "NC", name: "New Caledonia" },
|
|
174
|
+
{ code: "NE", name: "Niger" },
|
|
175
|
+
{ code: "NF", name: "Norfolk Island" },
|
|
176
|
+
{ code: "NG", name: "Nigeria" },
|
|
177
|
+
{ code: "NI", name: "Nicaragua" },
|
|
178
|
+
{ code: "NL", name: "Netherlands" },
|
|
179
|
+
{ code: "NO", name: "Norway" },
|
|
180
|
+
{ code: "NP", name: "Nepal" },
|
|
181
|
+
{ code: "NR", name: "Nauru" },
|
|
182
|
+
{ code: "NU", name: "Niue" },
|
|
183
|
+
{ code: "NZ", name: "New Zealand" },
|
|
184
|
+
{ code: "OM", name: "Oman" },
|
|
185
|
+
{ code: "PA", name: "Panama" },
|
|
186
|
+
{ code: "PE", name: "Peru" },
|
|
187
|
+
{ code: "PF", name: "French Polynesia" },
|
|
188
|
+
{ code: "PG", name: "Papua New Guinea" },
|
|
189
|
+
{ code: "PH", name: "Philippines" },
|
|
190
|
+
{ code: "PK", name: "Pakistan" },
|
|
191
|
+
{ code: "PL", name: "Poland" },
|
|
192
|
+
{ code: "PM", name: "Saint Pierre and Miquelon" },
|
|
193
|
+
{ code: "PN", name: "Pitcairn" },
|
|
194
|
+
{ code: "PR", name: "Puerto Rico" },
|
|
195
|
+
{ code: "PS", name: "State of Palestine" },
|
|
196
|
+
{ code: "PT", name: "Portugal" },
|
|
197
|
+
{ code: "PW", name: "Palau" },
|
|
198
|
+
{ code: "PY", name: "Paraguay" },
|
|
199
|
+
{ code: "QA", name: "Qatar" },
|
|
200
|
+
{ code: "RE", name: "Réunion" },
|
|
201
|
+
{ code: "RO", name: "Romania" },
|
|
202
|
+
{ code: "RS", name: "Serbia" },
|
|
203
|
+
{ code: "RU", name: "Russian Federation" },
|
|
204
|
+
{ code: "RW", name: "Rwanda" },
|
|
205
|
+
{ code: "SA", name: "Saudi Arabia" },
|
|
206
|
+
{ code: "SB", name: "Solomon Islands" },
|
|
207
|
+
{ code: "SC", name: "Seychelles" },
|
|
208
|
+
{ code: "SD", name: "Sudan" },
|
|
209
|
+
{ code: "SE", name: "Sweden" },
|
|
210
|
+
{ code: "SG", name: "Singapore" },
|
|
211
|
+
{ code: "SH", name: "Saint Helena, Ascension and Tristan da Cunha" },
|
|
212
|
+
{ code: "SI", name: "Slovenia" },
|
|
213
|
+
{ code: "SJ", name: "Svalbard and Jan Mayen" },
|
|
214
|
+
{ code: "SK", name: "Slovakia" },
|
|
215
|
+
{ code: "SL", name: "Sierra Leone" },
|
|
216
|
+
{ code: "SM", name: "San Marino" },
|
|
217
|
+
{ code: "SN", name: "Senegal" },
|
|
218
|
+
{ code: "SO", name: "Somalia" },
|
|
219
|
+
{ code: "SR", name: "Suriname" },
|
|
220
|
+
{ code: "SS", name: "South Sudan" },
|
|
221
|
+
{ code: "ST", name: "Sao Tome and Principe" },
|
|
222
|
+
{ code: "SV", name: "El Salvador" },
|
|
223
|
+
{ code: "SX", name: "Sint Maarten (Dutch part)" },
|
|
224
|
+
{ code: "SY", name: "Syrian Arab Republic" },
|
|
225
|
+
{ code: "SZ", name: "Eswatini, eSwatini, Swaziland" },
|
|
226
|
+
{ code: "TA", name: "Tristan da Cunha" },
|
|
227
|
+
{ code: "TC", name: "Turks and Caicos Islands" },
|
|
228
|
+
{ code: "TD", name: "Chad" },
|
|
229
|
+
{ code: "TF", name: "French Southern Territories" },
|
|
230
|
+
{ code: "TG", name: "Togo" },
|
|
231
|
+
{ code: "TH", name: "Thailand" },
|
|
232
|
+
{ code: "TJ", name: "Tajikistan" },
|
|
233
|
+
{ code: "TK", name: "Tokelau" },
|
|
234
|
+
{ code: "TL", name: "Timor-Leste" },
|
|
235
|
+
{ code: "TM", name: "Turkmenistan" },
|
|
236
|
+
{ code: "TN", name: "Tunisia" },
|
|
237
|
+
{ code: "TO", name: "Tonga" },
|
|
238
|
+
{ code: "TR", name: "Türkiye, Turkey" },
|
|
239
|
+
{ code: "TT", name: "Trinidad and Tobago" },
|
|
240
|
+
{ code: "TV", name: "Tuvalu" },
|
|
241
|
+
{ code: "TW", name: "Taiwan, Province of China" },
|
|
242
|
+
{ code: "TZ", name: "United Republic of Tanzania" },
|
|
243
|
+
{ code: "UA", name: "Ukraine" },
|
|
244
|
+
{ code: "UG", name: "Uganda" },
|
|
245
|
+
{ code: "UM", name: "United States Minor Outlying Islands" },
|
|
246
|
+
{ code: "UN", name: "United Nations" },
|
|
247
|
+
{ code: "US", name: "United States" },
|
|
248
|
+
{ code: "UY", name: "Uruguay" },
|
|
249
|
+
{ code: "UZ", name: "Uzbekistan" },
|
|
250
|
+
{ code: "VA", name: "Holy See (Vatican City State)" },
|
|
251
|
+
{ code: "VC", name: "Saint Vincent and the Grenadines" },
|
|
252
|
+
{ code: "VE", name: "Venezuela" },
|
|
253
|
+
{ code: "VG", name: "British Virgin Islands" },
|
|
254
|
+
{ code: "VI", name: "U.S. Virgin Islands" },
|
|
255
|
+
{ code: "VN", name: "Viet Nam" },
|
|
256
|
+
{ code: "VU", name: "Vanuatu" },
|
|
257
|
+
{ code: "WF", name: "Wallis and Futuna" },
|
|
258
|
+
{ code: "WS", name: "Samoa" },
|
|
259
|
+
{ code: "YE", name: "Yemen" },
|
|
260
|
+
{ code: "YT", name: "Mayotte" },
|
|
261
|
+
{ code: "ZA", name: "South Africa" },
|
|
262
|
+
{ code: "ZM", name: "Zambia" },
|
|
263
|
+
{ code: "ZW", name: "Zimbabwe" },
|
|
264
|
+
]
|
|
@@ -1,32 +1,11 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
type RegistryEntry = {
|
|
4
|
-
Type: string
|
|
5
|
-
Subtag?: string
|
|
6
|
-
Description?: string[]
|
|
7
|
-
Deprecated?: string
|
|
8
|
-
}
|
|
1
|
+
import { REGISTRY_COUNTRIES } from './countries.generated'
|
|
9
2
|
|
|
10
3
|
export type IsoCountry = {
|
|
11
4
|
code: string
|
|
12
5
|
name: string
|
|
13
6
|
}
|
|
14
7
|
|
|
15
|
-
|
|
16
|
-
if (entry.Type !== 'region') return false
|
|
17
|
-
if (!entry.Subtag || !/^[A-Z]{2}$/.test(entry.Subtag)) return false
|
|
18
|
-
if (entry.Deprecated) return false
|
|
19
|
-
if (!entry.Description || !entry.Description.length) return false
|
|
20
|
-
if (entry.Description[0] === 'Private use') return false
|
|
21
|
-
return true
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const RAW_COUNTRIES: IsoCountry[] = (registry as RegistryEntry[])
|
|
25
|
-
.filter(isIsoAlpha2)
|
|
26
|
-
.map((entry) => ({
|
|
27
|
-
code: entry.Subtag,
|
|
28
|
-
name: entry.Description.join(', '),
|
|
29
|
-
}))
|
|
8
|
+
const RAW_COUNTRIES: IsoCountry[] = REGISTRY_COUNTRIES
|
|
30
9
|
|
|
31
10
|
// Codes in common use that the language-subtag registry does not list as region subtags.
|
|
32
11
|
// XK is user-assigned rather than ISO 3166-1 assigned, so it never appears in the registry.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* These helpers exist so a boundary-crossing portal navigation re-runs the server layout that
|
|
3
|
+
* computes the portal chrome and the session. Which document API each one uses is the whole
|
|
4
|
+
* contract: `assign` keeps the current page in session history, `replace` does not, so they are
|
|
5
|
+
* the document-loading counterparts of `router.push` and `router.replace` respectively. Swapping
|
|
6
|
+
* one for the other is invisible on screen and only surfaces as a broken Back button, so pin both.
|
|
7
|
+
*
|
|
8
|
+
* This runs in the node environment on purpose: jsdom exposes `window.location` as a
|
|
9
|
+
* non-configurable getter whose `assign`/`replace` are non-writable, so the real navigation calls
|
|
10
|
+
* cannot be observed there. A synthetic `window` lets the helper bodies run for real.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const assign = jest.fn()
|
|
14
|
+
const replace = jest.fn()
|
|
15
|
+
|
|
16
|
+
describe('page reload navigation helpers', () => {
|
|
17
|
+
let navigateWithPageReload: (path: string) => void
|
|
18
|
+
let replaceWithPageReload: (path: string) => void
|
|
19
|
+
|
|
20
|
+
beforeAll(() => {
|
|
21
|
+
Object.defineProperty(globalThis, 'window', {
|
|
22
|
+
configurable: true,
|
|
23
|
+
writable: true,
|
|
24
|
+
value: { location: { assign, replace } },
|
|
25
|
+
})
|
|
26
|
+
// Imported after the synthetic window exists, so the module resolves against it.
|
|
27
|
+
const helpers = require('../pageReload')
|
|
28
|
+
navigateWithPageReload = helpers.navigateWithPageReload
|
|
29
|
+
replaceWithPageReload = helpers.replaceWithPageReload
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
afterAll(() => {
|
|
33
|
+
delete (globalThis as { window?: unknown }).window
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
assign.mockReset()
|
|
38
|
+
replace.mockReset()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('navigateWithPageReload', () => {
|
|
42
|
+
it('loads the path and keeps the current page in session history', () => {
|
|
43
|
+
navigateWithPageReload('/acme/portal/dashboard')
|
|
44
|
+
|
|
45
|
+
expect(assign).toHaveBeenCalledWith('/acme/portal/dashboard')
|
|
46
|
+
expect(replace).not.toHaveBeenCalled()
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('replaceWithPageReload', () => {
|
|
51
|
+
it('loads the path without leaving the redirecting page in session history', () => {
|
|
52
|
+
replaceWithPageReload('/acme/portal/login')
|
|
53
|
+
|
|
54
|
+
expect(replace).toHaveBeenCalledWith('/acme/portal/login')
|
|
55
|
+
expect(assign).not.toHaveBeenCalled()
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Boundary-crossing navigation helpers: they reload the document instead of handing the route to
|
|
3
|
+
* the client router.
|
|
4
|
+
*
|
|
5
|
+
* The portal's `(frontend)` layout — the server component that decides the portal chrome and
|
|
6
|
+
* resolves the customer session — sits *above* the `[...slug]` segment so portal navigation does
|
|
7
|
+
* not remount the client subtree. A client-side `router.push`/`router.replace` therefore never
|
|
8
|
+
* re-runs that layout, and any navigation that crosses the public/authenticated boundary would
|
|
9
|
+
* keep rendering chrome computed for the page it came from: authenticated chrome over the login
|
|
10
|
+
* page after a logout, or logged-out chrome over the dashboard after a login.
|
|
11
|
+
*
|
|
12
|
+
* Use these helpers for every boundary-crossing navigation (login, logout, signup completion,
|
|
13
|
+
* invite acceptance, auth guards redirecting to login) so the layout re-runs and recomputes both
|
|
14
|
+
* the chrome and the session. Same-side navigation should keep using the client router.
|
|
15
|
+
*
|
|
16
|
+
* Pick the one that matches the client-router call it stands in for, so session history behaves
|
|
17
|
+
* the same way: `navigateWithPageReload` for `router.push`, `replaceWithPageReload` for
|
|
18
|
+
* `router.replace`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Full page load that adds a session-history entry — the document-loading counterpart of `router.push`. */
|
|
22
|
+
export function navigateWithPageReload(path: string): void {
|
|
23
|
+
window.location.assign(path)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Full page load that replaces the current session-history entry — the document-loading
|
|
28
|
+
* counterpart of `router.replace`.
|
|
29
|
+
*
|
|
30
|
+
* Use this where the client-router call being replaced was a `router.replace`: an auth guard
|
|
31
|
+
* sending an unauthenticated visitor to the login page, or the portal landing page forwarding a
|
|
32
|
+
* signed-in customer to the dashboard.
|
|
33
|
+
*
|
|
34
|
+
* Note that for those call sites specifically, `navigateWithPageReload` was measured to behave
|
|
35
|
+
* identically: they redirect from an effect during page load, and a browser treats a navigation
|
|
36
|
+
* started then as a client redirect, replacing the entry rather than pushing it whichever method
|
|
37
|
+
* is used. This helper exists so the call site states which semantics it means instead of relying
|
|
38
|
+
* on that heuristic — and so a redirect that later moves behind a user gesture, where the
|
|
39
|
+
* distinction does bite, keeps the behaviour it was written with.
|
|
40
|
+
*/
|
|
41
|
+
export function replaceWithPageReload(path: string): void {
|
|
42
|
+
window.location.replace(path)
|
|
43
|
+
}
|