diff --git a/plugins/woocommerce-admin/changelogs/fix-8225-country-region-not-preserved b/plugins/woocommerce-admin/changelogs/fix-8225-country-region-not-preserved new file mode 100644 index 00000000000..e72d0431acb --- /dev/null +++ b/plugins/woocommerce-admin/changelogs/fix-8225-country-region-not-preserved @@ -0,0 +1,4 @@ +Significance: patch +Type: Fix + +Fix country/region selection not preserved in store details task. #8228 diff --git a/plugins/woocommerce-admin/client/dashboard/components/settings/general/store-address.tsx b/plugins/woocommerce-admin/client/dashboard/components/settings/general/store-address.tsx index 8f50a835d38..b2c288bec14 100644 --- a/plugins/woocommerce-admin/client/dashboard/components/settings/general/store-address.tsx +++ b/plugins/woocommerce-admin/client/dashboard/components/settings/general/store-address.tsx @@ -16,6 +16,9 @@ import { useSelect } from '@wordpress/data'; import { getAdminSetting } from '~/utils/admin-settings'; const { countries } = getAdminSetting( 'dataEndpoints', { countries: {} } ); + +type Option = { key: string; label: string }; + /** * Check if a given address field is required for the locale. * @@ -23,9 +26,12 @@ const { countries } = getAdminSetting( 'dataEndpoints', { countries: {} } ); * @param {Object} locale Locale data. * @return {boolean} Field requirement. */ -export function isAddressFieldRequired( fieldName, locale = {} ) { +export function isAddressFieldRequired( + fieldName: string, + locale: unknown = {} +): boolean { if ( locale[ fieldName ]?.hasOwnProperty( 'required' ) ) { - return locale[ fieldName ]?.required; + return locale[ fieldName ]?.required as boolean; } if ( fieldName === 'address_2' ) { @@ -122,6 +128,58 @@ export function getCountryStateOptions() { return countryStateOptions; } +/** + * 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 + * + * @param {string} isStateAbbreviation Whether to use state abbreviation or not. + * @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; +}; + /** * Get the autofill countryState fields and set value from filtered options. * @@ -130,91 +188,94 @@ export function getCountryStateOptions() { * @param {Function} setValue Set value of the countryState input. * @return {Object} React component. */ -export function useGetCountryStateAutofill( options, countryState, setValue ) { +export function useGetCountryStateAutofill( + options: Option[], + countryState: string, + setValue: ( key: string, value: string ) => void +): JSX.Element { const [ autofillCountry, setAutofillCountry ] = useState( '' ); const [ autofillState, setAutofillState ] = useState( '' ); const isAutofillChange: { current: boolean; } = useRef(); + // Sync the autofill fields on first render and the countryState value changes. useEffect( () => { - 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(); + 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(); - if ( - ! isAutofillChange.current && - ( newCountry !== autofillCountry || newState !== autofillState ) - ) { - setAutofillCountry( newCountry ); - setAutofillState( newState ); + if ( + newCountry !== autofillCountry || + newState !== autofillState + ) { + setAutofillCountry( newCountry ); + setAutofillState( newState ); + } } isAutofillChange.current = false; - }, [ countryState ] ); + // 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 ] ); + // Sync the countryState value the autofill fields changes useEffect( () => { + // Skip on first render since we only want to update the value when the autofill fields changes. + if ( isAutofillChange.current === undefined ) { + return; + } + if ( ! autofillCountry && ! autofillState && countryState ) { - // Clear form. + // Clear form isAutofillChange.current = true; setValue( 'countryState', '' ); + return; } - let filteredOptions = []; const countrySearch = new RegExp( escapeRegExp( autofillCountry ), 'i' ); - const stateSearch = new RegExp( - escapeRegExp( autofillState.replace( /\s/g, '' ) ) + '$', // Always match the end of string for region. - 'i' - ); - if ( autofillState.length || autofillCountry.length ) { - filteredOptions = options.filter( ( option ) => - ( autofillCountry.length ? countrySearch : stateSearch ).test( - option.label - ) - ); - } + const isCountryAbbreviation = autofillCountry.length < 3; + const isStateAbbreviation = + autofillState.length < 3 && !! autofillState.match( /^[\w]+$/ ); + let filteredOptions = []; + if ( autofillCountry.length && autofillState.length ) { - const isStateAbbreviation = autofillState.length < 3; - filteredOptions = filteredOptions.filter( ( option ) => - stateSearch.test( - ( isStateAbbreviation ? option.key : option.label ) - .replace( '-', '' ) - .replace( /\s/g, '' ) + filteredOptions = options.filter( ( option ) => + countrySearch.test( + isCountryAbbreviation ? option.key : option.label ) ); - - const isCountryAbbreviation = autofillCountry.length < 3; + // no country matches so use all options for state filter. + if ( ! filteredOptions.length ) { + filteredOptions = [ ...options ]; + } if ( filteredOptions.length > 1 ) { - let countryKeyOptions = []; - countryKeyOptions = filteredOptions.filter( ( option ) => - countrySearch.test( - isCountryAbbreviation ? option.key : option.label + filteredOptions = filteredOptions.filter( + getStateFilter( + isStateAbbreviation, + normalizeState( autofillState ) ) ); - - if ( countryKeyOptions.length > 0 ) { - filteredOptions = countryKeyOptions; - } - } - - if ( filteredOptions.length > 1 ) { - let stateKeyOptions = []; - stateKeyOptions = filteredOptions.filter( ( option ) => - stateSearch.test( - ( isStateAbbreviation ? option.key : option.label ) - .replace( '-', '' ) - .replace( /\s/g, '' ) - ) - ); - - if ( stateKeyOptions.length === 1 ) { - filteredOptions = stateKeyOptions; - } } + } 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 ) + ) + ); } - if ( filteredOptions.length === 1 && countryState !== filteredOptions[ 0 ].key @@ -222,6 +283,8 @@ export function useGetCountryStateAutofill( options, countryState, setValue ) { isAutofillChange.current = true; setValue( 'countryState', filteredOptions[ 0 ].key ); } + // 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 }, [ autofillCountry, autofillState, options, setValue ] ); return ( @@ -234,7 +297,7 @@ export function useGetCountryStateAutofill( options, countryState, setValue ) { name="country" type="text" className="woocommerce-select-control__autofill-input" - tabIndex="-1" + tabIndex={ -1 } autoComplete="country" /> @@ -244,21 +307,32 @@ export function useGetCountryStateAutofill( options, countryState, setValue ) { name="state" type="text" className="woocommerce-select-control__autofill-input" - tabIndex="-1" + tabIndex={ -1 } autoComplete="address-level1" /> ); } +type StoreAddressProps = { + // Disable reason: The getInputProps type are not provided by the caller and source. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getInputProps: any; + setValue: ( key: string, value: string ) => void; +}; + /** * Store address fields. * * @param {Object} props Props for input components. + * @param {Function} props.getInputProps Get input props. + * @param {Function} props.setValue Set value of the countryState input. * @return {Object} - */ -export function StoreAddress( props ) { - const { getInputProps, setValue, onChange } = props; +export function StoreAddress( { + getInputProps, + setValue, +}: StoreAddressProps ): JSX.Element { const countryState = getInputProps( 'countryState' ).value; const { locale, hasFinishedResolution } = useSelect( ( select ) => { return { @@ -274,7 +348,6 @@ export function StoreAddress( props ) { countryState, setValue ); - if ( ! hasFinishedResolution ) { return ; } diff --git a/plugins/woocommerce-admin/client/dashboard/components/settings/general/test/use-get-country-state-autofill.js b/plugins/woocommerce-admin/client/dashboard/components/settings/general/test/store-address.js similarity index 61% rename from plugins/woocommerce-admin/client/dashboard/components/settings/general/test/use-get-country-state-autofill.js rename to plugins/woocommerce-admin/client/dashboard/components/settings/general/test/store-address.js index 0140fe46c83..4f05554f9ba 100644 --- a/plugins/woocommerce-admin/client/dashboard/components/settings/general/test/use-get-country-state-autofill.js +++ b/plugins/woocommerce-admin/client/dashboard/components/settings/general/test/store-address.js @@ -7,7 +7,7 @@ import { render, fireEvent } from '@testing-library/react'; /** * Internal dependencies */ -import { useGetCountryStateAutofill } from '../store-address'; +import { useGetCountryStateAutofill, getStateFilter } from '../store-address'; const AutofillWrapper = ( { options, value, onChange } ) => { const [ values, setValues ] = useState( { countryState: value || '' } ); @@ -41,7 +41,12 @@ const DEFAULT_OPTIONS = [ { key: 'CA:BC', label: 'Canada — British Columbia' }, { key: 'CA:MB', label: 'Canada — Manitoba' }, { key: 'US:CA', label: 'United States - California' }, + { key: 'US:AR', label: 'United States (US) — Arkansas' }, + { key: 'US:KS', label: 'United States (US) — Kansas' }, + { key: 'CN:CN2', label: 'China — Beijing / 北京' }, + { key: 'IR:THR', label: 'Iran — Tehran (تهران)' }, ]; + describe( 'useGetCountryStateAutofill', () => { it( 'should render a country and state inputs with autoComplete', () => { const { queryAllByRole } = render( @@ -54,7 +59,23 @@ describe( 'useGetCountryStateAutofill', () => { expect( inputs[ 1 ].autocomplete ).toEqual( 'address-level1' ); } ); - it( 'should set autocomplete fields if a value is selected', () => { + it( 'should set countryState value if a value is provided', () => { + const onChange = jest.fn(); + render( + + ); + + // check the most recent call values + expect( onChange.mock.calls.pop() ).toEqual( [ + { countryState: 'US:KS' }, + ] ); + } ); + + it( 'should set autocomplete fields if the countryState is not empty', () => { const { queryAllByRole } = render( ); @@ -65,7 +86,7 @@ describe( 'useGetCountryStateAutofill', () => { expect( inputs[ 1 ].value ).toEqual( 'Manitoba' ); } ); - it( 'should select region by key if abbreviation is used', () => { + it( 'should set countryState if auto complete fields are changed and abbreviation is used', () => { const onChange = jest.fn(); const { queryAllByRole } = render( { expect( onChange ).toHaveBeenCalledWith( { countryState: 'US:CA' } ); } ); - it( 'should update the value if the auto complete fields changed', () => { + it( 'should set countryState if auto complete fields are changed and abbreviation is not used', () => { const onChange = jest.fn(); const { queryAllByRole } = render( { expect( onChange ).toHaveBeenCalledWith( { countryState: 'CA:BC' } ); } ); - it( 'should update the value if the auto complete fields changed and value was already set', () => { + it( 'should update the countryState if the auto complete fields changed and countryState was already set', () => { const onChange = jest.fn(); const { queryAllByRole } = render( { expect( onChange ).toHaveBeenCalledWith( { countryState: 'CA:BC' } ); } ); - it( 'should update the auto complete inputs when value changed and inputs already set', () => { + it( 'should update the auto complete fields when countryState is changed and inputs already set', () => { const onChange = jest.fn(); const options = [ ...DEFAULT_OPTIONS ]; const { rerender, queryAllByRole } = render( @@ -140,3 +161,53 @@ describe( 'useGetCountryStateAutofill', () => { expect( inputs[ 1 ].value ).toEqual( '' ); } ); } ); + +describe( 'getStateFilter', () => { + test.each( [ + { + isStateAbbreviation: false, + normalizedAutofillState: 'britishcolumbia', + expected: { key: 'CA:BC', label: 'Canada — British Columbia' }, + }, + { + isStateAbbreviation: true, + normalizedAutofillState: 'ks', + expected: { + key: 'US:KS', + label: 'United States (US) — Kansas', + }, + }, + { + isStateAbbreviation: false, + normalizedAutofillState: '北京', + expected: { key: 'CN:CN2', label: 'China — Beijing / 北京' }, + }, + { + isStateAbbreviation: false, + normalizedAutofillState: 'beijing', + expected: { key: 'CN:CN2', label: 'China — Beijing / 北京' }, + }, + { + isStateAbbreviation: false, + normalizedAutofillState: 'تهران', + expected: { key: 'IR:THR', label: 'Iran — Tehran (تهران)' }, + }, + { + isStateAbbreviation: false, + normalizedAutofillState: 'tehran', + expected: { key: 'IR:THR', label: 'Iran — Tehran (تهران)' }, + }, + ] )( + 'should filter state matches with isStateAbbreviation=$isStateAbbreviation and normalizedAutofillState=$normalizedAutofillState', + ( { isStateAbbreviation, normalizedAutofillState, expected } ) => { + expect( + DEFAULT_OPTIONS.filter( + getStateFilter( + isStateAbbreviation, + normalizedAutofillState + ) + ) + ).toEqual( [ expected ] ); + } + ); +} );