diff --git a/package.json b/package.json index 4b6112a..faa35f1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "processout.js", - "version": "1.9.10", + "version": "1.9.11", "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 996cbf8..1bc0012 100644 --- a/src/apm/elements/phone.ts +++ b/src/apm/elements/phone.ts @@ -243,6 +243,15 @@ module ProcessOut { if (currentValue.length < getDialingCode(dialingCode).length) { phoneNumber = cleanNumber; + // Propagate the (now empty/cleared) number to the form so validation + // reflects it — otherwise clearing the field back into the dialing-code + // prefix leaves a stale value in the form and required checks pass. + if (state.number !== phoneNumber) { + oninput && oninput(name, { + dialing_code: dialingCode, + number: phoneNumber, + }); + } dialingCodesRef.focus() dialingCodesRef.showPicker() setState({ diff --git a/src/apm/utils.ts b/src/apm/utils.ts index 55fd75b..e557b1d 100644 --- a/src/apm/utils.ts +++ b/src/apm/utils.ts @@ -81,6 +81,29 @@ module ProcessOut { return proto === null || proto === Object.prototype; } + /** + * Normalise a form field value to the scalar used for validation. + * + * Most fields are primitives, but the phone field is an object. The Phone + * component emits the number under `number` once the shopper interacts, while + * the initial/prefilled seed carries it under the legacy `value` key. We read + * whichever is present (`number` first, as it is the canonical key), otherwise + * validation (e.g. the required check) sees a non-empty object and silently + * passes even when the number has been cleared. + */ + export function getComparableFieldValue(value: unknown): unknown { + if (isPlainObject(value)) { + const record = value as Record; + if ('number' in record) { + return record.number; + } + if ('value' in record) { + return record.value; + } + } + return value; + } + export const isEmpty = (value: Record | Array): boolean => { if (Array.isArray(value)) { return value.length === 0; diff --git a/src/apm/views/utils/form.ts b/src/apm/views/utils/form.ts index ad2ddb3..26c4e34 100644 --- a/src/apm/views/utils/form.ts +++ b/src/apm/views/utils/form.ts @@ -24,12 +24,7 @@ module ProcessOut { return } - let actualValue; - if (isPlainObject(value) && 'value' in value) { - actualValue = value.value; - } else { - actualValue = value; - } + const actualValue = getComparableFieldValue(value); switch (true) { case validation.required && diff --git a/test/apm/phone-validation.test.ts b/test/apm/phone-validation.test.ts new file mode 100644 index 0000000..1b2ac65 --- /dev/null +++ b/test/apm/phone-validation.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest" +import { loadApmUtils } from "../support/loadNamespace" + +const { getComparableFieldValue } = loadApmUtils() + +/** + * Regression coverage for the phone "required" validation bug: the Phone + * component emits its value under `number`, while the initial/prefilled seed + * uses the legacy `value` key. The validator normalises both via + * getComparableFieldValue, so an empty phone number is recognised as empty + * regardless of which shape is present (previously the `number` shape was + * treated as a non-empty object and the required check silently passed). + */ +describe("getComparableFieldValue", () => { + it("returns primitives unchanged", () => { + expect(getComparableFieldValue("hello")).toBe("hello") + expect(getComparableFieldValue("")).toBe("") + expect(getComparableFieldValue(0)).toBe(0) + expect(getComparableFieldValue(undefined)).toBe(undefined) + }) + + it("reads the number from the emitted `number` shape", () => { + expect( + getComparableFieldValue({ dialing_code: "+44", number: "7911123456" }), + ).toBe("7911123456") + }) + + it("reads the number from the legacy seed/prefill `value` shape", () => { + expect( + getComparableFieldValue({ dialing_code: "+44", value: "7911123456" }), + ).toBe("7911123456") + expect(getComparableFieldValue({ dialing_code: "+44", value: "" })).toBe("") + }) + + it("returns an empty string for a cleared `number` (the reported bug)", () => { + // Shopper typed a number then removed it: the value is now { number: "" }. + // This must resolve to "" so the required check fires. + expect(getComparableFieldValue({ dialing_code: "+44", number: "" })).toBe("") + }) + + it("prefers the canonical `number` when both keys are present", () => { + expect( + getComparableFieldValue({ value: "from-value", number: "from-number" }), + ).toBe("from-number") + }) + + it("returns the object unchanged when neither key is present", () => { + const obj = { dialing_code: "+44" } + expect(getComparableFieldValue(obj)).toBe(obj) + }) +}) + +/** + * Mirrors form.ts validateField's required rule against the normalised value, + * documenting the end-to-end expectation the SDK relies on. + */ +function isMissingRequired(value: unknown): boolean { + const actualValue = getComparableFieldValue(value) + return ( + typeof value === "undefined" || + (typeof actualValue === "string" && actualValue.length === 0) + ) +} + +describe("required check on phone values", () => { + it("flags an empty phone number in either shape", () => { + expect(isMissingRequired({ dialing_code: "+44", value: "" })).toBe(true) + expect(isMissingRequired({ dialing_code: "+44", number: "" })).toBe(true) + }) + + it("accepts a provided phone number in either shape", () => { + expect(isMissingRequired({ dialing_code: "+44", value: "7911123456" })).toBe( + false, + ) + expect( + isMissingRequired({ dialing_code: "+44", number: "7911123456" }), + ).toBe(false) + }) +})