Commit 596e22bb65 for woocommerce
commit 596e22bb657b09ea874c49b2311049f7d1b69024
Author: Daniel Mallory <daniel.mallory@automattic.com>
Date: Fri Apr 25 13:44:53 2025 +0100
Add new Bank Details Table Component (#57379)
* WIP
* WIP
* WIP
* Updates to support bank account list component.
* Updates to support bank account list component.
* Removing code used for testing.
* Removing code used for testing.
* Update changelog.
* Add newline.
* Styling fixes for the bank account modal.
* Form validation.
* Form validation.
* Form validation.
* Form validation.
* Remove accidentally committed code and lint fix
* One more lint fix
* Update to add some JSDocs.
---------
Co-authored-by: Vladimir Reznichenko <kalessil@gmail.com>
Co-authored-by: Oleksandr Aratovskyi <79862886+oaratovskyi@users.noreply.github.com>
Co-authored-by: oaratovskyi <oleksandr.aratovskyi@automattic.com>
diff --git a/plugins/woocommerce/changelog/woopmnt-4906-payment-settings-offline-payment-methods-modernisation b/plugins/woocommerce/changelog/woopmnt-4906-payment-settings-offline-payment-methods-modernisation
new file mode 100644
index 0000000000..ceaff2d284
--- /dev/null
+++ b/plugins/woocommerce/changelog/woopmnt-4906-payment-settings-offline-payment-methods-modernisation
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+Introduce new component to manage offline methods bank accounts.
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/components/bank-accounts-list/bank-account-list.scss b/plugins/woocommerce/client/admin/client/settings-payments/components/bank-accounts-list/bank-account-list.scss
new file mode 100644
index 0000000000..e966a1b7c2
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/settings-payments/components/bank-accounts-list/bank-account-list.scss
@@ -0,0 +1,110 @@
+.bank-accounts__list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ border: 1px solid #dcdcde;
+ border-radius: 4px;
+ overflow: hidden;
+}
+
+.bank-accounts__add-button {
+ display: flex;
+ justify-content: flex-end;
+ padding: 12px 16px;
+ border-top: 1px solid #e0e0e0;
+}
+
+.bank-accounts__list-header {
+ display: flex;
+ align-items: center;
+ padding: 12px 16px;
+ background-color: #f8f9fa;
+ border-bottom: 1px solid #dcdcde;
+ font-weight: 600;
+ font-size: 11px;
+ color: $gray-900;
+ text-transform: uppercase;
+ border-top: none;
+
+ > .bank-accounts__list-item-inner {
+ display: flex;
+ width: 100%;
+ align-items: center;
+
+ > .bank-accounts__list-item-before {
+ width: 24px;
+ height: 20px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ margin-right: 16px;
+ }
+
+ > .bank-accounts__list-item-text {
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr;
+ flex-grow: 1;
+ column-gap: 24px;
+ }
+
+ > .bank-accounts__list-item-after {
+ width: 32px;
+ margin-left: 16px;
+ }
+ }
+}
+
+.bank-accounts__list-item {
+ display: flex;
+ align-items: center;
+ padding: 12px 16px;
+ border-top: 1px solid #e0e0e0;
+
+ &.first-item {
+ border-top: none;
+ }
+
+ &:not(.action):hover {
+ background-color: #f8f9fa;
+ }
+
+ &.action {
+ display: flex;
+ justify-content: flex-end;
+ padding: 12px 16px;
+ }
+
+ > .bank-accounts__list-item-inner {
+ display: flex;
+ width: 100%;
+ align-items: center;
+
+ > .bank-accounts__list-item-before {
+ width: 24px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ margin-right: 16px;
+ }
+
+ > .bank-accounts__list-item-text {
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr;
+ flex-grow: 1;
+ column-gap: 24px;
+
+ div {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ }
+
+ > .bank-accounts__list-item-after {
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+ margin-left: 16px;
+ }
+ }
+}
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/components/bank-accounts-list/bank-account-modal.scss b/plugins/woocommerce/client/admin/client/settings-payments/components/bank-accounts-list/bank-account-modal.scss
new file mode 100644
index 0000000000..02ae148277
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/settings-payments/components/bank-accounts-list/bank-account-modal.scss
@@ -0,0 +1,24 @@
+.bank-account-modal {
+ width: 512px;
+ max-width: 512px;
+ max-height: 90%;
+
+ &__description {
+ margin-top: 0;
+ margin-bottom: 24px;
+ }
+
+ &__field {
+ margin-bottom: 24px;
+ }
+
+ &__actions {
+ display: flex;
+ justify-content: flex-end;
+ margin-top: 16px;
+
+ > .bank-account-modal__save {
+ margin-left: 8px;
+ }
+ }
+}
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/components/bank-accounts-list/bank-account-modal.tsx b/plugins/woocommerce/client/admin/client/settings-payments/components/bank-accounts-list/bank-account-modal.tsx
new file mode 100644
index 0000000000..e8a32ab0b2
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/settings-payments/components/bank-accounts-list/bank-account-modal.tsx
@@ -0,0 +1,261 @@
+/**
+ * External dependencies
+ */
+import { Modal, TextControl, Button } from '@wordpress/components';
+import { useEffect, useState } from 'react';
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import { BankAccount } from './types';
+import { getDefaultRoutingField } from './utils';
+import { validateRequiredField, validateNumericField } from './validation';
+import './bank-account-modal.scss';
+
+/**
+ * Props for the BankAccountModal component.
+ *
+ * @property {BankAccount | null} account - The bank account to edit, or null to add a new account.
+ * @property {() => void} onClose - Callback invoked when the modal should be closed.
+ * @property {(account: BankAccount) => void} onSave - Callback invoked when the bank account is saved.
+ * @property {string} defaultCountry - The default country used to determine the routing field.
+ */
+interface Props {
+ account: BankAccount | null;
+ onClose: () => void;
+ onSave: ( account: BankAccount ) => void;
+ defaultCountry: string;
+}
+
+/**
+ * BankAccountModal component renders a modal dialog for adding or editing a bank account.
+ * It manages form state, validation, and invokes callbacks to save or close the modal.
+ *
+ * @param {Props} props - Component props.
+ * @return {Element} The rendered modal component.
+ */
+export const BankAccountModal = ( {
+ account,
+ onClose,
+ onSave,
+ defaultCountry,
+}: Props ) => {
+ const [ formData, setFormData ] = useState< BankAccount >(
+ account || {
+ id: '',
+ account_name: '',
+ account_number: '',
+ bank_name: '',
+ routing_number: '',
+ sort_code: '',
+ iban: '',
+ bic: '',
+ }
+ );
+ const [ routingField, setRoutingField ] = useState<
+ 'routing_number' | 'sort_code' | 'iban'
+ >( 'iban' );
+
+ const [ errors, setErrors ] = useState<
+ Partial< Record< keyof BankAccount, string > >
+ >( {} );
+
+ /**
+ * Validates the form fields and sets error messages accordingly.
+ *
+ * @return {boolean} True if the form is valid, false otherwise.
+ */
+ const validate = () => {
+ const newErrors: Partial< Record< keyof BankAccount, string > > = {};
+
+ newErrors.account_name = validateRequiredField( formData.account_name );
+ newErrors.account_number =
+ validateRequiredField( formData.account_number ) ||
+ validateNumericField( formData.account_number );
+
+ if ( routingField === 'routing_number' ) {
+ newErrors.routing_number = validateRequiredField(
+ formData.routing_number
+ );
+ }
+
+ if ( routingField === 'sort_code' ) {
+ newErrors.sort_code = validateRequiredField( formData.sort_code );
+ }
+
+ if ( routingField === 'iban' ) {
+ newErrors.iban = validateRequiredField( formData.iban );
+ }
+
+ newErrors.bic = validateRequiredField( formData.bic );
+
+ const filteredErrors = Object.fromEntries(
+ Object.entries( newErrors ).filter( ( [ , v ] ) => v )
+ );
+ setErrors( filteredErrors );
+
+ return Object.keys( filteredErrors ).length === 0;
+ };
+
+ useEffect( () => {
+ if ( account ) {
+ if ( account.routing_number ) setRoutingField( 'routing_number' );
+ else if ( account.sort_code ) setRoutingField( 'sort_code' );
+ else if ( account.iban ) setRoutingField( 'iban' );
+ else setRoutingField( getDefaultRoutingField( defaultCountry ) );
+ } else {
+ setRoutingField( getDefaultRoutingField( defaultCountry ) );
+ }
+ }, [ account, defaultCountry ] );
+
+ /**
+ * Updates a specific field in the form data state.
+ *
+ * @param {keyof BankAccount} field - The field name to update.
+ * @param {string} value - The new value for the field.
+ */
+ const updateField = ( field: keyof BankAccount, value: string ) => {
+ setFormData( ( prev ) => ( { ...prev, [ field ]: value } ) );
+ };
+
+ return (
+ <Modal
+ className="bank-account-modal"
+ title={
+ account
+ ? __( 'Edit bank account', 'woocommerce' )
+ : __( 'Add a bank account', 'woocommerce' )
+ }
+ onRequestClose={ onClose }
+ shouldCloseOnClickOutside={ false }
+ >
+ <p className={ 'bank-account-modal__description' }>
+ { account
+ ? __( 'Edit your bank account details.', 'woocommerce' )
+ : __( 'Add your bank account details.', 'woocommerce' ) }
+ </p>
+
+ <TextControl
+ className={ 'bank-account-modal__field' }
+ label={ __( 'Account Name *', 'woocommerce' ) }
+ required
+ value={ formData.account_name }
+ onChange={ ( value ) => updateField( 'account_name', value ) }
+ help={
+ errors.account_name ? (
+ <span className="bank-account-modal__error">
+ { errors.account_name }
+ </span>
+ ) : undefined
+ }
+ />
+
+ <TextControl
+ className={ 'bank-account-modal__field' }
+ label={ __( 'Account Number *', 'woocommerce' ) }
+ required
+ value={ formData.account_number }
+ onChange={ ( value ) => updateField( 'account_number', value ) }
+ help={
+ errors.account_number ? (
+ <span className="bank-account-modal__error">
+ { errors.account_number }
+ </span>
+ ) : undefined
+ }
+ />
+
+ <TextControl
+ className={ 'bank-account-modal__field' }
+ label={ __( 'Bank Name', 'woocommerce' ) }
+ value={ formData.bank_name }
+ onChange={ ( value ) => updateField( 'bank_name', value ) }
+ />
+
+ { routingField === 'routing_number' && (
+ <TextControl
+ className={ 'bank-account-modal__field' }
+ label={ __( 'Routing Number', 'woocommerce' ) }
+ required
+ value={ formData.routing_number }
+ onChange={ ( value ) =>
+ updateField( 'routing_number', value )
+ }
+ help={
+ errors.routing_number ? (
+ <span className="bank-account-modal__error">
+ { errors.routing_number }
+ </span>
+ ) : undefined
+ }
+ />
+ ) }
+
+ { routingField === 'sort_code' && (
+ <TextControl
+ className={ 'bank-account-modal__field' }
+ label={ __( 'BSB', 'woocommerce' ) }
+ required
+ value={ formData.sort_code }
+ onChange={ ( value ) => updateField( 'sort_code', value ) }
+ help={
+ errors.sort_code ? (
+ <span className="bank-account-modal__error">
+ { errors.sort_code }
+ </span>
+ ) : undefined
+ }
+ />
+ ) }
+
+ { routingField === 'iban' && (
+ <TextControl
+ className={ 'bank-account-modal__field' }
+ label={ __( 'IBAN', 'woocommerce' ) }
+ required
+ value={ formData.iban }
+ onChange={ ( value ) => updateField( 'iban', value ) }
+ help={
+ errors.iban ? (
+ <span className="bank-account-modal__error">
+ { errors.iban }
+ </span>
+ ) : undefined
+ }
+ />
+ ) }
+
+ <TextControl
+ className={ 'bank-account-modal__field' }
+ label={ __( 'BIC / SWIFT', 'woocommerce' ) }
+ value={ formData.bic }
+ onChange={ ( value ) => updateField( 'bic', value ) }
+ help={
+ errors.bic ? (
+ <span className="bank-account-modal__error">
+ { errors.bic }
+ </span>
+ ) : undefined
+ }
+ />
+
+ <div className={ 'bank-account-modal__actions' }>
+ <Button variant={ 'tertiary' } onClick={ onClose }>
+ { __( 'Cancel', 'woocommerce' ) }
+ </Button>
+ <Button
+ className={ 'bank-account-modal__save' }
+ variant={ 'primary' }
+ onClick={ () => {
+ if ( validate() ) {
+ onSave( formData );
+ }
+ } }
+ >
+ { __( 'Save', 'woocommerce' ) }
+ </Button>
+ </div>
+ </Modal>
+ );
+};
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/components/bank-accounts-list/bank-accounts-list.tsx b/plugins/woocommerce/client/admin/client/settings-payments/components/bank-accounts-list/bank-accounts-list.tsx
new file mode 100644
index 0000000000..c6f94dee13
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/settings-payments/components/bank-accounts-list/bank-accounts-list.tsx
@@ -0,0 +1,239 @@
+/**
+ * External dependencies
+ */
+import { Button, MenuGroup, MenuItem, Modal } from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+import { EllipsisMenu } from '@woocommerce/components';
+import { useState } from 'react';
+
+/**
+ * Internal dependencies
+ */
+import { BankAccount } from './types';
+import { BankAccountModal } from './bank-account-modal';
+import {
+ DefaultDragHandle,
+ SortableContainer,
+ SortableItem,
+} from '~/settings-payments/components/sortable';
+import './bank-account-list.scss';
+
+/**
+ * Generates a random string ID for new bank accounts.
+ *
+ * @return {string} A unique identifier string.
+ */
+function generateId() {
+ return Math.random().toString( 36 ).substring( 2, 10 );
+}
+
+/**
+ * Props for BankAccountsList component.
+ *
+ */
+interface Props {
+ initialAccounts: BankAccount[];
+ onChange: ( accounts: BankAccount[] ) => void;
+ updateOrdering: ( accounts: BankAccount[] ) => void;
+ defaultCountry: string;
+}
+
+/**
+ * BankAccountsList component renders a sortable list of bank accounts,
+ * allowing users to add, edit, reorder, and delete accounts.
+ *
+ * @param {Props} props Component props.
+ * @return {Element} The rendered component.
+ */
+export const BankAccountsList = ( {
+ initialAccounts,
+ onChange,
+ updateOrdering,
+ defaultCountry,
+}: Props ) => {
+ const [ accounts, setAccounts ] = useState( initialAccounts );
+ const [ selectedAccount, setSelectedAccount ] =
+ useState< BankAccount | null >( null );
+ const [ isModalOpen, setIsModalOpen ] = useState( false );
+ const [ accountToDelete, setAccountToDelete ] =
+ useState< BankAccount | null >( null );
+
+ /**
+ * Opens the bank account modal for adding or editing an account.
+ *
+ * @param {BankAccount | null} account The account to edit, or null to add a new one.
+ */
+ const openModal = ( account: BankAccount | null = null ) => {
+ setSelectedAccount( account );
+ setIsModalOpen( true );
+ };
+
+ /**
+ * Handles saving of a bank account, either updating an existing one or adding a new one.
+ *
+ * @param {BankAccount} updated The updated or new bank account.
+ */
+ const handleSave = ( updated: BankAccount ) => {
+ const newAccounts = accounts.some( ( acc ) => acc.id === updated.id )
+ ? accounts.map( ( acc ) =>
+ acc.id === updated.id ? updated : acc
+ )
+ : [ ...accounts, { ...updated, id: generateId() } ];
+ setAccounts( newAccounts );
+ onChange( newAccounts );
+ setIsModalOpen( false );
+ };
+
+ /**
+ * Confirms and deletes the selected bank account.
+ */
+ const confirmDelete = () => {
+ if ( ! accountToDelete ) return;
+ const newAccounts = accounts.filter(
+ ( acc ) => acc.id !== accountToDelete.id
+ );
+ setAccounts( newAccounts );
+ onChange( newAccounts );
+ setAccountToDelete( null );
+ };
+
+ /**
+ * Updates the ordering of bank accounts after drag-and-drop sorting.
+ *
+ * @param {BankAccount[]} newAccounts The reordered list of bank accounts.
+ */
+ const handleUpdateOrdering = ( newAccounts: BankAccount[] ) => {
+ setAccounts( newAccounts );
+ updateOrdering( newAccounts );
+ };
+
+ return (
+ <>
+ <SortableContainer< BankAccount >
+ items={ accounts }
+ className={ 'bank-accounts__list' }
+ setItems={ handleUpdateOrdering }
+ >
+ <div className="bank-accounts__list-header">
+ <div className="bank-accounts__list-item-inner">
+ <div className="bank-accounts__list-item-before" />
+ <div className="bank-accounts__list-item-text">
+ <div>Account Name</div>
+ <div>Account Number</div>
+ <div>Bank Name</div>
+ </div>
+ <div className="bank-accounts__list-item-after" />
+ </div>
+ </div>
+ { accounts.map( ( account, index ) => (
+ <SortableItem
+ key={ account.id }
+ id={ account.id }
+ className={ `bank-accounts__list-item${
+ index === 0 ? ' first-item' : ''
+ }` }
+ >
+ <div className="bank-accounts__list-item-inner">
+ <div className="bank-accounts__list-item-before">
+ <DefaultDragHandle />
+ </div>
+ <div className="bank-accounts__list-item-text">
+ <div>{ account.account_name }</div>
+ <div>{ account.account_number }</div>
+ <div>{ account.bank_name }</div>
+ </div>
+ <div className="bank-accounts__list-item-after">
+ <EllipsisMenu
+ label={ __( 'Options', 'woocommerce' ) }
+ placement={ 'bottom-right' }
+ renderContent={ () => (
+ <MenuGroup>
+ <MenuItem
+ role={ 'menuitem' }
+ onClick={ () =>
+ openModal( account )
+ }
+ >
+ { __(
+ 'View / edit',
+ 'woocommerce'
+ ) }
+ </MenuItem>
+ <MenuItem
+ isDestructive
+ onClick={ () =>
+ setAccountToDelete(
+ account
+ )
+ }
+ >
+ { __(
+ 'Delete',
+ 'woocommerce'
+ ) }
+ </MenuItem>
+ </MenuGroup>
+ ) }
+ />
+ </div>
+ </div>
+ </SortableItem>
+ ) ) }
+ <li className="bank-accounts__list-item action">
+ <Button
+ variant={ 'secondary' }
+ onClick={ () => openModal( null ) }
+ >
+ { __( '+ Add account', 'woocommerce' ) }
+ </Button>
+ </li>
+ </SortableContainer>
+
+ { isModalOpen && (
+ <BankAccountModal
+ account={ selectedAccount }
+ onClose={ () => setIsModalOpen( false ) }
+ onSave={ handleSave }
+ defaultCountry={ defaultCountry }
+ />
+ ) }
+
+ { accountToDelete && (
+ <Modal
+ title={ __( 'Delete account', 'woocommerce' ) }
+ onRequestClose={ () => setAccountToDelete( null ) }
+ shouldCloseOnClickOutside={ false }
+ >
+ <p>
+ { __(
+ 'Are you sure you want to delete this bank account?',
+ 'woocommerce'
+ ) }
+ </p>
+ <div
+ style={ {
+ display: 'flex',
+ justifyContent: 'flex-end',
+ gap: '8px',
+ marginTop: '16px',
+ } }
+ >
+ <Button
+ variant="secondary"
+ onClick={ () => setAccountToDelete( null ) }
+ >
+ { __( 'Cancel', 'woocommerce' ) }
+ </Button>
+ <Button
+ variant="primary"
+ isDestructive
+ onClick={ confirmDelete }
+ >
+ { __( 'Delete', 'woocommerce' ) }
+ </Button>
+ </div>
+ </Modal>
+ ) }
+ </>
+ );
+};
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/components/bank-accounts-list/index.ts b/plugins/woocommerce/client/admin/client/settings-payments/components/bank-accounts-list/index.ts
new file mode 100644
index 0000000000..d99372b148
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/settings-payments/components/bank-accounts-list/index.ts
@@ -0,0 +1 @@
+export { BankAccountsList } from './bank-accounts-list';
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/components/bank-accounts-list/types.ts b/plugins/woocommerce/client/admin/client/settings-payments/components/bank-accounts-list/types.ts
new file mode 100644
index 0000000000..0351deddd7
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/settings-payments/components/bank-accounts-list/types.ts
@@ -0,0 +1,10 @@
+export interface BankAccount {
+ id: string;
+ account_name: string;
+ account_number: string;
+ bank_name: string;
+ routing_number: string;
+ sort_code: string;
+ iban: string;
+ bic: string;
+}
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/components/bank-accounts-list/utils.ts b/plugins/woocommerce/client/admin/client/settings-payments/components/bank-accounts-list/utils.ts
new file mode 100644
index 0000000000..17a314f762
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/settings-payments/components/bank-accounts-list/utils.ts
@@ -0,0 +1,10 @@
+/**
+ * Determines the default routing field based on the country.
+ *
+ * @param country The selected country code.
+ */
+export const getDefaultRoutingField = ( country: string ) => {
+ if ( country === 'US' ) return 'routing_number';
+ if ( country === 'AU' ) return 'sort_code';
+ return 'iban';
+};
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/components/bank-accounts-list/validation.ts b/plugins/woocommerce/client/admin/client/settings-payments/components/bank-accounts-list/validation.ts
new file mode 100644
index 0000000000..d284db8f2d
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/settings-payments/components/bank-accounts-list/validation.ts
@@ -0,0 +1,15 @@
+/**
+ * Returns an error message if the input is empty.
+ */
+export const validateRequiredField = ( value: string ): string | undefined => {
+ return value.trim() === '' ? 'This field is required.' : undefined;
+};
+
+/**
+ * Returns an error message if the input is not numeric.
+ */
+export const validateNumericField = ( value: string ): string | undefined => {
+ return /^\d+$/.test( value.trim() )
+ ? undefined
+ : 'This field must be numeric.';
+};