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;