From 89b6b965f3b148993af04f5fca8c485bbe27b3ff Mon Sep 17 00:00:00 2001 From: Roshan Gorasia Date: Thu, 6 Aug 2026 11:36:12 +0100 Subject: [PATCH 1/2] fix: prefill APM fields by canonical key and parse E.164 phone numbers Prefilling an APM form via `initialData` had two problems. Values were matched only against the payment method's own parameter key, so the documented `initialData.phone_number` silently did nothing whenever a payment method named the field something else (for example `customerPhone`), leaving merchants to discover each one's internal parameter names. `email` and `phone_number` now also match on the parameter's type, so the canonical keys prefill on every payment method. An exact parameter-key match still wins, so a specific field can be targeted or a canonical value overridden. A phone number passed as an E.164 string was dropped into the national number input whole, with the dialing code set to the first entry in the available list. That selected the wrong country, left `+CC` inside the number box and submitted a malformed value. The string is now split on a longest-prefix match against the available dialing codes, falling back to the browser-locale default when the country isn't offered. Co-Authored-By: Claude Opus 5 (1M context) --- src/apm/types.ts | 21 ++++- src/apm/utils.ts | 101 ++++++++++++++++++++++++ src/apm/views/NextSteps.ts | 16 ++-- test/apm/prefill.test.ts | 158 +++++++++++++++++++++++++++++++++++++ 4 files changed, 283 insertions(+), 13 deletions(-) create mode 100644 test/apm/prefill.test.ts diff --git a/src/apm/types.ts b/src/apm/types.ts index e933d75e..60bf123e 100644 --- a/src/apm/types.ts +++ b/src/apm/types.ts @@ -37,12 +37,25 @@ module ProcessOut { export type Container = string | Element + /** + * Values used to prefill the payment method's form fields. + * + * `email` and `phone_number` are canonical: they match the field by type, so + * they work whatever the gateway names its own parameter. Any gateway + * parameter key can also be passed directly, and takes precedence over the + * canonical key for that type. + */ export interface InitialData { email: string, - phone_number: { - dialing_code: string, - value: string, - } + /** + * Either an E.164 string ("+48123123123") or the split form, with the + * country given as a dialing code ("+48"). + */ + phone_number: string | { + dialing_code?: string, + value?: string, + }, + [key: string]: unknown, } } diff --git a/src/apm/utils.ts b/src/apm/utils.ts index 55fd75b1..02523bbe 100644 --- a/src/apm/utils.ts +++ b/src/apm/utils.ts @@ -46,6 +46,107 @@ module ProcessOut { return (match || dialing_codes[0]).value; } + /** + * Canonical `initialData` keys and the parameter type each one prefills. + * + * Payment methods name their own parameters, so a phone field can arrive as + * `customerPhone` rather than `phone_number`. Matching on type as well as on + * the raw key lets the documented canonical keys prefill on every payment + * method, without the merchant having to know each one's parameter names. + */ + const CANONICAL_PREFILL_KEYS: Array<{ key: string, type: string }> = [ + { key: 'email', type: 'email' }, + { key: 'phone_number', type: 'phone' }, + ] + + /** + * Find the value in `initialData` that should prefill the given parameter. + * An exact key match wins, so a merchant can always target one specific + * gateway parameter (or override a canonical key); otherwise we fall back to + * the canonical key for the parameter's type. + */ + export function resolvePrefilledValue( + initialData: object | undefined, + param: { key: string, type: string }, + ): unknown { + if (!initialData) { + return undefined; + } + + const data = initialData as Record; + + if (data[param.key] !== undefined && data[param.key] !== null) { + return data[param.key]; + } + + for (let i = 0; i < CANONICAL_PREFILL_KEYS.length; i++) { + const canonical = CANONICAL_PREFILL_KEYS[i]; + if (canonical.type === param.type && data[canonical.key]) { + return data[canonical.key]; + } + } + + return undefined; + } + + /** + * Coerce a prefilled phone value into the `{ dialing_code, value }` shape the + * phone field renders. + * + * Accepts the object form and a bare E.164 string (`"+48123123123"`). The + * string is split on a longest-prefix match against the gateway's own dialing + * codes so the right country is selected and only the national number lands + * in the input — otherwise the whole string ends up in the number box and the + * submitted value is malformed. + */ + export function normalizePhoneValue( + value: unknown, + dialing_codes: Array<{ region_code: string, value: string }>, + ): { dialing_code: string, value: string } { + const defaultDialingCode = getDefaultDialingCode(dialing_codes); + + if (isPlainObject(value)) { + // `number` is the key the phone field emits on input, so accept it too: + // a value read back off a `field-change` event can be fed straight in. + const object = value as { dialing_code?: string, value?: string, number?: string }; + return { + dialing_code: object.dialing_code || defaultDialingCode, + value: digitsOnly(object.value || object.number || ''), + }; + } + + if (typeof value !== 'string') { + return { dialing_code: defaultDialingCode, value: '' }; + } + + // Strip separators the docs allow around an E.164 number ("+48 123 123 123"). + const compact = value.replace(/[^\d+]/g, ''); + + if (compact.charAt(0) !== '+') { + return { dialing_code: defaultDialingCode, value: digitsOnly(compact) }; + } + + // Longest prefix first, so "+1" doesn't win over "+1242". + const matches = (dialing_codes || []) + .filter(code => code.value && compact.indexOf(code.value) === 0) + .sort((a, b) => b.value.length - a.value.length); + + if (matches.length === 0) { + // The gateway doesn't offer this country. Keep the digits so the merchant + // sees what was passed rather than silently dropping it. + return { dialing_code: defaultDialingCode, value: digitsOnly(compact) }; + } + + return { + dialing_code: matches[0].value, + value: digitsOnly(compact.substring(matches[0].value.length)), + }; + } + + function digitsOnly(value: string): string { + return value.replace(/\D/g, ''); + } + /** * Simple hash function for content comparison (djb2 algorithm) * @param str - String to hash diff --git a/src/apm/views/NextSteps.ts b/src/apm/views/NextSteps.ts index aeb92055..5548c092 100644 --- a/src/apm/views/NextSteps.ts +++ b/src/apm/views/NextSteps.ts @@ -37,18 +37,16 @@ module ProcessOut { state.values = forms.reduce((acc, form) => { form.parameters.parameter_definitions.forEach(param => { - // Check for prefilled data from initialData - const initialData = ContextImpl.context.initialData; - const prefilledValue = initialData && initialData[param.key]; + // Check for prefilled data from initialData, by gateway parameter key or + // by canonical key for the parameter type (email, phone_number). + const prefilledValue = resolvePrefilledValue(ContextImpl.context.initialData, param); // If we have prefilled data, use it and exit early if (prefilledValue) { - // Special handling for phone numbers - convert string to expected object format - if (param.type === 'phone' && typeof prefilledValue === 'string') { - acc[param.key] = { - dialing_code: param.dialing_codes[0].value, - value: prefilledValue, - }; + // Phone accepts an E.164 string or an object; both need splitting into + // the { dialing_code, value } shape the field renders. + if (param.type === 'phone') { + acc[param.key] = normalizePhoneValue(prefilledValue, param.dialing_codes); } else { acc[param.key] = prefilledValue; } diff --git a/test/apm/prefill.test.ts b/test/apm/prefill.test.ts new file mode 100644 index 00000000..7df768aa --- /dev/null +++ b/test/apm/prefill.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from "vitest" +import { loadApmUtils, FakeNavigator } from "../support/loadNamespace" + +type DialingCode = { region_code: string; value: string } + +const CODES: DialingCode[] = [ + { region_code: "PL", value: "+48" }, + { region_code: "GB", value: "+44" }, + { region_code: "US", value: "+1" }, + { region_code: "BS", value: "+1242" }, +] + +function normalizePhoneValue( + value: unknown, + dialingCodes: DialingCode[] = CODES, + navigator: FakeNavigator = { language: "en-GB" }, +): { dialing_code: string; value: string } { + return loadApmUtils(navigator).normalizePhoneValue(value, dialingCodes) +} + +function resolvePrefilledValue( + initialData: object | undefined, + param: { key: string; type: string }, +): unknown { + return loadApmUtils({}).resolvePrefilledValue(initialData, param) +} + +describe("normalizePhoneValue", () => { + it("splits a bare E.164 string into dialing code and national number", () => { + expect(normalizePhoneValue("+48123123123")).toEqual({ + dialing_code: "+48", + value: "123123123", + }) + }) + + it("ignores separators in an E.164 string", () => { + expect(normalizePhoneValue("+44 7700 900123")).toEqual({ + dialing_code: "+44", + value: "7700900123", + }) + expect(normalizePhoneValue("+44 (7700) 900-123")).toEqual({ + dialing_code: "+44", + value: "7700900123", + }) + }) + + it("prefers the longest matching dialing code", () => { + expect(normalizePhoneValue("+1242570000")).toEqual({ + dialing_code: "+1242", + value: "570000", + }) + expect(normalizePhoneValue("+12025550123")).toEqual({ + dialing_code: "+1", + value: "2025550123", + }) + }) + + it("falls back to the locale default when the gateway has no matching code", () => { + expect(normalizePhoneValue("+33612345678")).toEqual({ + dialing_code: "+44", + value: "33612345678", + }) + }) + + it("uses the locale default for a national-format string", () => { + expect(normalizePhoneValue("07700900123")).toEqual({ + dialing_code: "+44", + value: "07700900123", + }) + }) + + it("passes through the object form", () => { + expect( + normalizePhoneValue({ dialing_code: "+48", value: "123123123" }), + ).toEqual({ dialing_code: "+48", value: "123123123" }) + }) + + it("accepts the `number` key the phone field emits on input", () => { + expect( + normalizePhoneValue({ dialing_code: "+48", number: "123123123" }), + ).toEqual({ dialing_code: "+48", value: "123123123" }) + }) + + it("fills in the locale default when the object omits the dialing code", () => { + expect(normalizePhoneValue({ value: "7700900123" })).toEqual({ + dialing_code: "+44", + value: "7700900123", + }) + }) + + it("returns an empty number for a non-string, non-object value", () => { + expect(normalizePhoneValue(undefined)).toEqual({ + dialing_code: "+44", + value: "", + }) + expect(normalizePhoneValue(42)).toEqual({ dialing_code: "+44", value: "" }) + }) +}) + +describe("resolvePrefilledValue", () => { + it("matches the gateway parameter key exactly", () => { + expect( + resolvePrefilledValue( + { customerPhone: "+48123123123" }, + { key: "customerPhone", type: "phone" }, + ), + ).toBe("+48123123123") + }) + + it("matches the canonical key by parameter type", () => { + expect( + resolvePrefilledValue( + { phone_number: "+48123123123" }, + { key: "customerPhone", type: "phone" }, + ), + ).toBe("+48123123123") + + expect( + resolvePrefilledValue( + { email: "a@b.com" }, + { key: "customerEmail", type: "email" }, + ), + ).toBe("a@b.com") + }) + + it("prefers an exact key match over the canonical key", () => { + expect( + resolvePrefilledValue( + { phone_number: "+48123123123", customerPhone: "+441234567890" }, + { key: "customerPhone", type: "phone" }, + ), + ).toBe("+441234567890") + }) + + it("does not apply a canonical key to an unrelated parameter type", () => { + expect( + resolvePrefilledValue( + { phone_number: "+48123123123" }, + { key: "documentNumber", type: "text" }, + ), + ).toBeUndefined() + }) + + it("returns undefined when there is nothing to prefill", () => { + expect( + resolvePrefilledValue({}, { key: "customerPhone", type: "phone" }), + ).toBeUndefined() + expect( + resolvePrefilledValue(undefined, { key: "customerPhone", type: "phone" }), + ).toBeUndefined() + }) + + it("keeps falsy-but-present exact values distinguishable from absent ones", () => { + expect( + resolvePrefilledValue({ agreed: false }, { key: "agreed", type: "boolean" }), + ).toBe(false) + }) +}) From 8b1eb79dc5e15b3190bff74e4188c5912ab81637 Mon Sep 17 00:00:00 2001 From: Roshan Gorasia Date: Tue, 8 Sep 2026 11:08:27 +0100 Subject: [PATCH 2/2] fix: deliver the prefilled phone value to the field and the API The prefill computed in NextSteps never reached the phone field. The field reads a `value` prop; the form's only call site passed it as `number`, and `Props`'s `[key: string]: any` index signature let the misnamed prop typecheck and vanish into the input's attributes. A prefilled phone rendered as an empty box on the locale default country. `normalizePhoneValue` also returned the pre-#225 `value` key, so a prefilled phone submitted without the user touching the field posted `value` where the API expects `number`. It now emits `number`, matching the declared `PhoneState` and the field's own `oninput`; both keys are still accepted as input, so the documented `initialData` object form keeps working. `validateField` read `value` too, which made `required` pass vacuously for any phone once the field had emitted its own shape. It now reads the national number under either key, so an empty phone is caught. Co-Authored-By: Claude Opus 5 (1M context) --- package.json | 2 +- src/apm/elements/phone.ts | 9 +- src/apm/utils.ts | 20 ++-- src/apm/views/utils/form.ts | 9 +- test/apm/phone-prefill-wiring.test.ts | 145 ++++++++++++++++++++++++++ test/apm/prefill.test.ts | 28 ++--- test/support/loadNamespace.ts | 119 +++++++++++++++++++++ 7 files changed, 300 insertions(+), 32 deletions(-) create mode 100644 test/apm/phone-prefill-wiring.test.ts diff --git a/package.json b/package.json index faa35f1b..c27f4ba5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "processout.js", - "version": "1.9.11", + "version": "1.9.12", "description": "ProcessOut.js is a JavaScript library for ProcessOut's payment processing API.", "scripts": { "build:processout": "tsc -p src/processout && uglifyjs --compress --keep-fnames --ie8 dist/processout.js -o dist/processout.js", diff --git a/src/apm/elements/phone.ts b/src/apm/elements/phone.ts index 996cbf81..620b1fc6 100644 --- a/src/apm/elements/phone.ts +++ b/src/apm/elements/phone.ts @@ -9,7 +9,7 @@ module ProcessOut { }> oninput?: FormFieldUpdate, onblur?: (key: string, value: { dialing_code: string, number: string }) => void, - value?: { dialing_code: string, value: string }, + value?: { dialing_code?: string, number?: string }, } const { div, label: labelEl, img, input, select, option } = elements @@ -61,7 +61,7 @@ module ProcessOut { // Use StateManager for internal state management const { state, setState } = useComponentState({ dialing_code: value && value.dialing_code || getDefaultDialingCode(dialing_codes), - number: value && value.value || '', + number: value && value.number || '', iso: '' }); @@ -101,9 +101,10 @@ module ProcessOut { dialingCodesRef.value = iso; } - // Trigger callback to update form state if there's a value + // Seed the form in the wire shape. `state` also carries `iso`, which is + // internal to the flag picker and isn't a gateway parameter. if (value) { - oninput && oninput(name, state, true); + oninput && oninput(name, { dialing_code: dialingCode, number: phoneNumber }, true); } setState({ diff --git a/src/apm/utils.ts b/src/apm/utils.ts index 02523bbe..301dab61 100644 --- a/src/apm/utils.ts +++ b/src/apm/utils.ts @@ -90,8 +90,8 @@ module ProcessOut { } /** - * Coerce a prefilled phone value into the `{ dialing_code, value }` shape the - * phone field renders. + * Coerce a prefilled phone value into the `{ dialing_code, number }` shape the + * phone field renders and submits. * * Accepts the object form and a bare E.164 string (`"+48123123123"`). The * string is split on a longest-prefix match against the gateway's own dialing @@ -102,28 +102,28 @@ module ProcessOut { export function normalizePhoneValue( value: unknown, dialing_codes: Array<{ region_code: string, value: string }>, - ): { dialing_code: string, value: string } { + ): { dialing_code: string, number: string } { const defaultDialingCode = getDefaultDialingCode(dialing_codes); if (isPlainObject(value)) { - // `number` is the key the phone field emits on input, so accept it too: - // a value read back off a `field-change` event can be fed straight in. + // `value` is the key the prefill docs use, `number` the one the field emits + // on input, so a value read off a `field-change` event feeds straight back in. const object = value as { dialing_code?: string, value?: string, number?: string }; return { dialing_code: object.dialing_code || defaultDialingCode, - value: digitsOnly(object.value || object.number || ''), + number: digitsOnly(object.value || object.number || ''), }; } if (typeof value !== 'string') { - return { dialing_code: defaultDialingCode, value: '' }; + return { dialing_code: defaultDialingCode, number: '' }; } // Strip separators the docs allow around an E.164 number ("+48 123 123 123"). const compact = value.replace(/[^\d+]/g, ''); if (compact.charAt(0) !== '+') { - return { dialing_code: defaultDialingCode, value: digitsOnly(compact) }; + return { dialing_code: defaultDialingCode, number: digitsOnly(compact) }; } // Longest prefix first, so "+1" doesn't win over "+1242". @@ -134,12 +134,12 @@ module ProcessOut { if (matches.length === 0) { // The gateway doesn't offer this country. Keep the digits so the merchant // sees what was passed rather than silently dropping it. - return { dialing_code: defaultDialingCode, value: digitsOnly(compact) }; + return { dialing_code: defaultDialingCode, number: digitsOnly(compact) }; } return { dialing_code: matches[0].value, - value: digitsOnly(compact.substring(matches[0].value.length)), + number: digitsOnly(compact.substring(matches[0].value.length)), }; } diff --git a/src/apm/views/utils/form.ts b/src/apm/views/utils/form.ts index ad2ddb38..868045c1 100644 --- a/src/apm/views/utils/form.ts +++ b/src/apm/views/utils/form.ts @@ -25,8 +25,11 @@ module ProcessOut { } let actualValue; - if (isPlainObject(value) && 'value' in value) { - actualValue = value.value; + if (isPlainObject(value)) { + // Phone holds { dialing_code, number } — validate the national number. + // `value` is the pre-#225 key, still accepted from merchant `initialData`. + const phone = value as { number?: string, value?: string }; + actualValue = phone.number !== undefined ? phone.number : phone.value; } else { actualValue = value; } @@ -192,7 +195,7 @@ module ProcessOut { onblur: onBlur(setState), errored: !!error, disabled: state.loading, - number: value as PhoneState, + value: value as PhoneState, }); break; } diff --git a/test/apm/phone-prefill-wiring.test.ts b/test/apm/phone-prefill-wiring.test.ts new file mode 100644 index 00000000..8659231c --- /dev/null +++ b/test/apm/phone-prefill-wiring.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest" +import { loadApmForm, loadApmPhone, VNodeStub } from "../support/loadNamespace" + +const CODES = [ + { region_code: "GB", value: "+44", name: "United Kingdom" }, + { region_code: "PL", value: "+48", name: "Poland" }, +] + +const PHONE_FIELD = { + type: "phone", + key: "customerPhone", + label: "Phone", + required: true, + dialing_codes: CODES, +} + +function formState(values: Record, validation: Record = {}) { + return { + loading: false, + form: { values, errors: {}, validation, touched: {} }, + } +} + +function validate(values: Record) { + const { validateForm } = loadApmForm() + let next: any + const ok = validateForm( + formState(values, { customerPhone: { required: true } }), + (fn: (prev: any) => any) => { next = fn(formState(values)) }, + ) + return { ok, errors: next.form.errors } +} + +function renderForm(values: Record) { + const { Form, phoneProps } = loadApmForm() + Form( + { parameters: { parameter_definitions: [PHONE_FIELD] } }, + formState(values), + () => undefined, + () => undefined, + ) + return phoneProps +} + +function findInput(node: VNodeStub | null): VNodeStub | undefined { + if (!node || typeof node !== "object") return undefined + if (node.tag === "input") return node + return (node.children || []) + .map(child => findInput(child as VNodeStub)) + .filter(Boolean)[0] +} + +describe("form -> Phone prop wiring", () => { + it("hands the stored phone state to the prop the field reads", () => { + const props = renderForm({ + customerPhone: { dialing_code: "+48", number: "123123123" }, + }) + + expect(props).toHaveLength(1) + expect(props[0].value).toEqual({ dialing_code: "+48", number: "123123123" }) + }) + + it("does not pass the phone state under any other prop name", () => { + const props = renderForm({ + customerPhone: { dialing_code: "+48", number: "123123123" }, + }) + + expect(props[0].number).toBeUndefined() + }) +}) + +describe("Phone field initialisation", () => { + it("renders the prefilled dialing code and national number", () => { + const { Phone } = loadApmPhone() + const input = findInput( + Phone({ + name: "customerPhone", + dialing_codes: CODES, + value: { dialing_code: "+48", number: "123123123" }, + }), + ) + + expect(input!.props.value).toBe("+48 123 123 123") + }) + + it("falls back to the default dialing code with no prefill", () => { + const { Phone } = loadApmPhone() + const input = findInput( + Phone({ name: "customerPhone", dialing_codes: CODES }), + ) + + expect(input!.props.value).toBe("+44 ") + }) + + it("seeds the form with the wire shape, without the internal iso field", () => { + const { Phone, emitted } = loadApmPhone() + Phone({ + name: "customerPhone", + dialing_codes: CODES, + value: { dialing_code: "+48", number: "123123123" }, + }) + + expect(emitted).toEqual([ + { + key: "customerPhone", + value: { dialing_code: "+48", number: "123123123" }, + isInitial: true, + }, + ]) + }) + + it("does not seed the form when there is nothing prefilled", () => { + const { Phone, emitted } = loadApmPhone() + Phone({ name: "customerPhone", dialing_codes: CODES }) + + expect(emitted).toEqual([]) + }) +}) + +describe("phone required validation", () => { + it("rejects a phone holding only a dialing code", () => { + const { ok, errors } = validate({ + customerPhone: { dialing_code: "+44", number: "" }, + }) + + expect(ok).toBe(false) + expect(errors.customerPhone).toBe("Missing required value") + }) + + it("accepts a phone with a national number", () => { + const { ok } = validate({ + customerPhone: { dialing_code: "+48", number: "123123123" }, + }) + + expect(ok).toBe(true) + }) + + it("still reads the pre-#225 `value` key", () => { + const { ok } = validate({ + customerPhone: { dialing_code: "+48", value: "123123123" }, + }) + + expect(ok).toBe(true) + }) +}) diff --git a/test/apm/prefill.test.ts b/test/apm/prefill.test.ts index 7df768aa..4e2deb48 100644 --- a/test/apm/prefill.test.ts +++ b/test/apm/prefill.test.ts @@ -14,7 +14,7 @@ function normalizePhoneValue( value: unknown, dialingCodes: DialingCode[] = CODES, navigator: FakeNavigator = { language: "en-GB" }, -): { dialing_code: string; value: string } { +): { dialing_code: string; number: string } { return loadApmUtils(navigator).normalizePhoneValue(value, dialingCodes) } @@ -29,71 +29,71 @@ describe("normalizePhoneValue", () => { it("splits a bare E.164 string into dialing code and national number", () => { expect(normalizePhoneValue("+48123123123")).toEqual({ dialing_code: "+48", - value: "123123123", + number: "123123123", }) }) it("ignores separators in an E.164 string", () => { expect(normalizePhoneValue("+44 7700 900123")).toEqual({ dialing_code: "+44", - value: "7700900123", + number: "7700900123", }) expect(normalizePhoneValue("+44 (7700) 900-123")).toEqual({ dialing_code: "+44", - value: "7700900123", + number: "7700900123", }) }) it("prefers the longest matching dialing code", () => { expect(normalizePhoneValue("+1242570000")).toEqual({ dialing_code: "+1242", - value: "570000", + number: "570000", }) expect(normalizePhoneValue("+12025550123")).toEqual({ dialing_code: "+1", - value: "2025550123", + number: "2025550123", }) }) it("falls back to the locale default when the gateway has no matching code", () => { expect(normalizePhoneValue("+33612345678")).toEqual({ dialing_code: "+44", - value: "33612345678", + number: "33612345678", }) }) it("uses the locale default for a national-format string", () => { expect(normalizePhoneValue("07700900123")).toEqual({ dialing_code: "+44", - value: "07700900123", + number: "07700900123", }) }) - it("passes through the object form", () => { + it("accepts the documented `value` key in the object form", () => { expect( normalizePhoneValue({ dialing_code: "+48", value: "123123123" }), - ).toEqual({ dialing_code: "+48", value: "123123123" }) + ).toEqual({ dialing_code: "+48", number: "123123123" }) }) it("accepts the `number` key the phone field emits on input", () => { expect( normalizePhoneValue({ dialing_code: "+48", number: "123123123" }), - ).toEqual({ dialing_code: "+48", value: "123123123" }) + ).toEqual({ dialing_code: "+48", number: "123123123" }) }) it("fills in the locale default when the object omits the dialing code", () => { expect(normalizePhoneValue({ value: "7700900123" })).toEqual({ dialing_code: "+44", - value: "7700900123", + number: "7700900123", }) }) it("returns an empty number for a non-string, non-object value", () => { expect(normalizePhoneValue(undefined)).toEqual({ dialing_code: "+44", - value: "", + number: "", }) - expect(normalizePhoneValue(42)).toEqual({ dialing_code: "+44", value: "" }) + expect(normalizePhoneValue(42)).toEqual({ dialing_code: "+44", number: "" }) }) }) diff --git a/test/support/loadNamespace.ts b/test/support/loadNamespace.ts index e06ade4b..048a4ffd 100644 --- a/test/support/loadNamespace.ts +++ b/test/support/loadNamespace.ts @@ -127,3 +127,122 @@ export function loadDynamicCheckout(): DynamicCheckoutNamespace { return { namespace: moduleShim.exports, dispatchedEvents } } + +export interface VNodeStub { + tag: string + props: Record + children: any[] +} + +function elementsStub(): Record { + const tags = ["div", "label", "form", "input", "select", "option", "img", "span"] + const stub: Record = {} + tags.forEach(tag => { + stub[tag] = (props: any = {}, ...children: any[]): VNodeStub => ({ tag, props, children }) + }) + return stub +} + +export interface ApmFormHarness { + Form: (...args: any[]) => VNodeStub + validateForm: (state: any, setState: (fn: (prev: any) => any) => void) => boolean + phoneProps: Array> +} + +/** + * Load `src/apm/views/utils/form.ts` with its field components stubbed, so a + * test can assert which props a field type is actually handed. Guards the + * prop-name contract between the form and the components: `Props` carries an + * `[key: string]: any` index signature, so a misnamed prop typechecks and is + * silently swallowed into the element's attributes (see #277). + */ +export function loadApmForm(): ApmFormHarness { + const phoneProps: Array> = [] + const componentStub = () => ({ tag: "stub", props: {}, children: [] }) + + const scope: Record = { + elements: elementsStub(), + Phone: (props: Record) => { + phoneProps.push(props) + return componentStub() + }, + OTP: componentStub, + Select: componentStub, + Checkbox: componentStub, + Input: componentStub, + isPlainObject: (v: unknown) => + v !== null && typeof v === "object" && !Array.isArray(v), + isEmpty: (v: any) => Object.keys(v).length === 0, + createGroupedElements: (items: any[], _group: any, render: (item: any) => any) => + items.map(render), + ContextImpl: { context: { events: { emit: () => undefined } } }, + // Only reached on the validation-failure path, to scroll to the first error. + requestAnimationFrame: () => 0, + scrollTo: () => undefined, + } + + const names = Object.keys(scope) + const moduleShim = { exports: {} as Record } + const run = new Function( + "module", + "exports", + ...names, + `${compile("src/apm/views/utils/form.ts")}\nmodule.exports = ProcessOut;`, + ) + run(moduleShim, moduleShim.exports, ...names.map(n => scope[n])) + + return { + Form: moduleShim.exports.Form, + validateForm: moduleShim.exports.validateForm, + phoneProps, + } +} + +export interface ApmPhoneHarness { + Phone: (props: Record) => VNodeStub | null + emitted: Array<{ key: string; value: any; isInitial?: boolean }> +} + +/** + * Load `src/apm/elements/phone.ts` with a synchronous `loadScript` and a + * pass-through component state, and record what the field emits back to the + * form on initialisation. + */ +export function loadApmPhone(navigator: FakeNavigator = { language: "en-GB" }): ApmPhoneHarness { + const emitted: Array<{ key: string; value: any; isInitial?: boolean }> = [] + + const scope: Record = { + elements: elementsStub(), + navigator, + // No libphonenumber: the field falls back to its manual region lookup. + window: {}, + useComponentState: (initial: Record) => ({ + state: initial, + setState: () => undefined, + }), + getDefaultDialingCode: (codes: Array<{ value: string }>) => + (codes && codes[0] && codes[0].value) || "", + ContextImpl: { + context: { page: { loadScript: (_n: string, _u: string, cb: () => void) => cb() } }, + }, + } + + const names = Object.keys(scope) + const moduleShim = { exports: {} as Record } + const run = new Function( + "module", + "exports", + ...names, + `${compile("src/apm/elements/phone.ts")}\nmodule.exports = ProcessOut;`, + ) + run(moduleShim, moduleShim.exports, ...names.map(n => scope[n])) + + const Phone = (props: Record) => + moduleShim.exports.Phone({ + ...props, + oninput: (key: string, value: any, isInitial?: boolean) => + emitted.push({ key, value, isInitial }), + }) + + return { Phone, emitted } +}