Fix country region select with autocomplete (https://github.com/woocommerce/woocommerce-admin/pull/7497)
* initial refactor * Fix auto fill and write tests * Removed autofill from country/region and added support for abbreviation regions * Add changelog * Add changelog for the component package * Fix clear form for autofill
This commit is contained in:
parent
7cb06f00fd
commit
c6abb21840
|
@ -0,0 +1,4 @@
|
|||
Significance: patch
|
||||
Type: Fix
|
||||
|
||||
Update country region typeahead for better autofill support. #7497
|
|
@ -4,7 +4,7 @@
|
|||
import { __ } from '@wordpress/i18n';
|
||||
import { decodeEntities } from '@wordpress/html-entities';
|
||||
import { escapeRegExp } from 'lodash';
|
||||
import { Fragment, useEffect, useMemo, useState } from '@wordpress/element';
|
||||
import { useEffect, useMemo, useState, useRef } from '@wordpress/element';
|
||||
import { getSetting } from '@woocommerce/wc-admin-settings';
|
||||
import { SelectControl, TextControl } from '@woocommerce/components';
|
||||
|
||||
|
@ -85,33 +85,63 @@ export function getCountryStateOptions() {
|
|||
export function useGetCountryStateAutofill( options, countryState, setValue ) {
|
||||
const [ autofillCountry, setAutofillCountry ] = useState( '' );
|
||||
const [ autofillState, setAutofillState ] = useState( '' );
|
||||
const isAutofillChange = useRef();
|
||||
|
||||
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 &&
|
||||
( newCountry !== autofillCountry || newState !== autofillState )
|
||||
) {
|
||||
setAutofillCountry( newCountry );
|
||||
setAutofillState( newState );
|
||||
}
|
||||
isAutofillChange.current = false;
|
||||
}, [ countryState ] );
|
||||
|
||||
useEffect( () => {
|
||||
if ( ! autofillCountry && ! autofillState && countryState ) {
|
||||
// Clear form.
|
||||
isAutofillChange.current = true;
|
||||
setValue( 'countryState', '' );
|
||||
}
|
||||
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 ) =>
|
||||
countrySearch.test( option.label )
|
||||
( autofillCountry.length ? countrySearch : stateSearch ).test(
|
||||
option.label
|
||||
)
|
||||
);
|
||||
}
|
||||
if ( autofillCountry.length && autofillState.length ) {
|
||||
const stateSearch = new RegExp(
|
||||
escapeRegExp( autofillState.replace( /\s/g, '' ) ),
|
||||
'i'
|
||||
);
|
||||
const isStateAbbreviation = autofillState.length < 3;
|
||||
filteredOptions = filteredOptions.filter( ( option ) =>
|
||||
stateSearch.test(
|
||||
option.label.replace( '-', '' ).replace( /\s/g, '' )
|
||||
( isStateAbbreviation ? option.key : option.label )
|
||||
.replace( '-', '' )
|
||||
.replace( /\s/g, '' )
|
||||
)
|
||||
);
|
||||
|
||||
const isCountryAbbreviation = autofillCountry.length < 3;
|
||||
if ( filteredOptions.length > 1 ) {
|
||||
let countryKeyOptions = [];
|
||||
countryKeyOptions = filteredOptions.filter( ( option ) =>
|
||||
countrySearch.test( option.key )
|
||||
countrySearch.test(
|
||||
isCountryAbbreviation ? option.key : option.label
|
||||
)
|
||||
);
|
||||
|
||||
if ( countryKeyOptions.length > 0 ) {
|
||||
|
@ -122,7 +152,11 @@ export function useGetCountryStateAutofill( options, countryState, setValue ) {
|
|||
if ( filteredOptions.length > 1 ) {
|
||||
let stateKeyOptions = [];
|
||||
stateKeyOptions = filteredOptions.filter( ( option ) =>
|
||||
stateSearch.test( option.key )
|
||||
stateSearch.test(
|
||||
( isStateAbbreviation ? option.key : option.label )
|
||||
.replace( '-', '' )
|
||||
.replace( /\s/g, '' )
|
||||
)
|
||||
);
|
||||
|
||||
if ( stateKeyOptions.length === 1 ) {
|
||||
|
@ -135,12 +169,13 @@ export function useGetCountryStateAutofill( options, countryState, setValue ) {
|
|||
filteredOptions.length === 1 &&
|
||||
countryState !== filteredOptions[ 0 ].key
|
||||
) {
|
||||
isAutofillChange.current = true;
|
||||
setValue( 'countryState', filteredOptions[ 0 ].key );
|
||||
}
|
||||
}, [ autofillCountry, autofillState, countryState, options, setValue ] );
|
||||
}, [ autofillCountry, autofillState, options, setValue ] );
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<>
|
||||
<input
|
||||
onChange={ ( event ) =>
|
||||
setAutofillCountry( event.target.value )
|
||||
|
@ -162,7 +197,7 @@ export function useGetCountryStateAutofill( options, countryState, setValue ) {
|
|||
tabIndex="-1"
|
||||
autoComplete="address-level1"
|
||||
/>
|
||||
</Fragment>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -200,6 +235,7 @@ export function StoreAddress( props ) {
|
|||
<SelectControl
|
||||
label={ __( 'Country / Region', 'woocommerce-admin' ) }
|
||||
required
|
||||
autoComplete="new-password" // disable autocomplete and autofill
|
||||
options={ countryStateOptions }
|
||||
excludeSelectedOptions={ false }
|
||||
showAllOnFocus
|
||||
|
|
|
@ -0,0 +1,142 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useState, useEffect, useCallback } from '@wordpress/element';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useGetCountryStateAutofill } from '../store-address';
|
||||
|
||||
const AutofillWrapper = ( { options, value, onChange } ) => {
|
||||
const [ values, setValues ] = useState( { countryState: value || '' } );
|
||||
const setCountryState = useCallback( ( key, newValue ) => {
|
||||
setValues( {
|
||||
...values,
|
||||
[ key ]: newValue,
|
||||
} );
|
||||
}, [] );
|
||||
useEffect( () => {
|
||||
setValues( { countryState: value } );
|
||||
}, [ value ] );
|
||||
useEffect( () => {
|
||||
if ( onChange ) {
|
||||
onChange( values );
|
||||
}
|
||||
}, [ values ] );
|
||||
const countryStateAutofill = useGetCountryStateAutofill(
|
||||
options,
|
||||
values.countryState,
|
||||
setCountryState
|
||||
);
|
||||
|
||||
return countryStateAutofill;
|
||||
};
|
||||
|
||||
const DEFAULT_OPTIONS = [
|
||||
{ key: 'KH', label: 'Cambodia' },
|
||||
{ key: 'CM', label: 'Cameroon' },
|
||||
{ key: 'CA:AB', label: 'Canada — Alberta' },
|
||||
{ key: 'CA:BC', label: 'Canada — British Columbia' },
|
||||
{ key: 'CA:MB', label: 'Canada — Manitoba' },
|
||||
{ key: 'US:CA', label: 'United States - California' },
|
||||
];
|
||||
describe( 'useGetCountryStateAutofill', () => {
|
||||
it( 'should render a country and state inputs with autoComplete', () => {
|
||||
const { queryAllByRole } = render(
|
||||
<AutofillWrapper options={ [ ...DEFAULT_OPTIONS ] } />
|
||||
);
|
||||
const inputs = queryAllByRole( 'textbox' );
|
||||
|
||||
expect( inputs.length ).toBe( 2 );
|
||||
expect( inputs[ 0 ].autocomplete ).toEqual( 'country' );
|
||||
expect( inputs[ 1 ].autocomplete ).toEqual( 'address-level1' );
|
||||
} );
|
||||
|
||||
it( 'should set autocomplete fields if a value is selected', () => {
|
||||
const { queryAllByRole } = render(
|
||||
<AutofillWrapper options={ [ ...DEFAULT_OPTIONS ] } value="CA:MB" />
|
||||
);
|
||||
const inputs = queryAllByRole( 'textbox' );
|
||||
|
||||
expect( inputs.length ).toBe( 2 );
|
||||
expect( inputs[ 0 ].value ).toEqual( 'Canada' );
|
||||
expect( inputs[ 1 ].value ).toEqual( 'Manitoba' );
|
||||
} );
|
||||
|
||||
it( 'should select region by key if abbreviation is used', () => {
|
||||
const onChange = jest.fn();
|
||||
const { queryAllByRole } = render(
|
||||
<AutofillWrapper
|
||||
options={ [ ...DEFAULT_OPTIONS ] }
|
||||
onChange={ onChange }
|
||||
/>
|
||||
);
|
||||
const inputs = queryAllByRole( 'textbox' );
|
||||
fireEvent.change( inputs[ 0 ], { target: { value: 'United States' } } );
|
||||
fireEvent.change( inputs[ 1 ], {
|
||||
target: { value: 'CA' },
|
||||
} );
|
||||
expect( onChange ).toHaveBeenCalledWith( { countryState: 'US:CA' } );
|
||||
} );
|
||||
|
||||
it( 'should update the value if the auto complete fields changed', () => {
|
||||
const onChange = jest.fn();
|
||||
const { queryAllByRole } = render(
|
||||
<AutofillWrapper
|
||||
options={ [ ...DEFAULT_OPTIONS ] }
|
||||
onChange={ onChange }
|
||||
/>
|
||||
);
|
||||
const inputs = queryAllByRole( 'textbox' );
|
||||
fireEvent.change( inputs[ 0 ], { target: { value: 'Canada' } } );
|
||||
fireEvent.change( inputs[ 1 ], {
|
||||
target: { value: 'British Columbia' },
|
||||
} );
|
||||
expect( onChange ).toHaveBeenCalledWith( { countryState: 'CA:BC' } );
|
||||
} );
|
||||
|
||||
it( 'should update the value if the auto complete fields changed and value was already set', () => {
|
||||
const onChange = jest.fn();
|
||||
const { queryAllByRole } = render(
|
||||
<AutofillWrapper
|
||||
options={ [ ...DEFAULT_OPTIONS ] }
|
||||
onChange={ onChange }
|
||||
value="CM"
|
||||
/>
|
||||
);
|
||||
const inputs = queryAllByRole( 'textbox' );
|
||||
expect( inputs[ 0 ].value ).toEqual( 'Cameroon' );
|
||||
onChange.mockClear();
|
||||
fireEvent.change( inputs[ 0 ], { target: { value: 'Canada' } } );
|
||||
fireEvent.change( inputs[ 1 ], {
|
||||
target: { value: 'British Columbia' },
|
||||
} );
|
||||
expect( onChange ).toHaveBeenCalledWith( { countryState: 'CA:BC' } );
|
||||
} );
|
||||
|
||||
it( 'should update the auto complete inputs when value changed and inputs already set', () => {
|
||||
const onChange = jest.fn();
|
||||
const options = [ ...DEFAULT_OPTIONS ];
|
||||
const { rerender, queryAllByRole } = render(
|
||||
<AutofillWrapper options={ options } onChange={ onChange } />
|
||||
);
|
||||
let inputs = queryAllByRole( 'textbox' );
|
||||
fireEvent.change( inputs[ 0 ], { target: { value: 'Canada' } } );
|
||||
fireEvent.change( inputs[ 1 ], {
|
||||
target: { value: 'British Columbia' },
|
||||
} );
|
||||
expect( onChange ).toHaveBeenCalledWith( { countryState: 'CA:BC' } );
|
||||
rerender(
|
||||
<AutofillWrapper
|
||||
options={ options }
|
||||
onChange={ onChange }
|
||||
value="KH"
|
||||
/>
|
||||
);
|
||||
inputs = queryAllByRole( 'textbox' );
|
||||
expect( inputs[ 0 ].value ).toEqual( 'Cambodia' );
|
||||
expect( inputs[ 1 ].value ).toEqual( '' );
|
||||
} );
|
||||
} );
|
|
@ -1,4 +1,5 @@
|
|||
# Unreleased
|
||||
- Add `autoComplete` prop to the `SelectControl` component. #7497
|
||||
|
||||
# 8.1.0
|
||||
|
||||
|
|
|
@ -124,12 +124,13 @@ class Control extends Component {
|
|||
onSearch,
|
||||
placeholder,
|
||||
searchInputType,
|
||||
autoComplete,
|
||||
} = this.props;
|
||||
const { isActive } = this.state;
|
||||
|
||||
return (
|
||||
<input
|
||||
autoComplete="off"
|
||||
autoComplete={ autoComplete || 'off' }
|
||||
className="woocommerce-select-control__control-input"
|
||||
id={ `woocommerce-select-control-${ instanceId }__control-input` }
|
||||
ref={ this.input }
|
||||
|
@ -329,6 +330,10 @@ Control.propTypes = {
|
|||
* Show all options on focusing, even if a query exists.
|
||||
*/
|
||||
showAllOnFocus: PropTypes.bool,
|
||||
/**
|
||||
* Control input autocomplete field, defaults: off.
|
||||
*/
|
||||
autoComplete: PropTypes.string,
|
||||
};
|
||||
|
||||
export default Control;
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
*/
|
||||
import { __, _n, sprintf } from '@wordpress/i18n';
|
||||
import classnames from 'classnames';
|
||||
import { createElement, Component } from '@wordpress/element';
|
||||
import { Component, createElement } from '@wordpress/element';
|
||||
import { debounce, escapeRegExp, identity, noop } from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import { withFocusOutside, withSpokenMessages } from '@wordpress/components';
|
||||
|
@ -539,6 +539,10 @@ SelectControl.propTypes = {
|
|||
* Render results list positioned statically instead of absolutely.
|
||||
*/
|
||||
staticList: PropTypes.bool,
|
||||
/**
|
||||
* autocomplete prop for the Control input field.
|
||||
*/
|
||||
autoComplete: PropTypes.string,
|
||||
};
|
||||
|
||||
SelectControl.defaultProps = {
|
||||
|
@ -559,6 +563,7 @@ SelectControl.defaultProps = {
|
|||
showClearButton: false,
|
||||
hideBeforeSearch: false,
|
||||
staticList: false,
|
||||
autoComplete: 'off',
|
||||
};
|
||||
|
||||
export default compose( [
|
||||
|
|
Loading…
Reference in New Issue