2019-08-21 05:58:47 +00:00
/ * *
* External dependencies
* /
import { __ } from '@wordpress/i18n' ;
2022-04-13 08:57:58 +00:00
import { COUNTRIES_STORE_NAME , Country , Locale } from '@woocommerce/data' ;
2019-08-21 05:58:47 +00:00
import { decodeEntities } from '@wordpress/html-entities' ;
2022-04-14 04:53:45 +00:00
import { escapeRegExp , has } from 'lodash' ;
2021-08-23 17:36:00 +00:00
import { useEffect , useMemo , useState , useRef } from '@wordpress/element' ;
2019-10-09 23:00:33 +00:00
import { SelectControl , TextControl } from '@woocommerce/components' ;
2022-01-14 13:39:25 +00:00
import { Spinner } from '@wordpress/components' ;
2022-04-25 05:49:11 +00:00
import { useSelect } from '@wordpress/data' ;
2019-10-07 22:42:32 +00:00
2022-01-06 12:53:30 +00:00
/ * *
* Internal dependencies
* /
2022-02-09 04:40:44 +00:00
import { FormInputProps } from '~/utils/types' ;
2022-01-06 12:53:30 +00:00
2022-02-07 23:31:08 +00:00
const storeAddressFields = [
'addressLine1' ,
'addressLine2' ,
'city' ,
'countryState' ,
'postCode' ,
2022-04-13 08:57:58 +00:00
] as const ;
2022-02-01 18:13:54 +00:00
type Option = { key : string ; label : string } ;
2022-04-14 04:53:45 +00:00
/ * *
* Type guard to ensure that the specified locale object has a . required property
*
2022-04-25 05:49:11 +00:00
* @param fieldName field of Locale
* @param locale unknown object to be checked
2022-04-14 04:53:45 +00:00
* @return Boolean indicating if locale has a . required property
* /
const isLocaleRecord = (
fieldName : keyof Locale ,
locale : unknown
) : locale is Record < keyof Locale , { required : boolean } > = > {
return ! ! locale && has ( locale , ` ${ fieldName } .required ` ) ;
} ;
2019-08-21 05:58:47 +00:00
/ * *
2022-01-14 13:39:25 +00:00
* Check if a given address field is required for the locale .
2019-08-21 05:58:47 +00:00
*
2022-01-14 13:39:25 +00:00
* @param { string } fieldName Name of the field to check .
2022-03-18 11:45:14 +00:00
* @param { Object } locale Locale data .
2022-01-14 13:39:25 +00:00
* @return { boolean } Field requirement .
2019-08-21 05:58:47 +00:00
* /
2022-02-01 18:13:54 +00:00
export function isAddressFieldRequired (
2022-04-13 08:57:58 +00:00
fieldName : keyof Locale ,
locale : Locale = { }
2022-02-01 18:13:54 +00:00
) : boolean {
2022-04-14 04:53:45 +00:00
if ( isLocaleRecord ( fieldName , locale ) ) {
return locale [ fieldName ] . required ;
2019-08-21 05:58:47 +00:00
}
2022-01-14 13:39:25 +00:00
if ( fieldName === 'address_2' ) {
return false ;
2019-08-21 05:58:47 +00:00
}
2022-01-14 13:39:25 +00:00
return true ;
}
/ * *
* Form validation .
*
* @param { Object } locale The store locale .
* @return { Function } Validator function .
* /
2022-04-13 08:57:58 +00:00
export function getStoreAddressValidator ( locale : Locale = { } ) {
2022-01-14 13:39:25 +00:00
/ * *
* Form validator .
*
* @param { Object } values Keyed values of all fields in the form .
* @return { Object } Key value of fields and error messages , { myField : 'This field is required' }
* /
2022-04-13 08:57:58 +00:00
return (
values : Record < typeof storeAddressFields [ number ] , string >
) = > {
2022-01-14 13:39:25 +00:00
const errors : {
[ key : string ] : string ;
} = { } ;
if (
isAddressFieldRequired ( 'address_1' , locale ) &&
! values . addressLine1 . trim ( ) . length
) {
2022-03-30 09:00:04 +00:00
errors . addressLine1 = __ ( 'Please add an address' , 'woocommerce' ) ;
2022-01-14 13:39:25 +00:00
}
if ( ! values . countryState . trim ( ) . length ) {
errors . countryState = __ (
'Please select a country / region' ,
2022-03-30 09:00:04 +00:00
'woocommerce'
2022-01-14 13:39:25 +00:00
) ;
}
if (
isAddressFieldRequired ( 'city' , locale ) &&
! values . city . trim ( ) . length
) {
2022-03-30 09:00:04 +00:00
errors . city = __ ( 'Please add a city' , 'woocommerce' ) ;
2022-01-14 13:39:25 +00:00
}
if (
isAddressFieldRequired ( 'postcode' , locale ) &&
! values . postCode . trim ( ) . length
) {
2022-03-30 09:00:04 +00:00
errors . postCode = __ ( 'Please add a post code' , 'woocommerce' ) ;
2022-01-14 13:39:25 +00:00
}
return errors ;
} ;
2019-08-21 05:58:47 +00:00
}
/ * *
* Get all country and state combinations used for select dropdowns .
*
* @return { Object } Select options , { value : 'US:GA' , label : 'United States - Georgia' }
* /
2022-03-01 12:33:41 +00:00
export function getCountryStateOptions ( countries : Country [ ] ) {
2022-04-13 08:57:58 +00:00
const countryStateOptions = countries . reduce (
( acc : Option [ ] , country ) = > {
if ( ! country . states . length ) {
acc . push ( {
key : country.code ,
label : decodeEntities ( country . name ) ,
} ) ;
return acc ;
}
2019-08-21 05:58:47 +00:00
2022-04-13 08:57:58 +00:00
const countryStates = country . states . map ( ( state ) = > {
return {
key : country.code + ':' + state . code ,
label :
decodeEntities ( country . name ) +
' — ' +
decodeEntities ( state . name ) ,
} ;
} ) ;
2019-08-21 05:58:47 +00:00
2022-04-13 08:57:58 +00:00
acc . push ( . . . countryStates ) ;
2019-08-21 05:58:47 +00:00
2022-04-13 08:57:58 +00:00
return acc ;
} ,
[ ]
) ;
2019-08-21 05:58:47 +00:00
return countryStateOptions ;
}
2022-02-01 18:13:54 +00:00
/ * *
* Normalize state string for matching .
*
* @param { string } state The state to normalize .
* @return { Function } filter function .
* /
export const normalizeState = ( state : string ) : string = > {
return state . replace ( /\s/g , '' ) . toLowerCase ( ) ;
} ;
/ * *
* Get state filter
*
2022-03-18 11:45:14 +00:00
* @param { string } isStateAbbreviation Whether to use state abbreviation or not .
2022-02-01 18:13:54 +00:00
* @param { string } normalizedAutofillState The value of the autofillState field .
* @return { Function } filter function .
* /
export const getStateFilter = (
isStateAbbreviation : boolean ,
normalizedAutofillState : string
) : ( ( option : Option ) = > boolean ) = > ( option : Option ) = > {
const countryStateArray = isStateAbbreviation
? option . key . split ( ':' )
: option . label . split ( '—' ) ;
// No region options in the country
if ( countryStateArray . length <= 1 ) {
return false ;
}
const state = countryStateArray [ 1 ] ;
// Handle special case, for example: China — Beijing / 北京
if ( state . includes ( '/' ) ) {
const stateStrList = state . split ( '/' ) ;
return (
normalizeState ( stateStrList [ 0 ] ) === normalizedAutofillState ||
normalizeState ( stateStrList [ 1 ] ) === normalizedAutofillState
) ;
}
// Handle special case, for example: Iran — Alborz (البرز)
if ( state . includes ( '(' ) && state . includes ( ')' ) ) {
const stateStrList = state . replace ( ')' , '' ) . split ( '(' ) ;
return (
normalizeState ( stateStrList [ 0 ] ) === normalizedAutofillState ||
normalizeState ( stateStrList [ 1 ] ) === normalizedAutofillState
) ;
}
return normalizeState ( state ) === normalizedAutofillState ;
} ;
2019-10-30 23:44:57 +00:00
/ * *
* Get the autofill countryState fields and set value from filtered options .
*
2022-03-18 11:45:14 +00:00
* @param { Array } options Array of filterable options .
* @param { string } countryState The value of the countryState field .
* @param { Function } setValue Set value of the countryState input .
2019-10-30 23:44:57 +00:00
* @return { Object } React component .
* /
2022-02-01 18:13:54 +00:00
export function useGetCountryStateAutofill (
options : Option [ ] ,
countryState : string ,
setValue : ( key : string , value : string ) = > void
) : JSX . Element {
2019-10-30 23:44:57 +00:00
const [ autofillCountry , setAutofillCountry ] = useState ( '' ) ;
const [ autofillState , setAutofillState ] = useState ( '' ) ;
2022-04-13 08:57:58 +00:00
const isAutofillChange = useRef < boolean > ( ) ;
2019-10-30 23:44:57 +00:00
2022-02-01 18:13:54 +00:00
// Sync the autofill fields on first render and the countryState value changes.
2020-02-14 02:23:21 +00:00
useEffect ( ( ) = > {
2022-02-01 18:13:54 +00:00
if ( ! isAutofillChange . current ) {
const option = options . find ( ( opt ) = > opt . key === countryState ) ;
const labels = option
? option . label . split ( /\u2013|\u2014|\-/ )
: [ ] ;
const newCountry = ( labels [ 0 ] || '' ) . trim ( ) ;
const newState = ( labels [ 1 ] || '' ) . trim ( ) ;
2021-08-23 17:36:00 +00:00
2022-02-01 18:13:54 +00:00
if (
newCountry !== autofillCountry ||
newState !== autofillState
) {
setAutofillCountry ( newCountry ) ;
setAutofillState ( newState ) ;
}
2021-08-23 17:36:00 +00:00
}
isAutofillChange . current = false ;
2022-02-01 18:13:54 +00:00
// Disable reason: If we include autofillCountry/autofillState in the dependency array, we will have an unnecessary function call because we also update them in this function.
// eslint-disable-next-line react-hooks/exhaustive-deps
} , [ countryState , options ] ) ;
2021-08-23 17:36:00 +00:00
2022-02-01 18:13:54 +00:00
// Sync the countryState value the autofill fields changes
2021-08-23 17:36:00 +00:00
useEffect ( ( ) = > {
2022-02-01 18:13:54 +00:00
// Skip on first render since we only want to update the value when the autofill fields changes.
if ( isAutofillChange . current === undefined ) {
return ;
}
2021-08-23 17:36:00 +00:00
if ( ! autofillCountry && ! autofillState && countryState ) {
2022-02-01 18:13:54 +00:00
// Clear form
2021-08-23 17:36:00 +00:00
isAutofillChange . current = true ;
setValue ( 'countryState' , '' ) ;
2022-02-01 18:13:54 +00:00
return ;
2021-08-23 17:36:00 +00:00
}
2020-02-14 02:23:21 +00:00
const countrySearch = new RegExp (
escapeRegExp ( autofillCountry ) ,
'i'
) ;
2022-02-01 18:13:54 +00:00
const isCountryAbbreviation = autofillCountry . length < 3 ;
const isStateAbbreviation =
autofillState . length < 3 && ! ! autofillState . match ( /^[\w]+$/ ) ;
2022-04-13 08:57:58 +00:00
let filteredOptions : Option [ ] = [ ] ;
2022-02-01 18:13:54 +00:00
2020-02-14 02:23:21 +00:00
if ( autofillCountry . length && autofillState . length ) {
2022-02-01 18:13:54 +00:00
filteredOptions = options . filter ( ( option ) = >
countrySearch . test (
isCountryAbbreviation ? option.key : option.label
2020-02-14 02:23:21 +00:00
)
) ;
2022-02-01 18:13:54 +00:00
// no country matches so use all options for state filter.
if ( ! filteredOptions . length ) {
filteredOptions = [ . . . options ] ;
2020-02-14 02:23:21 +00:00
}
if ( filteredOptions . length > 1 ) {
2022-02-01 18:13:54 +00:00
filteredOptions = filteredOptions . filter (
getStateFilter (
isStateAbbreviation ,
normalizeState ( autofillState )
2021-08-23 17:36:00 +00:00
)
2020-02-14 02:23:21 +00:00
) ;
2019-10-30 23:44:57 +00:00
}
2022-02-01 18:13:54 +00:00
} else if ( autofillCountry . length ) {
filteredOptions = options . filter ( ( option ) = >
countrySearch . test (
isCountryAbbreviation ? option.key : option.label
)
) ;
} else if ( autofillState . length ) {
filteredOptions = options . filter (
getStateFilter (
isStateAbbreviation ,
normalizeState ( autofillState )
)
) ;
2020-02-14 02:23:21 +00:00
}
if (
filteredOptions . length === 1 &&
countryState !== filteredOptions [ 0 ] . key
) {
2021-08-23 17:36:00 +00:00
isAutofillChange . current = true ;
2020-02-14 02:23:21 +00:00
setValue ( 'countryState' , filteredOptions [ 0 ] . key ) ;
}
2022-02-01 18:13:54 +00:00
// Disable reason: If we include countryState in the dependency array, we will have an unnecessary function call because we also update it in this function.
// eslint-disable-next-line react-hooks/exhaustive-deps
2021-08-23 17:36:00 +00:00
} , [ autofillCountry , autofillState , options , setValue ] ) ;
2019-10-30 23:44:57 +00:00
return (
2021-08-23 17:36:00 +00:00
< >
2019-10-30 23:44:57 +00:00
< input
2020-02-14 02:23:21 +00:00
onChange = { ( event ) = >
setAutofillCountry ( event . target . value )
}
2019-10-30 23:44:57 +00:00
value = { autofillCountry }
name = "country"
type = "text"
className = "woocommerce-select-control__autofill-input"
2022-02-01 18:13:54 +00:00
tabIndex = { - 1 }
2019-12-11 17:10:05 +00:00
autoComplete = "country"
2019-10-30 23:44:57 +00:00
/ >
< input
2020-02-14 02:23:21 +00:00
onChange = { ( event ) = > setAutofillState ( event . target . value ) }
2019-10-30 23:44:57 +00:00
value = { autofillState }
name = "state"
type = "text"
className = "woocommerce-select-control__autofill-input"
2022-02-01 18:13:54 +00:00
tabIndex = { - 1 }
2019-12-11 17:10:05 +00:00
autoComplete = "address-level1"
2019-10-30 23:44:57 +00:00
/ >
2021-08-23 17:36:00 +00:00
< / >
2019-10-30 23:44:57 +00:00
) ;
}
2022-02-01 18:13:54 +00:00
type StoreAddressProps = {
2022-02-09 04:40:44 +00:00
getInputProps : ( key : string ) = > FormInputProps ;
2022-02-01 18:13:54 +00:00
setValue : ( key : string , value : string ) = > void ;
} ;
2019-08-21 05:58:47 +00:00
/ * *
* Store address fields .
*
2022-03-18 11:45:14 +00:00
* @param { Object } props Props for input components .
2022-02-01 18:13:54 +00:00
* @param { Function } props . getInputProps Get input props .
2022-03-18 11:45:14 +00:00
* @param { Function } props . setValue Set value of the countryState input .
2019-08-21 05:58:47 +00:00
* @return { Object } -
* /
2022-02-01 18:13:54 +00:00
export function StoreAddress ( {
getInputProps ,
setValue ,
} : StoreAddressProps ) : JSX . Element {
2022-01-14 13:39:25 +00:00
const countryState = getInputProps ( 'countryState' ) . value ;
2022-03-01 12:33:41 +00:00
const {
locale ,
hasFinishedResolution ,
countries ,
loadingCountries ,
2022-04-25 05:49:11 +00:00
} = useSelect ( ( select ) = > {
2022-03-01 12:33:41 +00:00
const {
getLocale ,
getCountries ,
hasFinishedResolution : hasFinishedCountryResolution ,
} = select ( COUNTRIES_STORE_NAME ) ;
2022-01-14 13:39:25 +00:00
return {
2022-03-01 12:33:41 +00:00
locale : getLocale ( countryState ) ,
countries : getCountries ( ) ,
loadingCountries : ! hasFinishedCountryResolution ( 'getCountries' ) ,
hasFinishedResolution : hasFinishedCountryResolution ( 'getLocales' ) ,
2022-01-14 13:39:25 +00:00
} ;
} ) ;
2022-03-01 12:33:41 +00:00
const countryStateOptions = useMemo (
( ) = > getCountryStateOptions ( countries ) ,
[ countries ]
) ;
2020-02-14 02:23:21 +00:00
const countryStateAutofill = useGetCountryStateAutofill (
countryStateOptions ,
2022-01-14 13:39:25 +00:00
countryState ,
2020-02-14 02:23:21 +00:00
setValue
) ;
2022-02-07 23:31:08 +00:00
2022-04-25 06:16:39 +00:00
const isLocaleKey = ( key : string ) : key is keyof typeof locale = > {
return locale . hasOwnProperty ( key ) ;
} ;
2022-02-07 23:31:08 +00:00
useEffect ( ( ) = > {
if ( locale ) {
storeAddressFields . forEach ( ( field ) = > {
const fieldKey = field
. replace ( /(address)Line([0-9])/ , '$1$2' )
. toLowerCase ( ) ;
const props = getInputProps ( field ) ;
2022-04-25 06:16:39 +00:00
if (
isLocaleKey ( fieldKey ) &&
locale [ fieldKey ] ? . hidden &&
props . value ? . length > 0
) {
2022-02-07 23:31:08 +00:00
// Clear hidden field.
setValue ( field , '' ) ;
}
} ) ;
}
} , [ countryState , locale ] ) ;
2022-03-01 12:33:41 +00:00
if ( ! hasFinishedResolution || loadingCountries ) {
2022-01-14 13:39:25 +00:00
return < Spinner / > ;
}
2019-08-21 05:58:47 +00:00
return (
< div className = "woocommerce-store-address-fields" >
2022-01-19 14:07:43 +00:00
{ ! locale ? . address_1 ? . hidden && (
< TextControl
label = {
locale ? . address_1 ? . label ||
2022-03-30 09:00:04 +00:00
__ ( 'Address line 1' , 'woocommerce' )
2022-01-19 14:07:43 +00:00
}
required = { isAddressFieldRequired ( 'address_1' , locale ) }
autoComplete = "address-line1"
{ . . . getInputProps ( 'addressLine1' ) }
/ >
) }
2019-08-21 05:58:47 +00:00
2022-01-19 14:07:43 +00:00
{ ! locale ? . address_2 ? . hidden && (
< TextControl
label = {
locale ? . address_2 ? . label ||
2022-03-30 09:00:04 +00:00
__ ( 'Address line 2 (optional)' , 'woocommerce' )
2022-01-19 14:07:43 +00:00
}
required = { isAddressFieldRequired ( 'address_2' , locale ) }
autoComplete = "address-line2"
{ . . . getInputProps ( 'addressLine2' ) }
/ >
) }
2019-08-21 05:58:47 +00:00
< SelectControl
2022-03-30 09:00:04 +00:00
label = { __ ( 'Country / Region' , 'woocommerce' ) }
2019-08-21 05:58:47 +00:00
required
2021-08-23 17:36:00 +00:00
autoComplete = "new-password" // disable autocomplete and autofill
2019-08-21 05:58:47 +00:00
options = { countryStateOptions }
2020-01-13 03:21:06 +00:00
excludeSelectedOptions = { false }
2020-01-14 10:19:59 +00:00
showAllOnFocus
2019-10-07 22:42:32 +00:00
isSearchable
2019-08-21 05:58:47 +00:00
{ . . . getInputProps ( 'countryState' ) }
2019-12-31 02:31:59 +00:00
controlClassName = { getInputProps ( 'countryState' ) . className }
2019-10-30 23:44:57 +00:00
>
2020-02-14 02:23:21 +00:00
{ countryStateAutofill }
2019-10-30 23:44:57 +00:00
< / SelectControl >
2019-08-21 05:58:47 +00:00
2022-01-19 14:07:43 +00:00
{ ! locale ? . city ? . hidden && (
< TextControl
2022-03-30 09:00:04 +00:00
label = { locale ? . city ? . label || __ ( 'City' , 'woocommerce' ) }
2022-01-19 14:07:43 +00:00
required = { isAddressFieldRequired ( 'city' , locale ) }
{ . . . getInputProps ( 'city' ) }
autoComplete = "address-level2"
/ >
) }
2019-08-21 05:58:47 +00:00
2022-01-19 14:07:43 +00:00
{ ! locale ? . postcode ? . hidden && (
< TextControl
label = {
locale ? . postcode ? . label ||
2022-03-30 09:00:04 +00:00
__ ( 'Post code' , 'woocommerce' )
2022-01-19 14:07:43 +00:00
}
required = { isAddressFieldRequired ( 'postcode' , locale ) }
autoComplete = "postal-code"
{ . . . getInputProps ( 'postCode' ) }
/ >
) }
2019-08-21 05:58:47 +00:00
< / div >
) ;
}