diff --git a/packages/offline-payment-integration/src/OfflinePaymentMethod.test.tsx b/packages/offline-payment-integration/src/OfflinePaymentMethod.test.tsx index 90f47d004a..f2b26c487c 100644 --- a/packages/offline-payment-integration/src/OfflinePaymentMethod.test.tsx +++ b/packages/offline-payment-integration/src/OfflinePaymentMethod.test.tsx @@ -1,9 +1,10 @@ import { createCheckoutService, createLanguageService } from '@bigcommerce/checkout-sdk'; import { createOfflinePaymentStrategy } from '@bigcommerce/checkout-sdk/integrations/offline'; +import { Formik } from 'formik'; import React from 'react'; import { type PaymentMethodProps } from '@bigcommerce/checkout/payment-integration-api'; -import { render } from '@bigcommerce/checkout/test-utils'; +import { render, screen } from '@bigcommerce/checkout/test-utils'; import OfflinePaymentMethod from './OfflinePaymentMethod'; import { getMethod } from './payment-method.mock'; @@ -72,4 +73,48 @@ describe('OfflinePaymentMethod', () => { await expect(checkoutService.deinitializePayment).rejects.toThrow('test error'); }); + + describe('formFieldsData', () => { + const formFieldsData = [ + { + name: 'Purchase Order', + id: 'purchaseOrderNumber', + required: true, + type: 'number', + fieldType: 'text', + }, + ]; + + it('returns null when formFieldsData is not present', () => { + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + it('returns null when formFieldsData is an empty array', () => { + const props = { + ...defaultProps, + method: { ...defaultProps.method, initializationData: { formFieldsData: [] } }, + }; + + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + it('renders fields from formFieldsData via PaymentFormFields', () => { + const props = { + ...defaultProps, + method: { ...defaultProps.method, initializationData: { formFieldsData } }, + }; + + render( + + + , + ); + + expect(screen.getByLabelText('Purchase Order')).toBeInTheDocument(); + }); + }); }); diff --git a/packages/offline-payment-integration/src/OfflinePaymentMethod.tsx b/packages/offline-payment-integration/src/OfflinePaymentMethod.tsx index 30ebee589a..e2e6d3e28d 100644 --- a/packages/offline-payment-integration/src/OfflinePaymentMethod.tsx +++ b/packages/offline-payment-integration/src/OfflinePaymentMethod.tsx @@ -1,11 +1,14 @@ import { createOfflinePaymentStrategy } from '@bigcommerce/checkout-sdk/integrations/offline'; -import { type FunctionComponent, useEffect } from 'react'; +import React, { type FunctionComponent, useEffect } from 'react'; import { + getPaymentFormFields, type PaymentMethodProps, toResolvableComponent, } from '@bigcommerce/checkout/payment-integration-api'; +import PaymentFormFields from './PaymentFormFields'; + const OfflinePaymentMethod: FunctionComponent = ({ method, checkoutService, @@ -46,7 +49,13 @@ const OfflinePaymentMethod: FunctionComponent = ({ }; }, [checkoutService, method.gateway, method.id, onUnhandledError]); - return null; + const formFields = getPaymentFormFields(method.initializationData?.formFieldsData); + + if (!formFields.length) { + return null; + } + + return ; }; export default toResolvableComponent(OfflinePaymentMethod, [ diff --git a/packages/offline-payment-integration/src/PaymentFormFields.test.tsx b/packages/offline-payment-integration/src/PaymentFormFields.test.tsx new file mode 100644 index 0000000000..5b6b47efbd --- /dev/null +++ b/packages/offline-payment-integration/src/PaymentFormFields.test.tsx @@ -0,0 +1,73 @@ +import { Formik } from 'formik'; +import React from 'react'; + +import { type PaymentFormField } from '@bigcommerce/checkout/payment-integration-api'; +import { fireEvent, render, screen } from '@bigcommerce/checkout/test-utils'; + +import PaymentFormFields from './PaymentFormFields'; + +const fields: PaymentFormField[] = [ + { + name: 'Purchase Order', + id: 'purchaseOrderNumber', + required: true, + type: 'number', + fieldType: 'text', + }, + { + name: 'Reference', + id: 'referenceText', + required: false, + type: 'text', + fieldType: 'text', + }, +]; + +const renderWithFormik = (ui: React.ReactElement) => + render( + + {ui} + , + ); + +describe('PaymentFormFields', () => { + it('renders a label and input for each field', () => { + renderWithFormik(); + + expect(screen.getByLabelText('Purchase Order')).toBeInTheDocument(); + expect(screen.getByLabelText('Reference')).toBeInTheDocument(); + }); + + it('uses numeric inputMode for number type fields', () => { + renderWithFormik(); + + expect(screen.getByLabelText('Purchase Order')).toHaveAttribute('inputmode', 'numeric'); + expect(screen.getByLabelText('Reference')).not.toHaveAttribute('inputmode'); + }); + + it('strips non-digit characters from a number field on change', () => { + renderWithFormik(); + + const input = screen.getByLabelText('Purchase Order'); + + fireEvent.change(input, { target: { value: '123abc' } }); + + expect((input as HTMLInputElement).value).toBe('123'); + }); + + it('shows a validation error for a required field left empty', async () => { + renderWithFormik(); + + fireEvent.blur(screen.getByLabelText('Purchase Order')); + + expect(await screen.findByText('Purchase Order is required')).toBeInTheDocument(); + }); + + it('does not show a validation error for an optional field left empty', () => { + renderWithFormik(); + + fireEvent.blur(screen.getByLabelText('Reference')); + + expect(screen.queryByText('Reference is required')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/offline-payment-integration/src/PaymentFormFields.tsx b/packages/offline-payment-integration/src/PaymentFormFields.tsx new file mode 100644 index 0000000000..6f23208c07 --- /dev/null +++ b/packages/offline-payment-integration/src/PaymentFormFields.tsx @@ -0,0 +1,55 @@ +import React, { type FunctionComponent } from 'react'; + +import { + getPaymentFormFields, + type PaymentFormField, +} from '@bigcommerce/checkout/payment-integration-api'; +import { BasicFormField, TextInput } from '@bigcommerce/checkout/ui'; + +export { getPaymentFormFields, type PaymentFormField }; + +const validateField = (value: string, field: PaymentFormField): string | undefined => { + if (field.required && !value) { + return `${field.name} is required`; + } + + if (field.type === 'number' && value && !/^\d+$/.test(value)) { + return `${field.name} must be a positive whole number`; + } +}; + +const PaymentFormFields: FunctionComponent<{ fields: PaymentFormField[] }> = ({ fields }) => ( + <> + {fields.map((field) => ( + ( +
+ + { + e.target.value = e.target.value.replace(/\D/g, ''); + fieldProps.onChange(e); + } + : fieldProps.onChange + } + type="text" + /> + {meta.touched && meta.error ? ( +

{meta.error}

+ ) : null} +
+ )} + validate={(value: string) => validateField(value, field)} + /> + ))} + +); + +export default PaymentFormFields; diff --git a/packages/payment-integration-api/src/PaymentFormFields.test.tsx b/packages/payment-integration-api/src/PaymentFormFields.test.tsx new file mode 100644 index 0000000000..0f6c32504a --- /dev/null +++ b/packages/payment-integration-api/src/PaymentFormFields.test.tsx @@ -0,0 +1,29 @@ +import { getPaymentFormFields, type PaymentFormField } from './PaymentFormFields'; + +const fields: PaymentFormField[] = [ + { + name: 'Purchase Order', + id: 'purchaseOrderNumber', + required: true, + type: 'number', + fieldType: 'text', + }, +]; + +describe('getPaymentFormFields', () => { + it('returns empty array when value is undefined', () => { + expect(getPaymentFormFields(undefined)).toEqual([]); + }); + + it('returns empty array when value is null', () => { + expect(getPaymentFormFields(null)).toEqual([]); + }); + + it('returns empty array when value is not an array', () => { + expect(getPaymentFormFields({ formFieldsData: [] })).toEqual([]); + }); + + it('returns the array when value is a valid array', () => { + expect(getPaymentFormFields(fields)).toEqual(fields); + }); +}); diff --git a/packages/payment-integration-api/src/PaymentFormFields.tsx b/packages/payment-integration-api/src/PaymentFormFields.tsx new file mode 100644 index 0000000000..a54c5e259e --- /dev/null +++ b/packages/payment-integration-api/src/PaymentFormFields.tsx @@ -0,0 +1,13 @@ +export interface PaymentFormField { + name: string; + id: string; + required: boolean; + type: string; + fieldType: string; +} + +const isPaymentFormFieldArray = (value: unknown): value is PaymentFormField[] => + Array.isArray(value); + +export const getPaymentFormFields = (formFieldsData: unknown): PaymentFormField[] => + isPaymentFormFieldArray(formFieldsData) ? formFieldsData : []; diff --git a/packages/payment-integration-api/src/index.ts b/packages/payment-integration-api/src/index.ts index 6cd435ad36..4099046004 100644 --- a/packages/payment-integration-api/src/index.ts +++ b/packages/payment-integration-api/src/index.ts @@ -22,6 +22,7 @@ export { export { SpecificError } from './errors'; export { getCountryData } from './CountryData'; export { CaptureMessageComponent } from './CaptureMessageComponent'; +export { getPaymentFormFields, type PaymentFormField } from './PaymentFormFields'; // export types separately export type { CountryData } from './CountryData';