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 ] );
+ }
+ );
+} );