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.';
+};