Commit 56e4e8c23a6 for woocommerce
commit 56e4e8c23a60a5872b6bbba3e0d9c5d71d3da46d
Author: Peter Petrov <peter.petrov89@gmail.com>
Date: Fri Sep 4 10:23:17 2026 +0300
Migrate @woocommerce/components AdvancedFilters to TypeScript (#68330)
* Migrate @woocommerce/components AdvancedFilters to TypeScript
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Export AdvancedFilters types from @woocommerce/components
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Type ReportHeader advancedFilters prop with AdvancedFilterConfig
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
diff --git a/packages/js/components/changelog/dev-migrate-advanced-filters-to-ts b/packages/js/components/changelog/dev-migrate-advanced-filters-to-ts
new file mode 100644
index 00000000000..825ab1ba26a
--- /dev/null
+++ b/packages/js/components/changelog/dev-migrate-advanced-filters-to-ts
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Migrate AdvancedFilters component to TS
diff --git a/packages/js/components/src/advanced-filters/attribute-filter.js b/packages/js/components/src/advanced-filters/attribute-filter.tsx
similarity index 82%
rename from packages/js/components/src/advanced-filters/attribute-filter.js
rename to packages/js/components/src/advanced-filters/attribute-filter.tsx
index 2883ae40df1..9781470aee8 100644
--- a/packages/js/components/src/advanced-filters/attribute-filter.js
+++ b/packages/js/components/src/advanced-filters/attribute-filter.tsx
@@ -1,7 +1,6 @@
/**
* External dependencies
*/
-import PropTypes from 'prop-types';
import { SelectControl as Select, Spinner } from '@wordpress/components';
import clsx from 'clsx';
import {
@@ -22,6 +21,24 @@ import {
backwardsCompatibleCreateInterpolateElement as createInterpolateElement,
textContent,
} from './utils';
+import type {
+ ActiveFilter,
+ FilterComponentProps,
+ FilterConfig,
+ FilterRule,
+} from './types';
+
+export type AttributeFilterProps = FilterComponentProps;
+
+type AttributeOption = {
+ key: string;
+ label: string;
+};
+
+type AttributeResponse = {
+ id: number;
+ name: string;
+};
const getScreenReaderText = ( {
attributeTerms,
@@ -29,6 +46,12 @@ const getScreenReaderText = ( {
filter,
selectedAttribute,
selectedAttributeTerm,
+}: {
+ attributeTerms: AttributeOption[] | false;
+ config: FilterConfig;
+ filter: ActiveFilter;
+ selectedAttribute: AttributeOption[];
+ selectedAttributeTerm: string;
} ) => {
if (
! attributeTerms ||
@@ -40,7 +63,7 @@ const getScreenReaderText = ( {
return '';
}
- const rule = Array.isArray( config.rules )
+ const rule: Partial< FilterRule > = Array.isArray( config.rules )
? config.rules.find(
( configRule ) => configRule.value === filter.rule
) || {}
@@ -57,7 +80,6 @@ const getScreenReaderText = ( {
}
const filterStr = createInterpolateElement(
- /* eslint-disable-next-line max-len */
/* translators: Sentence fragment describing a product attribute match. Example: "Color Is Not Blue" - attribute = Color, equals = Is Not, value = Blue */
__( '<attribute/> <equals/> <value/>', 'woocommerce' ),
{
@@ -76,12 +98,14 @@ const getScreenReaderText = ( {
);
};
-const AttributeFilter = ( props ) => {
+const AttributeFilter = ( props: AttributeFilterProps ) => {
const { className, config, filter, isEnglish, onFilterChange } = props;
const { rule, value } = filter;
const { labels, rules } = config;
- const [ selectedAttribute, setSelectedAttribute ] = useState( [] );
+ const [ selectedAttribute, setSelectedAttribute ] = useState<
+ AttributeOption[]
+ >( [] );
// Set selected attribute from filter value (in query string).
useEffect( () => {
@@ -90,7 +114,7 @@ const AttributeFilter = ( props ) => {
Array.isArray( value ) &&
value[ 0 ]
) {
- apiFetch( {
+ void apiFetch< AttributeResponse >( {
path: `/wc-analytics/products/attributes/${ value[ 0 ] }`,
} )
.then( ( { id, name } ) => [
@@ -103,7 +127,9 @@ const AttributeFilter = ( props ) => {
}
}, [ value, selectedAttribute ] );
- const [ attributeTerms, setAttributeTerms ] = useState( [] );
+ const [ attributeTerms, setAttributeTerms ] = useState<
+ AttributeOption[] | false
+ >( [] );
// Fetch all product attributes on mount.
useEffect( () => {
@@ -111,7 +137,7 @@ const AttributeFilter = ( props ) => {
return;
}
setAttributeTerms( false );
- apiFetch( {
+ void apiFetch< AttributeResponse[] >( {
path: `/wc-analytics/products/attributes/${ selectedAttribute[ 0 ].key }/terms?per_page=100`,
} )
.then( ( terms ) =>
@@ -124,7 +150,7 @@ const AttributeFilter = ( props ) => {
}, [ selectedAttribute ] );
const [ selectedAttributeTerm, setSelectedAttributeTerm ] = useState(
- Array.isArray( value ) ? value[ 1 ] || '' : ''
+ Array.isArray( value ) ? String( value[ 1 ] || '' ) : ''
);
const screenReaderText = getScreenReaderText( {
@@ -135,11 +161,10 @@ const AttributeFilter = ( props ) => {
selectedAttributeTerm,
} );
- /*eslint-disable jsx-a11y/no-noninteractive-tabindex*/
return (
<fieldset
className="woocommerce-filters-advanced__line-item"
- tabIndex="0"
+ tabIndex={ 0 }
>
<legend className="screen-reader-text">{ labels.add || '' }</legend>
<div
@@ -179,7 +204,9 @@ const AttributeFilter = ( props ) => {
selectedAttribute.length ? (
<Search
className="woocommerce-filters-advanced__input woocommerce-search"
- onChange={ ( [ attr ] ) => {
+ onChange={ ( [
+ attr,
+ ]: AttributeOption[] ) => {
setSelectedAttribute(
attr ? [ attr ] : []
);
@@ -208,13 +235,12 @@ const AttributeFilter = ( props ) => {
<Spinner />
) }
{ selectedAttribute.length > 0 &&
- ( attributeTerms.length ? (
+ ( attributeTerms && attributeTerms.length ? (
<Fragment>
<span className="woocommerce-filters-advanced__attribute-field-separator">
=
</span>
<SelectControl
- __next40pxDefaultSize
className="woocommerce-filters-advanced__input woocommerce-search"
placeholder={ __(
'Attribute value',
@@ -259,36 +285,6 @@ const AttributeFilter = ( props ) => {
) }
</fieldset>
);
- /*eslint-enable jsx-a11y/no-noninteractive-tabindex*/
-};
-
-AttributeFilter.propTypes = {
- /**
- * The configuration object for the single filter to be rendered.
- */
- config: PropTypes.shape( {
- labels: PropTypes.shape( {
- rule: PropTypes.string,
- title: PropTypes.string,
- filter: PropTypes.string,
- } ),
- rules: PropTypes.arrayOf( PropTypes.object ),
- input: PropTypes.object,
- } ).isRequired,
- /**
- * The activeFilter handed down by AdvancedFilters.
- */
- filter: PropTypes.shape( {
- key: PropTypes.string,
- rule: PropTypes.string,
- value: PropTypes.arrayOf(
- PropTypes.oneOfType( [ PropTypes.string, PropTypes.number ] )
- ),
- } ).isRequired,
- /**
- * Function to be called on update.
- */
- onFilterChange: PropTypes.func.isRequired,
};
export default AttributeFilter;
diff --git a/packages/js/components/src/advanced-filters/date-filter.js b/packages/js/components/src/advanced-filters/date-filter.tsx
similarity index 76%
rename from packages/js/components/src/advanced-filters/date-filter.js
rename to packages/js/components/src/advanced-filters/date-filter.tsx
index 460f5c83d77..719dbb0e8d9 100644
--- a/packages/js/components/src/advanced-filters/date-filter.js
+++ b/packages/js/components/src/advanced-filters/date-filter.tsx
@@ -8,6 +8,8 @@ import clsx from 'clsx';
import { __, _x } from '@wordpress/i18n';
import { isoDateFormat, toMoment } from '@woocommerce/date';
import moment from 'moment';
+import type { Moment } from 'moment';
+import type { ReactNode } from 'react';
/**
* Internal dependencies
@@ -17,13 +19,35 @@ import {
backwardsCompatibleCreateInterpolateElement as createInterpolateElement,
textContent,
} from './utils';
+import type { FilterComponentProps, FilterConfig, FilterRule } from './types';
const dateStringFormat = __( 'MMM D, YYYY', 'woocommerce' );
const dateFormat = __( 'MM/DD/YYYY', 'woocommerce' );
-class DateFilter extends Component {
- constructor( { filter } ) {
- super( ...arguments );
+export type DateFilterProps = FilterComponentProps;
+
+type DateUpdate = {
+ date: Moment | null;
+ text: string;
+ error: string | null;
+};
+
+type RangeInput = 'after' | 'before';
+
+type DateFilterState = {
+ before: Moment | null;
+ beforeText: string;
+ beforeError: string | null;
+ after: Moment | null;
+ afterText: string;
+ afterError: string | null;
+ rule?: string;
+};
+
+class DateFilter extends Component< DateFilterProps, DateFilterState > {
+ constructor( props: DateFilterProps ) {
+ super( props );
+ const { filter } = props;
const [ isoAfter, isoBefore ] = Array.isArray( filter.value )
? filter.value
@@ -54,8 +78,12 @@ class DateFilter extends Component {
);
}
- getScreenReaderText( filterRule, config ) {
- const rule = find( config.rules, { value: filterRule } ) || {};
+ getScreenReaderText(
+ filterRule: string | undefined,
+ config: FilterConfig
+ ) {
+ const rule: Partial< FilterRule > =
+ find( config.rules, { value: filterRule } ) || {};
const { before, after } = this.state;
@@ -64,9 +92,9 @@ class DateFilter extends Component {
return '';
}
- let filterStr = before.format( dateStringFormat );
+ let filterStr: ReactNode = before.format( dateStringFormat );
- if ( rule.value === 'between' ) {
+ if ( rule.value === 'between' && after ) {
filterStr = createInterpolateElement( this.getBetweenString(), {
after: (
<Fragment>{ after.format( dateStringFormat ) }</Fragment>
@@ -87,7 +115,7 @@ class DateFilter extends Component {
);
}
- onSingleDateChange( { date, text, error } ) {
+ onSingleDateChange( { date, text, error }: DateUpdate ) {
const { onFilterChange } = this.props;
this.setState( { before: date, beforeText: text, beforeError: error } );
@@ -99,14 +127,22 @@ class DateFilter extends Component {
}
}
- onRangeDateChange( input, { date, text, error } ) {
+ onRangeDateChange( input: RangeInput, { date, text, error }: DateUpdate ) {
const { onFilterChange } = this.props;
- this.setState( {
- [ input ]: date,
- [ input + 'Text' ]: text,
- [ input + 'Error' ]: error,
- } );
+ if ( input === 'after' ) {
+ this.setState( {
+ after: date,
+ afterText: text,
+ afterError: error,
+ } );
+ } else {
+ this.setState( {
+ before: date,
+ beforeText: text,
+ beforeError: error,
+ } );
+ }
if ( date ) {
const { before, after } = this.state;
@@ -132,31 +168,26 @@ class DateFilter extends Component {
}
}
- onRuleChange( newRule ) {
+ onRuleChange( newRule: string ) {
const { onFilterChange } = this.props;
const { rule } = this.state;
- let newDateState = null;
- let shouldResetValue = false;
+ const shouldResetValue = [ rule, newRule ].includes( 'between' );
- if ( [ rule, newRule ].includes( 'between' ) ) {
- newDateState = {
+ if ( shouldResetValue ) {
+ this.setState( {
+ rule: newRule,
before: null,
beforeText: '',
beforeError: null,
after: null,
afterText: '',
afterError: null,
- };
-
- shouldResetValue = true;
+ } );
+ } else {
+ this.setState( { rule: newRule } );
}
- this.setState( {
- rule: newRule,
- ...newDateState,
- } );
-
onFilterChange( {
property: 'rule',
value: newRule,
@@ -164,11 +195,16 @@ class DateFilter extends Component {
} );
}
- isFutureDate( dateString ) {
- return moment().isBefore( moment( dateString ), 'day' );
+ isFutureDate( date: Date ) {
+ return moment().isBefore( moment( date ), 'day' );
}
- getFormControl( { date, error, onUpdate, text } ) {
+ getFormControl( {
+ date,
+ error,
+ onUpdate,
+ text,
+ }: DateUpdate & { onUpdate: ( update: DateUpdate ) => void } ) {
return (
<DatePicker
date={ date }
@@ -256,11 +292,11 @@ class DateFilter extends Component {
</div>
),
} );
- /*eslint-disable jsx-a11y/no-noninteractive-tabindex*/
+
return (
<fieldset
className="woocommerce-filters-advanced__line-item"
- tabIndex="0"
+ tabIndex={ 0 }
>
<legend className="screen-reader-text">
{ labels.add || '' }
@@ -282,7 +318,6 @@ class DateFilter extends Component {
) }
</fieldset>
);
- /*eslint-enable jsx-a11y/no-noninteractive-tabindex*/
}
}
diff --git a/packages/js/components/src/advanced-filters/index.js b/packages/js/components/src/advanced-filters/index.tsx
similarity index 79%
rename from packages/js/components/src/advanced-filters/index.js
rename to packages/js/components/src/advanced-filters/index.tsx
index 0ab8078c919..ac4a81c869a 100644
--- a/packages/js/components/src/advanced-filters/index.js
+++ b/packages/js/components/src/advanced-filters/index.tsx
@@ -13,7 +13,6 @@ import {
} from '@wordpress/components';
import { createElement, Component, createRef } from '@wordpress/element';
import { partial, isEqual } from 'lodash';
-import PropTypes from 'prop-types';
import AddOutlineIcon from 'gridicons/dist/add-outline';
import {
getActiveFiltersFromQuery,
@@ -30,6 +29,70 @@ import Link from '../link';
import AdvancedFilterItem from './item';
import { Text } from '../experimental';
import { backwardsCompatibleCreateInterpolateElement as createInterpolateElement } from './utils';
+import type {
+ ActiveFilter,
+ AdvancedFilterAction,
+ AdvancedFilterConfig,
+ Currency,
+ FilterChange,
+ FilterConfig,
+ Query,
+} from './types';
+
+export type {
+ ActiveFilter,
+ ActiveFilterValue,
+ AdvancedFilterAction,
+ AdvancedFilterConfig,
+ FilterChange,
+ FilterConfig,
+ FilterInput,
+ FilterLabels,
+ FilterOption,
+ FilterRule,
+ Query,
+} from './types';
+
+type AdvancedFiltersDefaultProps = {
+ /**
+ * The query string represented in object form.
+ */
+ query: Query;
+ /**
+ * Function to be called after an advanced filter action has been taken.
+ */
+ onAdvancedFilterAction: (
+ action: AdvancedFilterAction,
+ data?: ActiveFilter | Record< string, unknown >
+ ) => void;
+ /**
+ * The locale for the site.
+ */
+ siteLocale: string;
+};
+
+export type AdvancedFiltersProps = {
+ /**
+ * The configuration object required to render filters.
+ */
+ config: AdvancedFilterConfig;
+ /**
+ * Name of this filter, used in translations.
+ */
+ path: string;
+ /**
+ * The currency formatting instance for the site.
+ */
+ currency: Currency;
+} & Partial< AdvancedFiltersDefaultProps >;
+
+// Inside the class the defaulted props are always present.
+type Props = AdvancedFiltersProps & AdvancedFiltersDefaultProps;
+
+type AdvancedFiltersState = {
+ match: string;
+ activeFilters: ActiveFilter[];
+};
const matches = [
{ value: 'all', label: __( 'All', 'woocommerce' ) },
@@ -39,12 +102,22 @@ const matches = [
/**
* Displays a configurable set of filters which can modify query parameters.
*/
-class AdvancedFilters extends Component {
- constructor( { query, config } ) {
- super( ...arguments );
- this.instanceCounts = {};
+class AdvancedFilters extends Component< Props, AdvancedFiltersState > {
+ static defaultProps: AdvancedFiltersDefaultProps = {
+ query: {},
+ onAdvancedFilterAction: () => {},
+ siteLocale: 'en_US',
+ };
+
+ instanceCounts: Record< string, number > = {};
+
+ filterListRef = createRef< HTMLUListElement >();
- const filtersFromQuery = getActiveFiltersFromQuery(
+ constructor( props: Props ) {
+ super( props );
+ const { query, config } = props;
+
+ const filtersFromQuery: ActiveFilter[] = getActiveFiltersFromQuery(
query,
config.filters
);
@@ -58,12 +131,13 @@ class AdvancedFilters extends Component {
} );
this.state = {
- match: query.match || 'all',
+ match:
+ typeof query.match === 'string' && query.match
+ ? query.match
+ : 'all',
activeFilters,
};
- this.filterListRef = createRef();
-
this.onMatchChange = this.onMatchChange.bind( this );
this.onFilterChange = this.onFilterChange.bind( this );
this.getAvailableFilters = this.getAvailableFilters.bind( this );
@@ -74,12 +148,12 @@ class AdvancedFilters extends Component {
this.onFilter = this.onFilter.bind( this );
}
- componentDidUpdate( prevProps ) {
+ componentDidUpdate( prevProps: Props ) {
const { config, query } = this.props;
const { query: prevQuery } = prevProps;
if ( ! isEqual( prevQuery, query ) ) {
- const filtersFromQuery = getActiveFiltersFromQuery(
+ const filtersFromQuery: ActiveFilter[] = getActiveFiltersFromQuery(
query,
config.filters
);
@@ -95,13 +169,11 @@ class AdvancedFilters extends Component {
return filter;
} );
- /* eslint-disable react/no-did-update-set-state */
this.setState( { activeFilters } );
- /* eslint-enable react/no-did-update-set-state */
}
}
- getInstanceNumber( key ) {
+ getInstanceNumber( key: string ) {
if ( ! this.instanceCounts.hasOwnProperty( key ) ) {
this.instanceCounts[ key ] = 1;
}
@@ -109,7 +181,7 @@ class AdvancedFilters extends Component {
return this.instanceCounts[ key ]++;
}
- onMatchChange( match ) {
+ onMatchChange( match: string ) {
const { onAdvancedFilterAction } = this.props;
this.setState( { match } );
@@ -117,7 +189,10 @@ class AdvancedFilters extends Component {
onAdvancedFilterAction( 'match', { match } );
}
- onFilterChange( index, { property, value, shouldResetValue = false } ) {
+ onFilterChange(
+ index: number,
+ { property, value, shouldResetValue = false }: FilterChange
+ ) {
const newActiveFilters = [ ...this.state.activeFilters ];
newActiveFilters[ index ] = {
...newActiveFilters[ index ],
@@ -128,7 +203,7 @@ class AdvancedFilters extends Component {
this.setState( { activeFilters: newActiveFilters } );
}
- removeFilter( index ) {
+ removeFilter( index: number ) {
const { onAdvancedFilterAction } = this.props;
const activeFilters = [ ...this.state.activeFilters ];
onAdvancedFilterAction( 'remove', activeFilters[ index ] );
@@ -186,10 +261,10 @@ class AdvancedFilters extends Component {
return availableFilters;
}
- addFilter( key, onClose ) {
+ addFilter( key: string, onClose: () => void ) {
const { onAdvancedFilterAction, config } = this.props;
- const filterConfig = config.filters[ key ];
- const newFilter = { key };
+ const filterConfig: FilterConfig = config.filters[ key ];
+ const newFilter: ActiveFilter = { key };
if (
Array.isArray( filterConfig.rules ) &&
filterConfig.rules.length
@@ -217,10 +292,11 @@ class AdvancedFilters extends Component {
onClose();
// after render, focus the newly added filter's first focusable element
setTimeout( () => {
- const addedFilter = this.filterListRef.current.querySelector(
- 'li:last-of-type fieldset'
- );
- addedFilter.focus();
+ const addedFilter =
+ this.filterListRef.current?.querySelector< HTMLElement >(
+ 'li:last-of-type fieldset'
+ );
+ addedFilter?.focus();
} );
}
@@ -233,7 +309,7 @@ class AdvancedFilters extends Component {
} );
}
- getUpdateHref( activeFilters, matchValue ) {
+ getUpdateHref( activeFilters: ActiveFilter[], matchValue?: string ) {
const { path, query, config } = this.props;
const updatedQuery = getQueryFromActiveFilters(
activeFilters,
@@ -259,7 +335,7 @@ class AdvancedFilters extends Component {
onAdvancedFilterAction( 'filter', { ...updatedQuery, match } );
}
- orderFilters( a, b ) {
+ orderFilters( a: ActiveFilter, b: ActiveFilter ) {
const qs = window.location.search;
const aPos = qs.indexOf( a.key );
const bPos = qs.indexOf( b.key );
@@ -282,6 +358,8 @@ class AdvancedFilters extends Component {
const isEnglish = this.isEnglish();
return (
<Card className="woocommerce-filters-advanced" size="small">
+ { /* CardHeader forwards unknown props to Flex, so `justify` works but isn't typed. */ }
+ { /* @ts-expect-error: justify is not a declared CardHeader prop. */ }
<CardHeader justify="flex-start">
<Text
variant="subtitle.small"
@@ -295,6 +373,8 @@ class AdvancedFilters extends Component {
</Text>
</CardHeader>
{ !! activeFilters.length && (
+ // An unknown size maps to no padding class, which is what the list relies on.
+ // @ts-expect-error: size must be one of small, medium, large, xSmall, extraSmall.
<CardBody size="none">
<ul
className="woocommerce-filters-advanced__list"
@@ -364,6 +444,8 @@ class AdvancedFilters extends Component {
</div>
</CardBody>
) }
+ { /* CardFooter forwards unknown props to Flex, so `align` works but isn't typed. */ }
+ { /* @ts-expect-error: align is not a declared CardFooter prop. */ }
<CardFooter align="center">
<div className="woocommerce-filters-advanced__controls">
{ updateDisabled && (
@@ -397,52 +479,4 @@ class AdvancedFilters extends Component {
}
}
-AdvancedFilters.propTypes = {
- /**
- * The configuration object required to render filters.
- */
- config: PropTypes.shape( {
- title: PropTypes.string,
- filters: PropTypes.objectOf(
- PropTypes.shape( {
- labels: PropTypes.shape( {
- add: PropTypes.string,
- remove: PropTypes.string,
- rule: PropTypes.string,
- title: PropTypes.string,
- filter: PropTypes.string,
- } ),
- rules: PropTypes.arrayOf( PropTypes.object ),
- input: PropTypes.object,
- } )
- ),
- } ).isRequired,
- /**
- * Name of this filter, used in translations.
- */
- path: PropTypes.string.isRequired,
- /**
- * The query string represented in object form.
- */
- query: PropTypes.object,
- /**
- * Function to be called after an advanced filter action has been taken.
- */
- onAdvancedFilterAction: PropTypes.func,
- /**
- * The locale for the site.
- */
- siteLocale: PropTypes.string,
- /**
- * The currency formatting instance for the site.
- */
- currency: PropTypes.object.isRequired,
-};
-
-AdvancedFilters.defaultProps = {
- query: {},
- onAdvancedFilterAction: () => {},
- siteLocale: 'en_US',
-};
-
export default AdvancedFilters;
diff --git a/packages/js/components/src/advanced-filters/item.js b/packages/js/components/src/advanced-filters/item.tsx
similarity index 64%
rename from packages/js/components/src/advanced-filters/item.js
rename to packages/js/components/src/advanced-filters/item.tsx
index 3b84ec857b4..41345e26dfa 100644
--- a/packages/js/components/src/advanced-filters/item.js
+++ b/packages/js/components/src/advanced-filters/item.tsx
@@ -14,8 +14,40 @@ import SearchFilter from './search-filter';
import NumberFilter from './number-filter';
import DateFilter from './date-filter';
import AttributeFilter from './attribute-filter';
+import type {
+ ActiveFilter,
+ AdvancedFilterConfig,
+ Currency,
+ FilterConfig,
+ OnFilterChange,
+ Query,
+} from './types';
-const AdvancedFilterItem = ( props ) => {
+export type AdvancedFilterItemProps = {
+ config: AdvancedFilterConfig;
+ currency: Currency;
+ filter: ActiveFilter;
+ isEnglish: boolean;
+ onFilterChange: OnFilterChange;
+ query: Query;
+ removeFilter: () => void;
+};
+
+const componentMap = {
+ Currency: NumberFilter,
+ Date: DateFilter,
+ Number: NumberFilter,
+ ProductAttribute: AttributeFilter,
+ Search: SearchFilter,
+ SelectControl: SelectFilter,
+};
+
+const isKnownComponent = (
+ component: string
+): component is keyof typeof componentMap =>
+ componentMap.hasOwnProperty( component );
+
+const AdvancedFilterItem = ( props: AdvancedFilterItemProps ) => {
const {
config,
currency,
@@ -26,20 +58,11 @@ const AdvancedFilterItem = ( props ) => {
removeFilter,
} = props;
const { key } = filterValue;
- let filterConfig = config.filters[ key ];
+ let filterConfig: FilterConfig = config.filters[ key ];
const { input, labels } = filterConfig;
- const componentMap = {
- Currency: NumberFilter,
- Date: DateFilter,
- Number: NumberFilter,
- ProductAttribute: AttributeFilter,
- Search: SearchFilter,
- SelectControl: SelectFilter,
- };
-
- if ( ! componentMap.hasOwnProperty( input.component ) ) {
- return;
+ if ( ! isKnownComponent( input.component ) ) {
+ return null;
}
if ( input.component === 'Currency' ) {
diff --git a/packages/js/components/src/advanced-filters/number-filter.js b/packages/js/components/src/advanced-filters/number-filter.tsx
similarity index 74%
rename from packages/js/components/src/advanced-filters/number-filter.js
rename to packages/js/components/src/advanced-filters/number-filter.tsx
index d3b25c0f78b..39218e53705 100644
--- a/packages/js/components/src/advanced-filters/number-filter.js
+++ b/packages/js/components/src/advanced-filters/number-filter.tsx
@@ -7,17 +7,53 @@ import { get, find, isArray } from 'lodash';
import clsx from 'clsx';
import { sprintf, __, _x } from '@wordpress/i18n';
import { CurrencyFactory } from '@woocommerce/currency';
+import type { CurrencyConfig } from '@woocommerce/currency';
+import type { ComponentType, ReactNode } from 'react';
/**
* Internal dependencies
*/
-import TextControlWithAffixes from '../text-control-with-affixes';
+import TextControlWithAffixesBase from '../text-control-with-affixes';
import {
backwardsCompatibleCreateInterpolateElement as createInterpolateElement,
textContent,
} from './utils';
+import type {
+ ActiveFilter,
+ Currency,
+ FilterComponentProps,
+ FilterConfig,
+ FilterRule,
+} from './types';
-class NumberFilter extends Component {
+export type NumberFilterProps = FilterComponentProps & {
+ currency: Currency;
+};
+
+type TextControlWithAffixesProps = {
+ className?: string;
+ type?: string;
+ value: string | number;
+ onChange: ( value: string ) => void;
+ prefix?: ReactNode;
+ suffix?: ReactNode;
+ 'aria-label'?: string;
+};
+
+// The component is wrapped in withInstanceId, which hides its props from TS.
+const TextControlWithAffixes =
+ TextControlWithAffixesBase as unknown as ComponentType< TextControlWithAffixesProps >;
+
+type FormControlArgs = {
+ type: string;
+ value: string | number | null | undefined;
+ label: string;
+ onChange: ( value: string ) => void;
+ currencySymbol: string;
+ symbolPosition: string;
+};
+
+class NumberFilter extends Component< NumberFilterProps > {
getBetweenString() {
return _x(
'<rangeStart/><span> and </span><rangeEnd/>',
@@ -26,9 +62,10 @@ class NumberFilter extends Component {
);
}
- getScreenReaderText( filter, config ) {
+ getScreenReaderText( filter: ActiveFilter, config: FilterConfig ) {
const { currency } = this.props;
- const rule = find( config.rules, { value: filter.rule } ) || {};
+ const rule: Partial< FilterRule > =
+ find( config.rules, { value: filter.rule } ) || {};
let [ rangeStart, rangeEnd ] = isArray( filter.value )
? filter.value
: [ filter.value ];
@@ -40,12 +77,16 @@ class NumberFilter extends Component {
const inputType = get( config, [ 'input', 'type' ], 'number' );
if ( inputType === 'currency' ) {
- const { formatAmount } = CurrencyFactory( currency );
+ const { formatAmount } = CurrencyFactory(
+ currency as CurrencyConfig
+ );
rangeStart = formatAmount( rangeStart );
- rangeEnd = formatAmount( rangeEnd );
+ if ( rangeEnd ) {
+ rangeEnd = formatAmount( rangeEnd );
+ }
}
- let filterStr = rangeStart;
+ let filterStr: ReactNode = rangeStart;
if ( rule.value === 'between' ) {
filterStr = createInterpolateElement( this.getBetweenString(), {
@@ -71,7 +112,7 @@ class NumberFilter extends Component {
onChange,
currencySymbol,
symbolPosition,
- } ) {
+ }: FormControlArgs ) {
if ( type === 'currency' ) {
return symbolPosition.indexOf( 'right' ) === 0 ? (
<TextControlWithAffixes
@@ -117,7 +158,7 @@ class NumberFilter extends Component {
const [ rangeStart, rangeEnd ] = isArray( filter.value )
? filter.value
: [ filter.value ];
- if ( Boolean( rangeEnd ) ) {
+ if ( rangeEnd ) {
// If there's a value for rangeEnd, we've just changed from "between"
// to "less than" or "more than" and need to transition the value
onFilterChange( {
@@ -126,23 +167,20 @@ class NumberFilter extends Component {
} );
}
- let labelFormat = '';
-
- if ( filter.rule === 'lessthan' ) {
- /* translators: Sentence fragment, "maximum amount" refers to a numeric value the field must be less than. Screenshot for context: https://cloudup.com/cmv5CLyMPNQ */
- labelFormat = _x(
- '%(field)s maximum amount',
- 'maximum value input',
- 'woocommerce'
- );
- } else {
- /* translators: Sentence fragment, "minimum amount" refers to a numeric value the field must be more than. Screenshot for context: https://cloudup.com/cmv5CLyMPNQ */
- labelFormat = _x(
- '%(field)s minimum amount',
- 'minimum value input',
- 'woocommerce'
- );
- }
+ const labelFormat =
+ filter.rule === 'lessthan'
+ ? /* translators: Sentence fragment, "maximum amount" refers to a numeric value the field must be less than. Screenshot for context: https://cloudup.com/cmv5CLyMPNQ */
+ _x(
+ '%(field)s maximum amount',
+ 'maximum value input',
+ 'woocommerce'
+ )
+ : /* translators: Sentence fragment, "minimum amount" refers to a numeric value the field must be more than. Screenshot for context: https://cloudup.com/cmv5CLyMPNQ */
+ _x(
+ '%(field)s minimum amount',
+ 'minimum value input',
+ 'woocommerce'
+ );
return this.getFormControl( {
type: inputType,
@@ -165,14 +203,14 @@ class NumberFilter extends Component {
? filter.value
: [ filter.value ];
- const rangeStartOnChange = ( newRangeStart ) => {
+ const rangeStartOnChange = ( newRangeStart: string ) => {
onFilterChange( {
property: 'value',
value: [ newRangeStart, rangeEnd ],
} );
};
- const rangeEndOnChange = ( newRangeEnd ) => {
+ const rangeEndOnChange = ( newRangeEnd: string ) => {
onFilterChange( {
property: 'value',
value: [ rangeStart, newRangeEnd ],
@@ -248,11 +286,10 @@ class NumberFilter extends Component {
const screenReaderText = this.getScreenReaderText( filter, config );
- /*eslint-disable jsx-a11y/no-noninteractive-tabindex*/
return (
<fieldset
className="woocommerce-filters-advanced__line-item"
- tabIndex="0"
+ tabIndex={ 0 }
>
<legend className="screen-reader-text">
{ labels.add || '' }
@@ -274,7 +311,6 @@ class NumberFilter extends Component {
) }
</fieldset>
);
- /*eslint-enable jsx-a11y/no-noninteractive-tabindex*/
}
}
diff --git a/packages/js/components/src/advanced-filters/search-filter.js b/packages/js/components/src/advanced-filters/search-filter.tsx
similarity index 71%
rename from packages/js/components/src/advanced-filters/search-filter.js
rename to packages/js/components/src/advanced-filters/search-filter.tsx
index e9fc6da8e86..d333a7db60a 100644
--- a/packages/js/components/src/advanced-filters/search-filter.js
+++ b/packages/js/components/src/advanced-filters/search-filter.tsx
@@ -4,28 +4,53 @@
import { createElement, Component, Fragment } from '@wordpress/element';
import { SelectControl } from '@wordpress/components';
import { find, isEqual } from 'lodash';
-import PropTypes from 'prop-types';
import clsx from 'clsx';
/**
* Internal dependencies
*/
import Search from '../search';
+import type { SearchProps, SearchType } from '../search';
import {
backwardsCompatibleCreateInterpolateElement as createInterpolateElement,
textContent,
} from './utils';
+import type {
+ ActiveFilter,
+ ActiveFilterValue,
+ FilterComponentProps,
+ FilterConfig,
+ FilterRule,
+ Query,
+ SearchLabel,
+} from './types';
+
+export type SearchFilterProps = FilterComponentProps;
-const normalizeFilterValue = ( value ) => {
+/**
+ * A selected search value. Keys come from API ids, so they may be numeric at runtime.
+ */
+export type SearchSelection = {
+ key: string | number;
+ label: string;
+ id?: string | number;
+};
+
+type SearchFilterState = {
+ selected: SearchSelection[];
+};
+
+const normalizeFilterValue = ( value: ActiveFilterValue | undefined ) => {
if ( Array.isArray( value ) ) {
return value.join( ',' );
}
return typeof value === 'string' ? value : '';
};
-class SearchFilter extends Component {
- constructor( { filter, query } ) {
- super( ...arguments );
+class SearchFilter extends Component< SearchFilterProps, SearchFilterState > {
+ constructor( props: SearchFilterProps ) {
+ super( props );
+ const { filter, query } = props;
this.onSearchChange = this.onSearchChange.bind( this );
this.state = {
selected: [],
@@ -39,7 +64,7 @@ class SearchFilter extends Component {
}
}
- componentDidUpdate( prevProps ) {
+ componentDidUpdate( prevProps: SearchFilterProps ) {
const { filter, query } = this.props;
const { filter: prevFilter } = prevProps;
const filterValue = normalizeFilterValue( filter.value );
@@ -61,9 +86,9 @@ class SearchFilter extends Component {
}
}
- loadLabels( filterValue, query ) {
- this.props.config.input
- .getLabels( filterValue, query )
+ loadLabels( filterValue: string, query?: Query ) {
+ void this.props.config.input
+ .getLabels?.( filterValue, query )
.then( ( selected ) => {
if (
filterValue ===
@@ -74,11 +99,15 @@ class SearchFilter extends Component {
} );
}
- updateLabels( selected ) {
- const normalizedSelected = selected.map( ( item ) => ( {
- ...item,
- key: item.key ?? item.id,
- } ) );
+ updateLabels( selected: SearchLabel[] ) {
+ const normalizedSelected = selected
+ .map( ( item ) => ( {
+ ...item,
+ key: item.key ?? item.id,
+ } ) )
+ .filter(
+ ( item ): item is SearchSelection => item.key !== undefined
+ );
const prevIds = this.state.selected.map( ( item ) => item.key );
const ids = normalizedSelected.map( ( item ) => item.key );
@@ -87,7 +116,7 @@ class SearchFilter extends Component {
}
}
- onSearchChange( values ) {
+ onSearchChange( values: SearchSelection[] ) {
this.setState( {
selected: values,
} );
@@ -96,14 +125,15 @@ class SearchFilter extends Component {
onFilterChange( { property: 'value', value: idList } );
}
- getScreenReaderText( filter, config ) {
+ getScreenReaderText( filter: ActiveFilter, config: FilterConfig ) {
const { selected } = this.state;
if ( selected.length === 0 ) {
return '';
}
- const rule = find( config.rules, { value: filter.rule } ) || {};
+ const rule: Partial< FilterRule > =
+ find( config.rules, { value: filter.rule } ) || {};
const filterStr = selected.map( ( item ) => item.label ).join( ', ' );
return textContent(
@@ -145,10 +175,11 @@ class SearchFilter extends Component {
'woocommerce-filters-advanced__input'
) }
onChange={ this.onSearchChange }
- type={ input.type }
+ type={ input.type as SearchType }
autocompleter={ input.autocompleter }
placeholder={ labels.placeholder }
- selected={ selected }
+ // Search types keys as strings, but ids resolved from the API are numeric.
+ selected={ selected as SearchProps[ 'selected' ] }
inlineTags
aria-label={ labels.filter }
/>
@@ -160,7 +191,7 @@ class SearchFilter extends Component {
return (
<fieldset
className="woocommerce-filters-advanced__line-item"
- tabIndex="0"
+ tabIndex={ 0 }
>
<legend className="screen-reader-text">
{ labels.add || '' }
@@ -185,35 +216,4 @@ class SearchFilter extends Component {
}
}
-SearchFilter.propTypes = {
- /**
- * The configuration object for the single filter to be rendered.
- */
- config: PropTypes.shape( {
- labels: PropTypes.shape( {
- placeholder: PropTypes.string,
- rule: PropTypes.string,
- title: PropTypes.string,
- } ),
- rules: PropTypes.arrayOf( PropTypes.object ),
- input: PropTypes.object,
- } ).isRequired,
- /**
- * The activeFilter handed down by AdvancedFilters.
- */
- filter: PropTypes.shape( {
- key: PropTypes.string,
- rule: PropTypes.string,
- value: PropTypes.oneOfType( [ PropTypes.string, PropTypes.array ] ),
- } ).isRequired,
- /**
- * Function to be called on update.
- */
- onFilterChange: PropTypes.func.isRequired,
- /**
- * The query string represented in object form.
- */
- query: PropTypes.object,
-};
-
export default SearchFilter;
diff --git a/packages/js/components/src/advanced-filters/select-filter.js b/packages/js/components/src/advanced-filters/select-filter.tsx
similarity index 71%
rename from packages/js/components/src/advanced-filters/select-filter.js
rename to packages/js/components/src/advanced-filters/select-filter.tsx
index f54c89723e1..053edf5089e 100644
--- a/packages/js/components/src/advanced-filters/select-filter.js
+++ b/packages/js/components/src/advanced-filters/select-filter.tsx
@@ -4,7 +4,6 @@
import { createElement, Component, Fragment } from '@wordpress/element';
import { SelectControl, Spinner } from '@wordpress/components';
import { find } from 'lodash';
-import PropTypes from 'prop-types';
import clsx from 'clsx';
import { getDefaultOptionValue } from '@woocommerce/navigation';
@@ -15,10 +14,24 @@ import {
backwardsCompatibleCreateInterpolateElement as createInterpolateElement,
textContent,
} from './utils';
+import type {
+ ActiveFilter,
+ FilterComponentProps,
+ FilterConfig,
+ FilterOption,
+ FilterRule,
+} from './types';
-class SelectFilter extends Component {
- constructor( { filter, config, onFilterChange } ) {
- super( ...arguments );
+export type SelectFilterProps = FilterComponentProps;
+
+type SelectFilterState = {
+ options?: FilterOption[];
+};
+
+class SelectFilter extends Component< SelectFilterProps, SelectFilterState > {
+ constructor( props: SelectFilterProps ) {
+ super( props );
+ const { filter, config, onFilterChange } = props;
const options = config.input.options;
this.state = { options };
@@ -26,7 +39,7 @@ class SelectFilter extends Component {
this.updateOptions = this.updateOptions.bind( this );
if ( ! options && config.input.getOptions ) {
- config.input
+ void config.input
.getOptions()
.then( this.updateOptions )
.then( ( returnedOptions ) => {
@@ -41,19 +54,23 @@ class SelectFilter extends Component {
}
}
- updateOptions( options ) {
+ updateOptions( options: FilterOption[] ) {
this.setState( { options } );
return options;
}
- getScreenReaderText( filter, config ) {
+ getScreenReaderText( filter: ActiveFilter, config: FilterConfig ) {
if ( filter.value === '' ) {
return '';
}
- const rule = find( config.rules, { value: filter.rule } ) || {};
- const value =
- find( config.input.options, { value: filter.value } ) || {};
+ const rule: Partial< FilterRule > =
+ find( config.rules, { value: filter.rule } ) || {};
+ const value: Partial< FilterOption > =
+ find(
+ config.input.options,
+ ( option ) => option.value === filter.value
+ ) || {};
return textContent(
createInterpolateElement( config.labels.title, {
@@ -98,7 +115,7 @@ class SelectFilter extends Component {
'woocommerce-filters-advanced__input'
) }
options={ options }
- value={ value }
+ value={ String( value ?? '' ) }
onChange={ ( selectedValue ) =>
onFilterChange( {
property: 'value',
@@ -114,11 +131,10 @@ class SelectFilter extends Component {
const screenReaderText = this.getScreenReaderText( filter, config );
- /*eslint-disable jsx-a11y/no-noninteractive-tabindex*/
return (
<fieldset
className="woocommerce-filters-advanced__line-item"
- tabIndex="0"
+ tabIndex={ 0 }
>
<legend className="screen-reader-text">
{ labels.add || '' }
@@ -140,35 +156,7 @@ class SelectFilter extends Component {
) }
</fieldset>
);
- /*eslint-enable jsx-a11y/no-noninteractive-tabindex*/
}
}
-SelectFilter.propTypes = {
- /**
- * The configuration object for the single filter to be rendered.
- */
- config: PropTypes.shape( {
- labels: PropTypes.shape( {
- rule: PropTypes.string,
- title: PropTypes.string,
- filter: PropTypes.string,
- } ),
- rules: PropTypes.arrayOf( PropTypes.object ),
- input: PropTypes.object,
- } ).isRequired,
- /**
- * The activeFilter handed down by AdvancedFilters.
- */
- filter: PropTypes.shape( {
- key: PropTypes.string,
- rule: PropTypes.string,
- value: PropTypes.string,
- } ).isRequired,
- /**
- * Function to be called on update.
- */
- onFilterChange: PropTypes.func.isRequired,
-};
-
export default SelectFilter;
diff --git a/packages/js/components/src/advanced-filters/stories/advanced-filters.story.js b/packages/js/components/src/advanced-filters/stories/advanced-filters.story.tsx
similarity index 90%
rename from packages/js/components/src/advanced-filters/stories/advanced-filters.story.js
rename to packages/js/components/src/advanced-filters/stories/advanced-filters.story.tsx
index d93f500b881..33b5028c1ca 100644
--- a/packages/js/components/src/advanced-filters/stories/advanced-filters.story.js
+++ b/packages/js/components/src/advanced-filters/stories/advanced-filters.story.tsx
@@ -1,9 +1,15 @@
/**
* External dependencies
*/
-import { AdvancedFilters } from '@woocommerce/components';
+import { createElement } from '@wordpress/element';
-const ORDER_STATUSES = {
+/**
+ * Internal dependencies
+ */
+import AdvancedFilters from '../';
+import type { AdvancedFilterConfig } from '../types';
+
+const ORDER_STATUSES: Record< string, string > = {
cancelled: 'Cancelled',
completed: 'Completed',
failed: 'Failed',
@@ -20,15 +26,15 @@ const currency = {
precision: 2,
priceFormat: '%1$s%2$s',
symbol: '$',
- symbolPosition: 'left',
+ symbolPosition: 'left' as const,
thousandSeparator: ',',
};
-const path = new URL( document.location ).searchParams.get( 'path' );
+const path = new URL( document.location.href ).searchParams.get( 'path' ) ?? '';
const query = {
component: 'advanced-filters',
};
-const advancedFilters = {
+const advancedFilters: AdvancedFilterConfig = {
title: 'Orders Match <select/> Filters',
filters: {
status: {
@@ -184,7 +190,6 @@ export const Basic = () => (
siteLocale={ siteLocale }
path={ path }
query={ query }
- filterTitle="Orders"
config={ advancedFilters }
currency={ currency }
/>
diff --git a/packages/js/components/src/advanced-filters/test/advanced-filters.test.js b/packages/js/components/src/advanced-filters/test/advanced-filters.test.tsx
similarity index 95%
rename from packages/js/components/src/advanced-filters/test/advanced-filters.test.js
rename to packages/js/components/src/advanced-filters/test/advanced-filters.test.tsx
index 97acdc3dc26..6fcd67d5120 100644
--- a/packages/js/components/src/advanced-filters/test/advanced-filters.test.js
+++ b/packages/js/components/src/advanced-filters/test/advanced-filters.test.tsx
@@ -12,8 +12,10 @@ import { createElement } from '@wordpress/element';
* Internal dependencies
*/
import AdvancedFilters from '../';
+import type { AdvancedFiltersProps } from '../';
+import type { AdvancedFilterConfig } from '../types';
-const ORDER_STATUSES = {
+const ORDER_STATUSES: Record< string, string > = {
cancelled: 'Cancelled',
completed: 'Completed',
failed: 'Failed',
@@ -29,11 +31,11 @@ const CURRENCY = {
precision: 2,
priceFormat: '%1$s%2$s',
symbol: '$',
- symbolPosition: 'left',
+ symbolPosition: 'left' as const,
thousandSeparator: ',',
};
-const advancedFiltersConfig = {
+const advancedFiltersConfig: AdvancedFilterConfig = {
title: 'Orders Match <select/> Filters',
filters: {
status: {
@@ -201,12 +203,13 @@ const advancedFiltersConfig = {
},
};
-const AdvancedFiltersComponent = ( props = null ) => (
+const AdvancedFiltersComponent = (
+ props: Partial< AdvancedFiltersProps > = {}
+) => (
<AdvancedFilters
siteLocale="en_US"
path=""
query={ { component: 'advanced-filters' } }
- filterTitle="Orders"
config={ advancedFiltersConfig }
currency={ CURRENCY }
{ ...props }
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.tsx
similarity index 84%
rename from packages/js/components/src/advanced-filters/test/search-filter.test.js
rename to packages/js/components/src/advanced-filters/test/search-filter.test.tsx
index 50e8be7ea12..6923f8dd3d8 100644
--- a/packages/js/components/src/advanced-filters/test/search-filter.test.js
+++ b/packages/js/components/src/advanced-filters/test/search-filter.test.tsx
@@ -12,8 +12,9 @@ import { createElement, createRef } from '@wordpress/element';
* Internal dependencies
*/
import SearchFilter from '../search-filter';
+import type { FilterConfig, FilterInput, SearchLabel } from '../types';
-const getConfig = ( getLabels ) => ( {
+const getConfig = ( getLabels: FilterInput[ 'getLabels' ] ): FilterConfig => ( {
labels: {
add: 'IP Address',
filter: 'Select IP addresses',
@@ -23,6 +24,7 @@ const getConfig = ( getLabels ) => ( {
},
rules: [ { value: 'includes', label: 'Includes' } ],
input: {
+ component: 'Search',
type: 'downloadIps',
getLabels,
},
@@ -34,7 +36,7 @@ describe( 'SearchFilter', () => {
.fn()
.mockResolvedValue( [ { id: '::1', label: '::1' } ] );
const onFilterChange = jest.fn();
- const ref = createRef();
+ const ref = createRef< SearchFilter >();
const props = {
config: getConfig( getLabels ),
filter: { key: 'ip_address', rule: 'includes', value: '' },
@@ -46,7 +48,7 @@ describe( 'SearchFilter', () => {
);
act( () => {
- ref.current.onSearchChange( [
+ ref.current?.onSearchChange( [
{ key: '127.0.0.1', label: '127.0.0.1' },
] );
} );
@@ -63,7 +65,7 @@ describe( 'SearchFilter', () => {
await waitFor( () =>
expect( getLabels ).toHaveBeenCalledWith( '::1', {} )
);
- expect( ref.current.state.selected ).toEqual( [
+ expect( ref.current?.state.selected ).toEqual( [
{ id: '::1', key: '::1', label: '::1' },
] );
@@ -85,7 +87,7 @@ describe( 'SearchFilter', () => {
.fn()
.mockResolvedValue( [ { id: '::1', label: '::1' } ] );
const onFilterChange = jest.fn();
- const ref = createRef();
+ const ref = createRef< SearchFilter >();
const props = {
config: getConfig( getLabels ),
filter: { key: 'ip_address', rule: 'includes', value: '' },
@@ -97,7 +99,7 @@ describe( 'SearchFilter', () => {
);
act( () => {
- ref.current.onSearchChange( [ { key: '::1', label: '::1' } ] );
+ ref.current?.onSearchChange( [ { key: '::1', label: '::1' } ] );
} );
onFilterChange.mockClear();
rerender(
@@ -109,7 +111,7 @@ describe( 'SearchFilter', () => {
);
expect( getLabels ).not.toHaveBeenCalled();
- expect( ref.current.state.selected ).toEqual( [
+ expect( ref.current?.state.selected ).toEqual( [
{ key: '::1', label: '::1' },
] );
@@ -126,7 +128,7 @@ describe( 'SearchFilter', () => {
const getLabels = jest
.fn()
.mockResolvedValue( [ { id: 60, label: '#60' } ] );
- const ref = createRef();
+ const ref = createRef< SearchFilter >();
const props = {
config: getConfig( getLabels ),
filter: { key: 'order', rule: 'includes', value: '' },
@@ -138,7 +140,7 @@ describe( 'SearchFilter', () => {
);
act( () => {
- ref.current.onSearchChange( [ { key: 60, label: '#60' } ] );
+ ref.current?.onSearchChange( [ { key: 60, label: '#60' } ] );
} );
rerender(
<SearchFilter
@@ -149,14 +151,14 @@ describe( 'SearchFilter', () => {
);
expect( getLabels ).not.toHaveBeenCalled();
- expect( ref.current.state.selected ).toEqual( [
+ 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 ref = createRef< SearchFilter >();
const props = {
config: getConfig( getLabels ),
filter: { key: 'order', rule: 'includes', value: '' },
@@ -168,7 +170,7 @@ describe( 'SearchFilter', () => {
);
act( () => {
- ref.current.onSearchChange( [ { key: 60, label: '#60' } ] );
+ ref.current?.onSearchChange( [ { key: 60, label: '#60' } ] );
} );
rerender(
<SearchFilter
@@ -206,7 +208,7 @@ describe( 'SearchFilter', () => {
const getLabels = jest
.fn()
.mockResolvedValue( [ { id: 60, label: '#60' } ] );
- const ref = createRef();
+ const ref = createRef< SearchFilter >();
const props = {
config: getConfig( getLabels ),
filter: { key: 'order', rule: 'includes', value: '' },
@@ -231,19 +233,19 @@ describe( 'SearchFilter', () => {
} );
test( 'ignores stale label responses', async () => {
- let resolveFirstRequest;
- let resolveSecondRequest;
- const firstRequest = new Promise( ( resolve ) => {
+ let resolveFirstRequest: ( labels: SearchLabel[] ) => void = () => {};
+ let resolveSecondRequest: ( labels: SearchLabel[] ) => void = () => {};
+ const firstRequest = new Promise< SearchLabel[] >( ( resolve ) => {
resolveFirstRequest = resolve;
} );
- const secondRequest = new Promise( ( resolve ) => {
+ const secondRequest = new Promise< SearchLabel[] >( ( resolve ) => {
resolveSecondRequest = resolve;
} );
const getLabels = jest
.fn()
.mockReturnValueOnce( firstRequest )
.mockReturnValueOnce( secondRequest );
- const ref = createRef();
+ const ref = createRef< SearchFilter >();
const props = {
config: getConfig( getLabels ),
filter: { key: 'ip_address', rule: 'includes', value: '' },
@@ -278,7 +280,7 @@ describe( 'SearchFilter', () => {
await firstRequest;
} );
- expect( ref.current.state.selected ).toEqual( [
+ expect( ref.current?.state.selected ).toEqual( [
{ id: '127.0.0.1', key: '127.0.0.1', label: '127.0.0.1' },
] );
} );
diff --git a/packages/js/components/src/advanced-filters/test/utils.js b/packages/js/components/src/advanced-filters/test/utils.tsx
similarity index 100%
rename from packages/js/components/src/advanced-filters/test/utils.js
rename to packages/js/components/src/advanced-filters/test/utils.tsx
diff --git a/packages/js/components/src/advanced-filters/types.ts b/packages/js/components/src/advanced-filters/types.ts
new file mode 100644
index 00000000000..f48812d23f5
--- /dev/null
+++ b/packages/js/components/src/advanced-filters/types.ts
@@ -0,0 +1,120 @@
+/**
+ * External dependencies
+ */
+import type { CurrencyConfig } from '@woocommerce/currency';
+
+/**
+ * Internal dependencies
+ */
+import type { AutoCompleter } from '../search/autocompleters';
+
+/**
+ * Parsed URL query. `allowMultiple` filters serialize as nested arrays
+ * (`attribute_is[0][0]=1`), so values are not always plain strings.
+ */
+export type Query = Record<
+ string,
+ string | string[] | string[][] | undefined
+>;
+
+export type FilterRule = {
+ value: string;
+ label: string;
+};
+
+export type FilterOption = {
+ value: string;
+ label: string;
+};
+
+export type FilterLabels = {
+ add: string;
+ remove?: string;
+ rule?: string;
+ title: string;
+ filter?: string;
+ placeholder?: string;
+};
+
+/**
+ * A label resolved for a selected search value, as returned by `input.getLabels`.
+ */
+export type SearchLabel = {
+ id?: string | number;
+ key?: string | number;
+ label: string;
+};
+
+export type FilterInput = {
+ component: string;
+ type?: string;
+ options?: FilterOption[];
+ defaultOption?: string;
+ getOptions?: () => Promise< FilterOption[] >;
+ getLabels?: ( value: string, query?: Query ) => Promise< SearchLabel[] >;
+ autocompleter?: AutoCompleter;
+};
+
+export type FilterConfig = {
+ labels: FilterLabels;
+ rules?: FilterRule[];
+ input: FilterInput;
+ allowMultiple?: boolean;
+};
+
+export type AdvancedFilterConfig = {
+ title: string;
+ filters: Record< string, FilterConfig >;
+};
+
+/**
+ * Range filters hold `[ start, end ]` while either end may still be unset.
+ */
+export type ActiveFilterValue =
+ | string
+ | number
+ | Array< string | number | null | undefined >
+ | null;
+
+export type ActiveFilter = {
+ key: string;
+ rule?: string;
+ value?: ActiveFilterValue;
+ instance?: number;
+};
+
+export type FilterChange = {
+ property: 'rule' | 'value';
+ value: ActiveFilterValue | undefined;
+ shouldResetValue?: boolean;
+};
+
+export type OnFilterChange = ( change: FilterChange ) => void;
+
+export type AdvancedFilterAction =
+ | 'add'
+ | 'remove'
+ | 'match'
+ | 'filter'
+ | 'clear_all';
+
+/**
+ * `getCurrencyConfig()` returns `symbolPosition` as a plain string, so this stays
+ * wider than `CurrencyProps` to accept what the site actually passes.
+ */
+export type Currency = Omit< CurrencyConfig, 'symbolPosition' > & {
+ symbol: string;
+ symbolPosition: string;
+};
+
+/**
+ * Props shared by every single-filter component rendered by `AdvancedFilterItem`.
+ */
+export type FilterComponentProps = {
+ className?: string;
+ config: FilterConfig;
+ filter: ActiveFilter;
+ isEnglish?: boolean;
+ onFilterChange: OnFilterChange;
+ query?: Query;
+};
diff --git a/packages/js/components/src/advanced-filters/utils.js b/packages/js/components/src/advanced-filters/utils.ts
similarity index 83%
rename from packages/js/components/src/advanced-filters/utils.js
rename to packages/js/components/src/advanced-filters/utils.ts
index d74bf26e030..701b0cee9b3 100644
--- a/packages/js/components/src/advanced-filters/utils.js
+++ b/packages/js/components/src/advanced-filters/utils.ts
@@ -3,7 +3,8 @@
*/
import { isArray, isNumber, isString } from 'lodash';
import deprecated from '@wordpress/deprecated';
-import { createInterpolateElement } from '@wordpress/element';
+import { createInterpolateElement, isValidElement } from '@wordpress/element';
+import type { ReactElement, ReactNode } from 'react';
/**
* DOM Node.textContent for React components
@@ -13,15 +14,18 @@ import { createInterpolateElement } from '@wordpress/element';
*
* @return {string} concatenated text content of all nodes
*/
-export function textContent( components ) {
+export function textContent( components: ReactNode ) {
let text = '';
- const toText = ( component ) => {
+ const toText = ( component: ReactNode ) => {
if ( isString( component ) || isNumber( component ) ) {
text += component;
} else if ( isArray( component ) ) {
component.forEach( toText );
- } else if ( component && component.props ) {
+ } else if (
+ isValidElement< { children?: ReactNode } >( component ) &&
+ component.props
+ ) {
const { children } = component.props;
if ( isArray( children ) ) {
@@ -47,12 +51,12 @@ export function textContent( components ) {
*
* @return {string} Fixed interpolation string.
*/
-export function getInterpolatedString( interpolatedString ) {
+export function getInterpolatedString( interpolatedString: string ) {
const regex = /(\{\{)(\/?\s*\w+\s*\/?)(\}\})/g;
const replacedString = interpolatedString.replaceAll(
regex,
- ( match, p1, p2 ) => {
+ ( match: string, p1: string, p2: string ) => {
const inner = p2.trim();
let replacement;
if ( inner.startsWith( '/' ) ) {
@@ -94,8 +98,8 @@ export function getInterpolatedString( interpolatedString ) {
* @return {Element} A React element that is the result of applying the transformation.
*/
export function backwardsCompatibleCreateInterpolateElement(
- interpolatedString,
- conversionMap
+ interpolatedString: string,
+ conversionMap: Record< string, ReactElement >
) {
return createInterpolateElement(
getInterpolatedString( interpolatedString ),
diff --git a/packages/js/components/src/index.ts b/packages/js/components/src/index.ts
index ff8f208e25d..78b7e6b899c 100644
--- a/packages/js/components/src/index.ts
+++ b/packages/js/components/src/index.ts
@@ -1,5 +1,19 @@
export { default as AbbreviatedCard } from './abbreviated-card';
export { default as AdvancedFilters } from './advanced-filters';
+export type {
+ ActiveFilter,
+ ActiveFilterValue,
+ AdvancedFilterAction,
+ AdvancedFilterConfig,
+ AdvancedFiltersProps,
+ FilterChange,
+ FilterConfig,
+ FilterInput,
+ FilterLabels,
+ FilterOption,
+ FilterRule,
+ Query,
+} from './advanced-filters';
export * from './analytics';
export { default as AnimationSlider } from './animation-slider';
export { default as Chart } from './chart';
diff --git a/plugins/woocommerce/changelog/dev-report-header-advanced-filters-type b/plugins/woocommerce/changelog/dev-report-header-advanced-filters-type
new file mode 100644
index 00000000000..811b74f7296
--- /dev/null
+++ b/plugins/woocommerce/changelog/dev-report-header-advanced-filters-type
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Type the ReportHeader advancedFilters prop with AdvancedFilterConfig from @woocommerce/components.
+
diff --git a/plugins/woocommerce/client/admin/client/analytics/components/report-header/report-header.tsx b/plugins/woocommerce/client/admin/client/analytics/components/report-header/report-header.tsx
index 3dd1fa9bc7f..9f898f39174 100644
--- a/plugins/woocommerce/client/admin/client/analytics/components/report-header/report-header.tsx
+++ b/plugins/woocommerce/client/admin/client/analytics/components/report-header/report-header.tsx
@@ -1,3 +1,8 @@
+/**
+ * External dependencies
+ */
+import type { AdvancedFilterConfig } from '@woocommerce/components';
+
/**
* Internal dependencies
*/
@@ -10,7 +15,7 @@ interface ReportHeaderProps {
/**
* Config option passed through to `AdvancedFilters`
*/
- advancedFilters?: object;
+ advancedFilters?: AdvancedFilterConfig;
/**
* Config option passed through to `FilterPicker`
*/