diff --git a/sdk/src/components/channel-form.tsx b/sdk/src/components/channel-form.tsx index fd38677..f64185a 100644 --- a/sdk/src/components/channel-form.tsx +++ b/sdk/src/components/channel-form.tsx @@ -5,6 +5,7 @@ import { useContext, useEffect, useImperativeHandle, + useLayoutEffect, useMemo, useRef, useState, @@ -74,6 +75,12 @@ const ChannelForm = forwardRef( onChannelPropertiesChanged(channelProperties); }, [getChannelProperties, onChannelPropertiesChanged]); + // call onChannelPropertiesChanged once on init + useLayoutEffect(() => { + handleFieldChanged(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const filteredForm = useFilteredFormFields( session, form, @@ -234,7 +241,13 @@ function formKvToChannelProperties( // assign next value to channel properties const nextValue = valueAsArray.length ? valueAsArray.shift() : ""; - cursor[parts[0]] = nextValue; + + // wrap in array if the subkey ends in [] + if (parts[0].endsWith("[]")) { + cursor[parts[0].slice(0, -2)] = [nextValue]; + } else { + cursor[parts[0]] = nextValue; + } } } diff --git a/sdk/src/components/field-country.tsx b/sdk/src/components/field-country.tsx index ba0c22c..8886cde 100644 --- a/sdk/src/components/field-country.tsx +++ b/sdk/src/components/field-country.tsx @@ -36,7 +36,8 @@ export const CountryField: FunctionComponent = (props) => { const [selectedCountry, setSelectedCountry] = useState< CountryCode | undefined - >(undefined); + >(field.initial_value as CountryCode | undefined); + const selectedCountryIndex = COUNTRIES_AS_DROPDOWN_OPTIONS.findIndex( (option) => option.value === selectedCountry, ); @@ -63,6 +64,17 @@ export const CountryField: FunctionComponent = (props) => { [onChange], ); + // on first render populate hidden field with initial value and notify parent of change + useLayoutEffect(() => { + if (field.initial_value) { + if (hiddenFieldRef.current) { + hiddenFieldRef.current.value = selectedCountry || ""; + } + onChange(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return (
diff --git a/sdk/src/components/field-dropdown.tsx b/sdk/src/components/field-dropdown.tsx index f44df1e..d763fa3 100644 --- a/sdk/src/components/field-dropdown.tsx +++ b/sdk/src/components/field-dropdown.tsx @@ -18,6 +18,16 @@ const toDropdownOptions = ( title: opt.label, description: opt.subtitle, value: opt.value, + leadingAsset: opt.icon_url ? ( + + ) : null, })); }; @@ -37,7 +47,7 @@ export const DropdownField: FunctionComponent = (props) => { }, [field.type.options]); const [selectedItemValue, setSelectedItemValue] = useState( - dropdownItems[0]?.value ?? "", + field.initial_value ?? "", ); const onChangeWrapper = useCallback( @@ -51,16 +61,18 @@ export const DropdownField: FunctionComponent = (props) => { [onChange], ); - useLayoutEffect(() => { - // first render only, force select first option - if (dropdownItems.length) onChangeWrapper(dropdownItems[0]); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - const selectedIndex = dropdownItems.findIndex( (opt) => opt.value === selectedItemValue, ); + // call onChange once on init + useLayoutEffect(() => { + if (field.initial_value) { + onChangeWrapper(dropdownItems[selectedIndex]); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return ( <> = (props) => { const hiddenFieldRef = useRef(null); - const [countryCode, setCountryCode] = useState(session.country); + const initial = useMemo( + () => initialValues(field.initial_value, session.country), + [field.initial_value, session.country], + ); + + const [countryCode, setCountryCode] = useState(initial.country); const countryCodeIndex = useMemo(() => { const index = COUNTRIES_WITH_DIAL_CODES_AS_DROPDOWN_OPTIONS.findIndex( (r) => r.value === countryCode, @@ -36,7 +47,7 @@ export const PhoneNumberField: FunctionComponent = (props) => { const country = COUNTRIES_WITH_DIAL_CODES_AS_DROPDOWN_OPTIONS[countryCodeIndex]; - const [localNumber, setLocalNumber] = useState(""); + const [localNumber, setLocalNumber] = useState(initial.localNumber); const inputRef = useRef(null); const formatPhoneNumber = useCallback( @@ -104,20 +115,31 @@ export const PhoneNumberField: FunctionComponent = (props) => { ); } - function formatForUser() { - const phoneNumber = sanitizePhoneNumber(country, localNumber); + function formatForUser(_country = country, _localNumber = localNumber) { + const phoneNumber = sanitizePhoneNumber(_country, _localNumber); if (phoneNumber) { const international = phoneNumber.formatInternational(); // remove country dial code from displayed local number setLocalNumber( international.replace( - `+${getCountryCallingCode(country.value as CountryCode)} `, + `+${getCountryCallingCode(_country.value as CountryCode)} `, "", ), ); } } + // on first render, populate hidden input and notify parent component of initial value + useLayoutEffect(() => { + if (field.initial_value) { + if (hiddenFieldRef.current) { + hiddenFieldRef.current.value = field.initial_value; + } + onChange(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return (
option.value === parsed.country, + ); + if (!countryOption) return defaultInitial; + const sanitized = sanitizePhoneNumber(countryOption, parsed.nationalNumber); + if (!sanitized) return defaultInitial; + const international = parsed.formatInternational(); + const countryCode = getCountryCallingCode(countryOption.value as CountryCode); + return { + country: countryOption.value as string, + localNumber: international.replace(`+${countryCode} `, ""), + }; +} diff --git a/sdk/src/components/field-province.tsx b/sdk/src/components/field-province.tsx index 659ac9b..eb12352 100644 --- a/sdk/src/components/field-province.tsx +++ b/sdk/src/components/field-province.tsx @@ -1,4 +1,4 @@ -import { useRef, useCallback, useLayoutEffect } from "preact/hooks"; +import { useRef, useCallback, useLayoutEffect, useState } from "preact/hooks"; import { FieldProps } from "./field"; import { CountryCode } from "libphonenumber-js"; import { Dropdown, DropdownOption } from "./core/dropdown"; @@ -27,9 +27,12 @@ export const ProvinceField: FunctionComponent = (props) => { const allFields = useChannel()?.form; const channelProperties = useChannelProperties(); + const [value, setValue] = useState(field.initial_value as string); + const hiddenFieldRef = useRef(null); const clearValue = useCallback(() => { + setValue(""); if (hiddenFieldRef.current) { hiddenFieldRef.current.value = ""; } @@ -38,6 +41,7 @@ export const ProvinceField: FunctionComponent = (props) => { const onChangeDropdown = useCallback( (option: DropdownOption) => { + setValue(option.value); if (hiddenFieldRef.current) { hiddenFieldRef.current.value = option.value; } @@ -49,6 +53,7 @@ export const ProvinceField: FunctionComponent = (props) => { const onChangeInput = useCallback( (e: TargetedEvent) => { + setValue(e.currentTarget.value); if (hiddenFieldRef.current) { hiddenFieldRef.current.value = (e.target as HTMLInputElement).value; } @@ -67,14 +72,38 @@ export const ProvinceField: FunctionComponent = (props) => { session, ), ); + const selectedOptionIndex = options + ? options.findIndex((option) => option.value === value) + : -1; - // if the option list changes, clear the selection + // if the options list changes, clear the value, + // but not on first render, + // or if the current value happens to be a valid option in the new list const previousOptions = usePrevious(options); + const didRenderOnce = useRef(false); useLayoutEffect(() => { - if (previousOptions !== options) { + if (!didRenderOnce.current) { + didRenderOnce.current = true; + return; + } + + // if options list changes, clear the selected value + if (options !== previousOptions) { + if (selectedOptionIndex !== -1) return; // ok, this is still valid clearValue(); } - }, [clearValue, options, previousOptions]); + }, [clearValue, options, previousOptions, selectedOptionIndex]); + + // on first render, populate hidden field and notify parent of initial value + useLayoutEffect(() => { + if (field.initial_value) { + if (hiddenFieldRef.current) { + hiddenFieldRef.current.value = value; + } + onChange(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); return ( <> @@ -84,6 +113,7 @@ export const ProvinceField: FunctionComponent = (props) => { key={objectId(options)} id={id} options={options} + selectedIndex={selectedOptionIndex} onChange={onChangeDropdown} placeholder={field.placeholder} enableSearch @@ -93,6 +123,7 @@ export const ProvinceField: FunctionComponent = (props) => { = (props) => { const name = formFieldName(field); const inputRef = useRef(null); + const [value, setValue] = useState(field.initial_value ?? ""); + function handleChange(event: TargetedEvent): void { + setValue(event.currentTarget.value); onChange(); } @@ -29,6 +32,7 @@ export const TextField: FunctionComponent = (props) => { type="text" placeholder={field.placeholder} className={`xendit-form-field-inner xendit-text-14`} + value={value} onBlur={handleBlur} onChange={handleChange} minLength={isTextField(field) ? field.type.min_length : undefined} diff --git a/sdk/src/data/test-data.ts b/sdk/src/data/test-data.ts index 7dd4ae7..2e9d485 100644 --- a/sdk/src/data/test-data.ts +++ b/sdk/src/data/test-data.ts @@ -805,6 +805,96 @@ export function makeTestBffData(): BffResponse { "All fields here are optional and blank values should pass validation.", ], }, + { + brand_name: "Initial Value Test", + channel_code: "UI_INITIAL_VALUE_TEST", + brand_logo_url: "https://placehold.co/48x48.png?text=Logo", + ui_group: "ui_tests", + allow_pay_without_save: false, + allow_save: false, + brand_color: "#000000", + min_amount: 1000, + max_amount: 100000000, + requires_customer_details: false, + form: [ + { + label: "Text", + placeholder: "Text", + type: { + name: "text", + max_length: 50, + }, + channel_property: "text_field", + required: false, + span: 2, + initial_value: "Initial value", + }, + { + label: "Phone Number", + placeholder: "123 123 123", + type: { + name: "phone_number", + }, + channel_property: "phone_number_field", + required: false, + span: 2, + initial_value: "+6581234567", + }, + { + label: "Email", + placeholder: "test@example.com", + type: { + name: "email", + }, + channel_property: "email_field", + required: false, + span: 2, + initial_value: "initial_value@test.com", + }, + { + label: "Postal Code", + placeholder: "123456", + type: { + name: "postal_code", + }, + channel_property: "postal_code_field", + required: false, + span: 2, + initial_value: "123456", + }, + { + label: "Country", + placeholder: "Select", + type: { + name: "country", + }, + channel_property: "country_field", + required: false, + span: 2, + initial_value: "SG", + }, + { + label: "Dropdown", + placeholder: "Select", + type: { + name: "dropdown", + options: [ + { label: "Option 1", value: "option_1" }, + { label: "Option 2", value: "option_2" }, + { label: "Option 3", value: "option_3" }, + ], + }, + channel_property: "dropdown_field", + required: false, + span: 2, + initial_value: "option_2", + }, + ], + instructions: [ + "This test demonstrates initial field values.", + "All fields here should be populated by default.", + ], + }, { brand_name: "Field Grouping Test", channel_code: "UI_FIELD_GROUPING_TEST", @@ -1036,6 +1126,7 @@ export function makeTestBffData(): BffResponse { channel_property: "country_field", required: true, span: 2, + initial_value: "US", }, { label: "State / Province", @@ -1047,6 +1138,7 @@ export function makeTestBffData(): BffResponse { required: true, span: 2, join: true, + initial_value: "CA", }, { group_label: @@ -1059,6 +1151,7 @@ export function makeTestBffData(): BffResponse { channel_property: "country_field_2", required: true, span: 1, + initial_value: "AU", }, { label: "State / Province", @@ -1070,6 +1163,7 @@ export function makeTestBffData(): BffResponse { required: true, span: 1, join: true, + initial_value: "NSW", }, ], instructions: [ diff --git a/sdk/src/emvco-qr.ts b/sdk/src/emvco-qr.ts index b5b4258..5bf0f11 100644 --- a/sdk/src/emvco-qr.ts +++ b/sdk/src/emvco-qr.ts @@ -37,9 +37,9 @@ export function emvcoQrTokenize( } let value = ""; let javascriptCharacters = 0; // javascript length of the value - let realCharacters = 0; // normal characters count as 2, surrogate pair halves count as 1 + let realCharacters = 0; // normal characters count as 1, surrogate pair halves count as 0.5 while (realCharacters < length) { - // read a character and increment valueChars unless it's a high surrogate pair + // read one javascript character const char = emvcoString.substring( 4 + javascriptCharacters, 5 + javascriptCharacters, diff --git a/sdk/src/styles.css b/sdk/src/styles.css index 0c491fc..61fca85 100644 --- a/sdk/src/styles.css +++ b/sdk/src/styles.css @@ -620,7 +620,6 @@ xendit-payment-channel[inert] { justify-content: space-between; cursor: pointer; padding: 0; - padding-left: 12px; outline: none; } @@ -634,6 +633,7 @@ xendit-payment-channel[inert] { .xendit-dropdown.xendit-dropdown-has-asset { grid-template-columns: auto 1fr auto; + padding-left: 12px; } .xendit-dropdown-channel-logo { @@ -648,6 +648,9 @@ xendit-payment-channel[inert] { .xendit-dropdown-text { padding: 12px; padding-right: 0; + white-space: nowrap; + min-width: 0; + overflow: clip; } .xendit-dropdown-chevron { diff --git a/sdk/src/validation.ts b/sdk/src/validation.ts index b5fd803..8de420c 100644 --- a/sdk/src/validation.ts +++ b/sdk/src/validation.ts @@ -208,10 +208,20 @@ export function getChannelPropertyValue( key: string, ): ChannelPropertyPrimative | ChannelPropertyPrimative[] | undefined { const parts = key.split("."); + const wantsArray = parts[0].endsWith("[]"); + if (wantsArray) { + if (parts.length !== 1) { + throw new Error("Array channel properties cannot have nested keys"); + } + parts[0] = parts[0].slice(0, -2); + } const value = channelProperties[parts[0]]; if (value === undefined) { return undefined; } + if (wantsArray && Array.isArray(value)) { + return value; + } if (typeof value !== "object" || Array.isArray(value)) { if (parts.length !== 1) { throw new Error( diff --git a/sdk/test-feature-level/channel-component-initial-values.test.ts b/sdk/test-feature-level/channel-component-initial-values.test.ts new file mode 100644 index 0000000..2fcfb26 --- /dev/null +++ b/sdk/test-feature-level/channel-component-initial-values.test.ts @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { XenditComponentsTest } from "../src"; +import { waitForEvent } from "./utils"; +import { assert } from "../src/utils"; +import { internal } from "../src/internal"; +import { screen } from "@testing-library/dom"; + +afterEach(() => { + document.body.replaceChildren(); +}); + +// !!! Channel form initialization must be synchronous, there must be no awaits in these tests + +describe("channel component initial values", () => { + it("should render without initial values", async () => { + const sdk = new XenditComponentsTest({}); + + await waitForEvent(sdk, "init"); + + const ch = sdk.getActiveChannels({ filter: "UI_INPUT_TEST" })[0]; + assert(ch); + document.body.appendChild(sdk.createChannelComponent(ch)); + + // do not sleep here, this should be synchronous + + const channelProperties = + sdk[internal].liveComponents.paymentChannels.get( + "UI_INPUT_TEST", + )?.channelProperties; + + // they should all be blank + expect(channelProperties).toEqual({ + card_number: "", + country_field: "", + cvn: "", + dropdown_field: "", + dropdown_field_with_icons: "", + email_field: "", + expiry_month: "", + expiry_year: "", + phone_number_field: "", + postal_code_field: "", + text_field: "", + }); + + // all the inputs should be blank + expect(screen.getByLabelText("Text")).toHaveValue(""); + expect(screen.getByLabelText("Phone Number")).toHaveValue(""); + expect(screen.getByLabelText("Email")).toHaveValue(""); + expect(screen.getByLabelText("Postal Code")).toHaveValue(""); + expect(screen.getByLabelText("Country").textContent).toBe("Select"); + expect(screen.getByLabelText("Dropdown").textContent).toBe("Select"); + expect(screen.getByLabelText("Dropdown With Icons").textContent).toBe( + "Select", + ); + }); + + it("should render with initial values", async () => { + const sdk = new XenditComponentsTest({}); + + await waitForEvent(sdk, "init"); + + const ch = sdk.getActiveChannels({ filter: "UI_INITIAL_VALUE_TEST" })[0]; + assert(ch); + document.body.appendChild(sdk.createChannelComponent(ch)); + + // do not sleep here + + const channelProperties = sdk[internal].liveComponents.paymentChannels.get( + "UI_INITIAL_VALUE_TEST", + )?.channelProperties; + + // the channel properties should be populated with the initial values + expect(channelProperties).toEqual({ + country_field: "SG", + dropdown_field: "option_2", + email_field: "initial_value@test.com", + phone_number_field: "+6581234567", + postal_code_field: "123456", + text_field: "Initial value", + }); + + // all the inputs should be populated + expect(screen.getByLabelText("Text")).toHaveValue("Initial value"); + expect(screen.getByLabelText("Phone Number")).toHaveValue("8123 4567"); + expect(screen.getByLabelText("Email")).toHaveValue( + "initial_value@test.com", + ); + expect(screen.getByLabelText("Postal Code")).toHaveValue("123456"); + expect(screen.getByLabelText("Country").textContent).toBe("Singapore"); + expect(screen.getByLabelText("Dropdown").textContent).toBe("Option 2"); + }); +}); diff --git a/sdk/test-feature-level/channel-component-province.test.ts b/sdk/test-feature-level/channel-component-province.test.ts new file mode 100644 index 0000000..77ab6b4 --- /dev/null +++ b/sdk/test-feature-level/channel-component-province.test.ts @@ -0,0 +1,92 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { XenditComponentsTest } from "../src"; +import { waitForEvent } from "./utils"; +import { assert, sleep } from "../src/utils"; +import { internal } from "../src/internal"; +import { screen } from "@testing-library/dom"; + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe("channel component state/province field test", () => { + it("should render state/province field with initial values", async () => { + const sdk = new XenditComponentsTest({ + componentsSdkKey: "test-client-key", + }); + + await waitForEvent(sdk, "init"); + + const ch = sdk.getActiveChannels({ filter: "UI_STATE_PROVINCE_TEST" })[0]; + assert(ch); + document.body.appendChild(sdk.createChannelComponent(ch)); + + // sleeping here isn't ideal but the province field relies on the country field's onChange to populate its options and that rerender is async even though it shouldn't be + await sleep(1); + + const channelProperties = sdk[internal].liveComponents.paymentChannels.get( + "UI_STATE_PROVINCE_TEST", + )?.channelProperties; + + // they should all be populated + expect(channelProperties).toEqual({ + country_field: "US", + province_field: "CA", + country_field_2: "AU", + province_field_2: "NSW", + }); + + // all the inputs should be blank + const buttons = screen.getAllByRole("button"); + const country1 = buttons[0]; + const province1 = buttons[1]; + const country2 = buttons[2]; + const province2 = screen.getAllByRole("textbox")[0]; + + expect(country1.textContent).toBe("United States"); + expect(province1.textContent).toBe("California"); + expect(country2.textContent).toBe("Australia"); + expect(province2).toHaveValue("NSW"); + }); + + it("should clear province when country changes", async () => { + const sdk = new XenditComponentsTest({ + componentsSdkKey: "test-client-key", + }); + + await waitForEvent(sdk, "init"); + + const ch = sdk.getActiveChannels({ filter: "UI_STATE_PROVINCE_TEST" })[0]; + assert(ch); + document.body.appendChild(sdk.createChannelComponent(ch)); + + // sleeping here isn't ideal but the province field relies on the country field's onChange to populate its options and that rerender is async even though it shouldn't be + await sleep(1); + + // test that changing from dropdown to text input clears the value + const buttons = screen.getAllByRole("button"); + const country1 = buttons[0]; + + await country1.click(); + const singaporeOption = screen.getByText("Singapore"); + await singaporeOption.click(); + + expect(screen.getAllByRole("textbox")).toHaveLength(2); // first province field should have switched to a text input + const province1 = screen.getAllByRole("textbox")[0]; + expect(province1).toHaveValue(""); + + // test changing text input to dropdown also clears the value + const country2 = buttons[2]; + await country2.click(); + const canadaOption = screen.getByText("Canada"); + await canadaOption.click(); + const province2 = screen.getAllByRole("button")[2]; // second province field should have switched to a dropdown + expect(province2.textContent).toBe("State / Province"); + + // test we can change the province + await province2.click(); + const ontarioOption = screen.getByText("Ontario"); + await ontarioOption.click(); + expect(screen.getAllByRole("button")[2].textContent).toBe("Ontario"); + }); +});