Commit 2af72a7817d for woocommerce
commit 2af72a7817d073022dab83fecea910bf84bd15dd
Author: Anand Rajaram <anandrajaram21@gmail.com>
Date: Thu Aug 20 08:43:02 2026 +0530
Normalize search filter IDs and label handling (#67634)
* Normalize search filter IDs and label handling
* Add changefile(s) from automation for the following project(s): @woocommerce/components
* Disable tabindex lint rule for search filter
* Handle array values in search filters
* Normalize search filter values and ignore stale labels
* Normalize initial search filter values once
* Handle null search filter values
---------
Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
diff --git a/packages/js/components/changelog/67634-35900-ip-search-bug b/packages/js/components/changelog/67634-35900-ip-search-bug
new file mode 100644
index 00000000000..dfe0597cd2e
--- /dev/null
+++ b/packages/js/components/changelog/67634-35900-ip-search-bug
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix removal of Advanced Filter selections that use string identifiers, such as download IP addresses.
diff --git a/packages/js/components/src/advanced-filters/search-filter.js b/packages/js/components/src/advanced-filters/search-filter.js
index efae4d61be6..e9fc6da8e86 100644
--- a/packages/js/components/src/advanced-filters/search-filter.js
+++ b/packages/js/components/src/advanced-filters/search-filter.js
@@ -3,7 +3,6 @@
*/
import { createElement, Component, Fragment } from '@wordpress/element';
import { SelectControl } from '@wordpress/components';
-import { getIdsFromQuery } from '@woocommerce/navigation';
import { find, isEqual } from 'lodash';
import PropTypes from 'prop-types';
import clsx from 'clsx';
@@ -17,8 +16,15 @@ import {
textContent,
} from './utils';
+const normalizeFilterValue = ( value ) => {
+ if ( Array.isArray( value ) ) {
+ return value.join( ',' );
+ }
+ return typeof value === 'string' ? value : '';
+};
+
class SearchFilter extends Component {
- constructor( { filter, config, query } ) {
+ constructor( { filter, query } ) {
super( ...arguments );
this.onSearchChange = this.onSearchChange.bind( this );
this.state = {
@@ -27,44 +33,57 @@ class SearchFilter extends Component {
this.updateLabels = this.updateLabels.bind( this );
- if ( filter.value.length ) {
- config.input
- .getLabels( filter.value, query )
- .then( ( selected ) => {
- const selectedWithKeys = selected.map( ( s ) => ( {
- key: s.id,
- ...s,
- } ) );
-
- this.updateLabels( selectedWithKeys );
- } );
+ const filterValue = normalizeFilterValue( filter.value );
+ if ( filterValue.length ) {
+ this.loadLabels( filterValue, query );
}
}
componentDidUpdate( prevProps ) {
- const { config, filter, query } = this.props;
+ const { filter, query } = this.props;
const { filter: prevFilter } = prevProps;
+ const filterValue = normalizeFilterValue( filter.value );
- if ( filter.value.length && ! isEqual( prevFilter, filter ) ) {
+ if ( filterValue.length && ! isEqual( prevFilter, filter ) ) {
const { selected } = this.state;
- const ids = selected.map( ( item ) => item.key );
- const filterIds = getIdsFromQuery( filter.value );
- const hasNewIds = filterIds.every( ( id ) => ! ids.includes( id ) );
-
- if ( hasNewIds ) {
- config.input
- .getLabels( filter.value, query )
- .then( this.updateLabels );
+ const selectedIds = selected.map( ( item ) => String( item.key ) );
+ const filterIds = filterValue
+ .split( ',' )
+ .filter( Boolean )
+ .map( String );
+ const haveIdsChanged =
+ filterIds.length !== selectedIds.length ||
+ filterIds.some( ( id ) => ! selectedIds.includes( id ) );
+
+ if ( haveIdsChanged ) {
+ this.loadLabels( filterValue, query );
}
}
}
+ loadLabels( filterValue, query ) {
+ this.props.config.input
+ .getLabels( filterValue, query )
+ .then( ( selected ) => {
+ if (
+ filterValue ===
+ normalizeFilterValue( this.props.filter.value )
+ ) {
+ this.updateLabels( selected );
+ }
+ } );
+ }
+
updateLabels( selected ) {
+ const normalizedSelected = selected.map( ( item ) => ( {
+ ...item,
+ key: item.key ?? item.id,
+ } ) );
const prevIds = this.state.selected.map( ( item ) => item.key );
- const ids = selected.map( ( item ) => item.key );
+ const ids = normalizedSelected.map( ( item ) => item.key );
- if ( ! isEqual( ids.sort(), prevIds.sort() ) ) {
- this.setState( { selected } );
+ if ( ! isEqual( [ ...ids ].sort(), [ ...prevIds ].sort() ) ) {
+ this.setState( { selected: normalizedSelected } );
}
}
@@ -138,7 +157,6 @@ class SearchFilter extends Component {
const screenReaderText = this.getScreenReaderText( filter, config );
- /*eslint-disable jsx-a11y/no-noninteractive-tabindex*/
return (
<fieldset
className="woocommerce-filters-advanced__line-item"
@@ -164,7 +182,6 @@ class SearchFilter extends Component {
) }
</fieldset>
);
- /*eslint-enable jsx-a11y/no-noninteractive-tabindex*/
}
}
@@ -187,7 +204,7 @@ SearchFilter.propTypes = {
filter: PropTypes.shape( {
key: PropTypes.string,
rule: PropTypes.string,
- value: PropTypes.string,
+ value: PropTypes.oneOfType( [ PropTypes.string, PropTypes.array ] ),
} ).isRequired,
/**
* Function to be called on update.
diff --git a/packages/js/components/src/advanced-filters/test/search-filter.test.js b/packages/js/components/src/advanced-filters/test/search-filter.test.js
new file mode 100644
index 00000000000..50e8be7ea12
--- /dev/null
+++ b/packages/js/components/src/advanced-filters/test/search-filter.test.js
@@ -0,0 +1,285 @@
+/**
+ * @jest-environment jsdom
+ */
+/**
+ * External dependencies
+ */
+import { act, render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { createElement, createRef } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import SearchFilter from '../search-filter';
+
+const getConfig = ( getLabels ) => ( {
+ labels: {
+ add: 'IP Address',
+ filter: 'Select IP addresses',
+ placeholder: 'Search IP address',
+ rule: 'Select an IP address filter match',
+ title: '<title>IP Address</title> <rule/> <filter/>',
+ },
+ rules: [ { value: 'includes', label: 'Includes' } ],
+ input: {
+ type: 'downloadIps',
+ getLabels,
+ },
+} );
+
+describe( 'SearchFilter', () => {
+ test( 'normalizes string identifiers when an external change reloads labels', async () => {
+ const getLabels = jest
+ .fn()
+ .mockResolvedValue( [ { id: '::1', label: '::1' } ] );
+ const onFilterChange = jest.fn();
+ const ref = createRef();
+ const props = {
+ config: getConfig( getLabels ),
+ filter: { key: 'ip_address', rule: 'includes', value: '' },
+ onFilterChange,
+ query: {},
+ };
+ const { rerender } = render(
+ <SearchFilter ref={ ref } { ...props } />
+ );
+
+ act( () => {
+ ref.current.onSearchChange( [
+ { key: '127.0.0.1', label: '127.0.0.1' },
+ ] );
+ } );
+ onFilterChange.mockClear();
+
+ rerender(
+ <SearchFilter
+ ref={ ref }
+ { ...props }
+ filter={ { ...props.filter, value: '::1' } }
+ />
+ );
+
+ await waitFor( () =>
+ expect( getLabels ).toHaveBeenCalledWith( '::1', {} )
+ );
+ expect( ref.current.state.selected ).toEqual( [
+ { id: '::1', key: '::1', label: '::1' },
+ ] );
+
+ await userEvent.click(
+ screen.getByRole( 'button', { name: 'Remove ::1' } )
+ );
+
+ expect(
+ screen.queryByRole( 'button', { name: 'Remove ::1' } )
+ ).not.toBeInTheDocument();
+ expect( onFilterChange ).toHaveBeenCalledWith( {
+ property: 'value',
+ value: '',
+ } );
+ } );
+
+ test( 'does not reload a current string identifier with stale label data', async () => {
+ const getLabels = jest
+ .fn()
+ .mockResolvedValue( [ { id: '::1', label: '::1' } ] );
+ const onFilterChange = jest.fn();
+ const ref = createRef();
+ const props = {
+ config: getConfig( getLabels ),
+ filter: { key: 'ip_address', rule: 'includes', value: '' },
+ onFilterChange,
+ query: {},
+ };
+ const { rerender } = render(
+ <SearchFilter ref={ ref } { ...props } />
+ );
+
+ act( () => {
+ ref.current.onSearchChange( [ { key: '::1', label: '::1' } ] );
+ } );
+ onFilterChange.mockClear();
+ rerender(
+ <SearchFilter
+ ref={ ref }
+ { ...props }
+ filter={ { ...props.filter, value: '::1' } }
+ />
+ );
+
+ expect( getLabels ).not.toHaveBeenCalled();
+ expect( ref.current.state.selected ).toEqual( [
+ { key: '::1', label: '::1' },
+ ] );
+
+ await userEvent.click(
+ screen.getByRole( 'button', { name: 'Remove ::1' } )
+ );
+ expect( onFilterChange ).toHaveBeenCalledWith( {
+ property: 'value',
+ value: '',
+ } );
+ } );
+
+ test( 'keeps numeric identifiers without reloading their labels', () => {
+ const getLabels = jest
+ .fn()
+ .mockResolvedValue( [ { id: 60, label: '#60' } ] );
+ const ref = createRef();
+ const props = {
+ config: getConfig( getLabels ),
+ filter: { key: 'order', rule: 'includes', value: '' },
+ onFilterChange: jest.fn(),
+ query: {},
+ };
+ const { rerender } = render(
+ <SearchFilter ref={ ref } { ...props } />
+ );
+
+ act( () => {
+ ref.current.onSearchChange( [ { key: 60, label: '#60' } ] );
+ } );
+ rerender(
+ <SearchFilter
+ ref={ ref }
+ { ...props }
+ filter={ { ...props.filter, value: '60' } }
+ />
+ );
+
+ expect( getLabels ).not.toHaveBeenCalled();
+ expect( ref.current.state.selected ).toEqual( [
+ { key: 60, label: '#60' },
+ ] );
+ } );
+
+ test( 'does not reload labels when an array filter value matches the selection', () => {
+ const getLabels = jest.fn();
+ const ref = createRef();
+ const props = {
+ config: getConfig( getLabels ),
+ filter: { key: 'order', rule: 'includes', value: '' },
+ onFilterChange: jest.fn(),
+ query: {},
+ };
+ const { rerender } = render(
+ <SearchFilter ref={ ref } { ...props } />
+ );
+
+ act( () => {
+ ref.current.onSearchChange( [ { key: 60, label: '#60' } ] );
+ } );
+ rerender(
+ <SearchFilter
+ ref={ ref }
+ { ...props }
+ filter={ { ...props.filter, value: [ 60 ] } }
+ />
+ );
+
+ expect( getLabels ).not.toHaveBeenCalled();
+ } );
+
+ test( 'handles null filter values', () => {
+ const getLabels = jest.fn();
+ const props = {
+ config: getConfig( getLabels ),
+ filter: { key: 'order', rule: 'includes', value: null },
+ onFilterChange: jest.fn(),
+ query: {},
+ };
+ const { rerender } = render( <SearchFilter { ...props } /> );
+
+ rerender(
+ <SearchFilter
+ { ...props }
+ filter={ { ...props.filter, value: '' } }
+ />
+ );
+ rerender( <SearchFilter { ...props } /> );
+
+ expect( getLabels ).not.toHaveBeenCalled();
+ } );
+
+ test( 'passes changed array filter values to getLabels as strings', async () => {
+ const getLabels = jest
+ .fn()
+ .mockResolvedValue( [ { id: 60, label: '#60' } ] );
+ const ref = createRef();
+ const props = {
+ config: getConfig( getLabels ),
+ filter: { key: 'order', rule: 'includes', value: '' },
+ onFilterChange: jest.fn(),
+ query: {},
+ };
+ const { rerender } = render(
+ <SearchFilter ref={ ref } { ...props } />
+ );
+
+ rerender(
+ <SearchFilter
+ ref={ ref }
+ { ...props }
+ filter={ { ...props.filter, value: [ 60 ] } }
+ />
+ );
+
+ await waitFor( () =>
+ expect( getLabels ).toHaveBeenCalledWith( '60', {} )
+ );
+ } );
+
+ test( 'ignores stale label responses', async () => {
+ let resolveFirstRequest;
+ let resolveSecondRequest;
+ const firstRequest = new Promise( ( resolve ) => {
+ resolveFirstRequest = resolve;
+ } );
+ const secondRequest = new Promise( ( resolve ) => {
+ resolveSecondRequest = resolve;
+ } );
+ const getLabels = jest
+ .fn()
+ .mockReturnValueOnce( firstRequest )
+ .mockReturnValueOnce( secondRequest );
+ const ref = createRef();
+ const props = {
+ config: getConfig( getLabels ),
+ filter: { key: 'ip_address', rule: 'includes', value: '' },
+ onFilterChange: jest.fn(),
+ query: {},
+ };
+ const { rerender } = render(
+ <SearchFilter ref={ ref } { ...props } />
+ );
+
+ rerender(
+ <SearchFilter
+ ref={ ref }
+ { ...props }
+ filter={ { ...props.filter, value: '::1' } }
+ />
+ );
+ rerender(
+ <SearchFilter
+ ref={ ref }
+ { ...props }
+ filter={ { ...props.filter, value: '127.0.0.1' } }
+ />
+ );
+
+ await act( async () => {
+ resolveSecondRequest( [ { id: '127.0.0.1', label: '127.0.0.1' } ] );
+ await secondRequest;
+ } );
+ await act( async () => {
+ resolveFirstRequest( [ { id: '::1', label: '::1' } ] );
+ await firstRequest;
+ } );
+
+ expect( ref.current.state.selected ).toEqual( [
+ { id: '127.0.0.1', key: '127.0.0.1', label: '127.0.0.1' },
+ ] );
+ } );
+} );