modules_Address_index.js

import { React, classnames } from '@gravityforms/libraries';
import { spacerClasses } from '@gravityforms/utils';
import Box from '../../elements/Box';
import Input from '../../elements/Input';
import Dropdown from '../Dropdown';
import CountryDropdown from '../Dropdown/CountryDropdown';

const { useState, forwardRef } = React;

const NEEDS_I18N_LABEL = 'Needs i18n';

// Default layout configuration
const DEFAULT_LAYOUT_CONFIG = [
	{ key: 'lineOne', width: 'full' },
	{ key: 'lineTwo', width: 'full' },
	{ key: 'city', width: 'half' },
	{ key: 'state', width: 'half' },
	{ key: 'postalCode', width: 'half' },
	{ key: 'country', width: 'half' },
];

// Helper function to sort regions based on preferredRegions
const sortRegions = ( regions, preferred ) => {
	if ( ! preferred || ! Array.isArray( preferred ) || preferred.length === 0 ) {
		return [ ...regions ];
	}

	const preferredItems = [];
	const otherItems = [];

	regions.forEach( ( region ) => {
		const index = preferred.indexOf( region.value );
		if ( index !== -1 ) {
			preferredItems[ index ] = region;
		} else {
			otherItems.push( region );
		}
	} );

	// Filter out any undefined slots and combine with other items
	return [
		...preferredItems.filter( ( item ) => item !== undefined ),
		...otherItems,
	];
};

const transformRegionCodes = ( inputObj ) => {
	const result = {};
	for ( const key in inputObj ) {
		if ( Object.prototype.hasOwnProperty.call( inputObj, key ) ) {
			result[ key ] = Object.entries( inputObj[ key ] ).map( ( [ value, label ] ) => {
				if ( key === 'countries' ) {
					return value;
				}
				return {
					value,
					label,
				};
			} );
		}
	}

	return result;
};

/**
 * @module Address
 * @description Renders an address component.
 *
 * @since 5.5.0
 *
 * @param {React.ReactNode|React.ReactNode[]}          [children]                  - React element children
 * @param {Record<string, any>}                        [customAttributes]          - Custom attributes for the component
 * @param {string|string[]|Record<string, boolean>}    [customClasses]             - Custom classes for the component
 * @param {Record<string, any>}                        [countryDropdownAttributes] - Attributes for the country dropdown
 * @param {string|string[]|Record<string, boolean>}    [countryDropdownClasses]    - Classes for the country dropdown
 * @param {Record<string, any>}                        [defaultData]               - Default data for the component state
 * @param {string[]}                                   [disabledFields]            - Fields to disable in the component. Use the state keys: lineOne, lineTwo, city, state, postalCode, country
 * @param {Record<string, any>}                        [initialData]               - Initial data for the component state
 * @param {Record<string, any>}                        [i18n]                      - Internationalization settings
 * @param {Function}                                   [onChange]                  - Callback for when the component changes, returns the state
 * @param {Array<object>}                              [layout]                    - Custom layout for the address fields. Each object should have a 'key' (string: 'lineOne', 'lineTwo', 'city', 'state', 'postalCode', 'country') and an optional 'width' (string: 'full' or 'half'). Defaults to a standard layout if not provided or invalid.
 * @param {boolean}                                    [parseRegionCodes]          - Whether to parse region codes for the component
 * @param {Record<string, any>}                        [preferredRegions]          - Preferred regions for the component: countries, usStates, caProvinces
 * @param {Record<string, any>}                        [regionCodes]               - Region codes for the component
 * @param {string|number|string[]|Record<string, any>} [spacing='']                - The spacing for the component
 * @param {Record<string, any>}                        [stateDropdownAttributes]   - Attributes for the state/province dropdown
 * @param {string|string[]|Record<string, boolean>}    [stateDropdownClasses]      - Classes for the state/province dropdown
 * @param {boolean}                                    [useSelectPlaceholders]     - Whether to use select placeholders for the dropdowns
 * @param {React.RefObject<HTMLElement>|null}          ref                         - Ref to the component
 *
 * @return {JSX.Element} The Address component.
 *
 * @example
 * import Address from '@gravityforms/components/react/admin/modules/Address';
 *
 * // Basic usage with all fields
 * <Address
 *   regionCodes={{
 *     countries: [{ value: 'US', label: 'United States' }, { value: 'CA', label: 'Canada' }],
 *     usStates: [{ value: 'CA', label: 'California' }, { value: 'NY', label: 'New York' }],
 *     caProvinces: [{ value: 'ON', label: 'Ontario' }, { value: 'QC', label: 'Quebec' }]
 *   }}
 *   onChange={(data) => console.log(data)}
 * />
 *
 * // With preferred regions and initial data
 * <Address
 *   regionCodes={{
 *     countries: [{ value: 'US', label: 'United States' }, { value: 'CA', label: 'Canada' }],
 *     usStates: [{ value: 'CA', label: 'California' }, { value: 'NY', label: 'New York' }],
 *     caProvinces: [{ value: 'ON', label: 'Ontario' }, { value: 'QC', label: 'Quebec' }]
 *   }}
 *   preferredRegions={{
 *     countries: ['CA', 'US'],
 *     usStates: ['NY'],
 *     caProvinces: ['QC']
 *   }}
 *   initialData={{
 *     country: 'US',
 *     state: 'CA',
 *     city: 'San Francisco'
 *   }}
 * />
 *
 * // With disabled fields
 * <Address
 *   regionCodes={{
 *     countries: [{ value: 'US', label: 'United States' }, { value: 'CA', label: 'Canada' }]
 *   }}
 *   disabledFields={['lineTwo', 'state']}
 *   initialData={{ country: 'US' }}
 * />
 *
 * // With custom classes and spacing
 * <Address
 *   regionCodes={{
 *     countries: [{ value: 'US', label: 'United States' }]
 *   }}
 *   customClasses={['custom-address-class']}
 *   spacing={4}
 *   customAttributes={{ 'data-test': 'address-component' }}
 * >
 *   <div>Additional content</div>
 * </Address>
 *
 * // With custom layout (e.g., country first, city and postal code on the same line)
 * <Address
 *   regionCodes={{
 *     countries: [{ value: 'US', label: 'United States' }, { value: 'CA', label: 'Canada' }],
 *     usStates: [{ value: 'CA', label: 'California' }, { value: 'NY', label: 'New York' }],
 *   }}
 *   layout={[
 *     { key: 'country', width: 'full' },
 *     { key: 'lineOne', width: 'full' },
 *     { key: 'lineTwo', width: 'full' },
 *     { key: 'city', width: 'half' },
 *     { key: 'postalCode', width: 'half' },
 *     { key: 'state', width: 'full' }, // Example: State takes full width on its own row
 *   ]}
 *   onChange={(data) => console.log(data)}
 * />
 *
 */
const Address = forwardRef( ( {
	children = null,
	customAttributes = {},
	customClasses = [],
	countryDropdownAttributes = {},
	countryDropdownClasses = [],
	defaultData = {},
	disabledFields = [],
	initialData = {},
	i18n = {},
	language = 'en',
	layout = undefined,
	onChange = () => {},
	parseRegionCodes = false,
	preferredRegions = {},
	regionCodes = {},
	spacing = '',
	stateDropdownAttributes = {},
	stateDropdownClasses = [],
	useSelectPlaceholders = true,
}, ref ) => {
	const getInitialValue = ( field ) => {
		if ( disabledFields.includes( field ) ) {
			return undefined;
		}
		return initialData[ field ] || defaultData[ field ] || '';
	};

	const [ addressData, setAddressData ] = useState( () => (
		[ 'lineOne', 'lineTwo', 'city', 'state', 'postalCode', 'country' ].reduce( ( carry, key ) => ( {
			...carry,
			[ key ]: getInitialValue( key ),
		} ), {} )
	) );

	const { countries = [], usStates = [], caProvinces = [] } = parseRegionCodes ? transformRegionCodes( regionCodes ) : regionCodes;

	const sortedUsStates = sortRegions( usStates, preferredRegions.usStates );
	const sortedCaProvinces = sortRegions( caProvinces, preferredRegions.caProvinces );

	const handleAddressChange = ( data = {} ) => {
		const newAddressData = {
			...addressData,
			...data,
		};
		setAddressData( newAddressData );
		const filteredData = Object.fromEntries(
			Object.entries( newAddressData ).filter( ( [ key ] ) => ! disabledFields.includes( key ) )
		);
		onChange( filteredData );
	};

	const handleInputChange = ( field ) => ( value ) => {
		if ( disabledFields.includes( field ) ) {
			return;
		}

		handleAddressChange( { [ field ]: value } );
	};

	const handleDropdownChange = ( field ) => ( event, regionCode ) => {
		if ( disabledFields.includes( field ) ) {
			return;
		}

		handleAddressChange( {
			[ field ]: regionCode.value,
			...( field === 'country' && ! disabledFields.includes( 'state' ) && { state: '' } ),
		} );
	};

	const getStateLabelPlaceholder = () => {
		if ( addressData.country === 'US' ) {
			return {
				label: i18n.stateLabel || NEEDS_I18N_LABEL,
				placeholder: i18n.statePlaceholder || NEEDS_I18N_LABEL,
			};
		}
		if ( addressData.country === 'CA' ) {
			return {
				label: i18n.provinceLabel || NEEDS_I18N_LABEL,
				placeholder: i18n.provincePlaceholder || NEEDS_I18N_LABEL,
			};
		}
		return {
			label: i18n.stateProvinceLabel || NEEDS_I18N_LABEL,
			placeholder: i18n.stateProvincePlaceholder || NEEDS_I18N_LABEL,
		};
	};

	const getPostalCodeLabelPlaceholder = () => {
		if ( addressData.country === 'US' ) {
			return {
				label: i18n.zipCodeLabel || NEEDS_I18N_LABEL,
				placeholder: i18n.zipCodePlaceholder || NEEDS_I18N_LABEL,
			};
		}
		if ( addressData.country === 'CA' ) {
			return {
				label: i18n.postalCodeLabel || NEEDS_I18N_LABEL,
				placeholder: i18n.postalCodePlaceholder || NEEDS_I18N_LABEL,
			};
		}
		return {
			label: i18n.zipPostalCodeLabel || NEEDS_I18N_LABEL,
			placeholder: i18n.zipPostalCodePlaceholder || NEEDS_I18N_LABEL,
		};
	};

	const renderInputField = ( key ) => {
		if ( disabledFields.includes( key ) ) {
			return null;
		}

		let label = i18n[ `${ key }Label` ] || NEEDS_I18N_LABEL;
		let placeholder = i18n[ `${ key }Placeholder` ] || NEEDS_I18N_LABEL;
		if ( key === 'state' ) {
			( { label, placeholder } = getStateLabelPlaceholder() );
		} else if ( key === 'postalCode' ) {
			( { label, placeholder } = getPostalCodeLabelPlaceholder() );
		}

		return <Input
			key={ key }
			labelAttributes={ { label } }
			value={ addressData[ key ] }
			onChange={ handleInputChange( key ) }
			placeholder={ placeholder }
		/>;
	};

	const renderCountryField = () => {
		if ( disabledFields.includes( 'country' ) ) {
			return null;
		}
		if ( countries.length === 0 ) {
			return renderInputField( 'country' );
		}
		const countryDropdownProps = {
			controlled: true,
			countries,
			hasSearch: true,
			i18n,
			language,
			label: i18n.countryLabel || NEEDS_I18N_LABEL,
			onChange: handleDropdownChange( 'country' ),
			popoverMaxHeight: 300,
			preferredCountries: preferredRegions.countries,
			size: 'r',
			value: addressData.country,
			...countryDropdownAttributes,
			customClasses: classnames( {
				'gform-address__country-dropdown': true,
				...countryDropdownClasses,
			} ),
		};

		return (
			<CountryDropdown { ...countryDropdownProps } />
		);
	};

	const renderStateProvinceField = () => {
		if ( disabledFields.includes( 'state' ) ) {
			return null;
		}

		if ( addressData.country === 'US' && sortedUsStates.length > 0 ) {
			const listItems = useSelectPlaceholders ? [ {
				label: i18n.stateSelectPlaceholder || NEEDS_I18N_LABEL,
				value: '',
			}, ...sortedUsStates ] : sortedUsStates;
			const stateDropdownProps = {
				controlled: true,
				hasSearch: true,
				i18n,
				label: i18n.stateLabel || NEEDS_I18N_LABEL,
				listItems,
				onChange: handleDropdownChange( 'state' ),
				popoverMaxHeight: 300,
				size: 'r',
				value: addressData.state,
				...stateDropdownAttributes,
				customClasses: classnames( {
					'gform-address__state-province-dropdown': true,
					...stateDropdownClasses,
				} ),
			};

			return ( <Dropdown { ...stateDropdownProps } /> );
		}
		if ( addressData.country === 'CA' && sortedCaProvinces.length > 0 ) {
			const listItems = useSelectPlaceholders ? [ {
				label: i18n.provinceSelectPlaceholder || NEEDS_I18N_LABEL,
				value: '',
			}, ...sortedCaProvinces ] : sortedCaProvinces;
			const stateDropdownProps = {
				controlled: true,
				hasSearch: true,
				i18n,
				label: i18n.provinceLabel || NEEDS_I18N_LABEL,
				listItems,
				onChange: handleDropdownChange( 'state' ),
				popoverMaxHeight: 300,
				size: 'r',
				value: addressData.state,
				...stateDropdownAttributes,
				customClasses: classnames( {
					'gform-address__state-province-dropdown': true,
					...stateDropdownClasses,
				} ),
			};

			return (
				<Dropdown { ...stateDropdownProps } />
			);
		}

		return renderInputField( 'state' );
	};

	const attributes = {
		className: classnames( {
			'gform-address': true,
			...spacerClasses( spacing ),
		}, customClasses ),
		ref,
		...customAttributes,
	};

	const fieldRenderers = {
		lineOne: renderInputField,
		lineTwo: renderInputField,
		city: renderInputField,
		state: renderStateProvinceField,
		postalCode: renderInputField,
		country: renderCountryField,
	};

	const currentLayout = layout && Array.isArray( layout ) && layout.length > 0 ? layout : DEFAULT_LAYOUT_CONFIG;

	// Filter out fields that are disabled or have unknown keys from the layout
	const activeLayout = currentLayout.filter( ( fieldConfig ) =>
		fieldConfig && typeof fieldConfig.key === 'string' &&
		! disabledFields.includes( fieldConfig.key ) &&
		Object.prototype.hasOwnProperty.call( fieldRenderers, fieldConfig.key )
	);

	return (
		<div { ...attributes }>
			{ activeLayout.map( ( { key, width = 'full' } ) => {
				const fieldRenderer = fieldRenderers[ key ];
				// Already checked for existence in activeLayout filter, but good practice
				if ( ! fieldRenderer ) {
					// eslint-disable-next-line no-console
					console.warn( `Address component: No renderer found for key "${ key }" in layout.` );
					return null;
				}

				const fieldElement = fieldRenderer( key ); // Pass key to renderInputField if it's the one being called
				if ( ! fieldElement ) {
					return null;
				}

				return (
					<Box
						key={ key }
						customClasses={ [
							'gform-address__field',
							`gform-address__field--${ key.toLowerCase() }`,
							`gform-address__field--${ width.toLowerCase() }`,
						] }
					>
						{ fieldElement }
					</Box>
				);
			} ) }
			{ children }
		</div>
	);
} );

Address.displayName = 'Address';

export default Address;