UNPKG

@gravityforms/components

Version:

UI components for use in Gravity Forms development. Both React and vanilla js flavors.

450 lines (410 loc) 14.6 kB
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;