Improve Products block Attributes Filter Inspector Controls (https://github.com/woocommerce/woocommerce-blocks/pull/8583)

This PR is meant to improve the UI and UX behind the Attributes filter
within the Inspector Controls of the “Products (Beta)“ block.

Also included:

* Refactor `useProductAttributes` hook
  * Move it into the shared hooks.
  * Fetch both terms AND attributes via the API (previously,
we got the attributes from the settings, but we'd get
partial objects compared to the API? Maybe a follow-up
to this could be to check why the attributes stored in
the settings are incomplete?)
  * Make sure the return values match the ones expected
from search items.
* Remove attribute-related types from PQ directory
* Improve functionality of `SearchListControl`
  * Allow the search input to be a Token based input.
  * Allow for search input to search even collapsed properties.
  * Use core `CheckboxControl` instead of radio buttons for
items having children (includes undeterminated state).
  * Enable removal of tokens from the input
* Improve styles:
  * Refactor classnames for `SearchItem`.
  * Add more semantic classes.
  * Align count label and caret to the right.
  * Make caret switch direction on expanded.
  * `cursor: pointer` on collapsible items.
  * Indent children of collapsible items.
  * Correctly pass through class names to search item
* Enable keyboard navigation for collapsible items
* Add link to manage attributes
* Change label inside the inspector controls
* Make search list attached when token type
* Implement more sophisticated behavior of parent checkbox
  * If indeterminate or unchecked, it will check all children.
  * If checked, it will uncheck all children.
* Remove hardcoded `isSingle` from `expandableSearchListItem`
This commit is contained in:
Lucio Giannotta 2023-03-08 17:22:51 +01:00 committed by GitHub
parent 3de39425c2
commit 61eeeb3c68
25 changed files with 818 additions and 753 deletions

View File

@ -6,7 +6,8 @@
"../../../../../packages/checkout/index.js", "../../../../../packages/checkout/index.js",
"../providers/cart-checkout/checkout-events/index.tsx", "../providers/cart-checkout/checkout-events/index.tsx",
"../providers/cart-checkout/payment-events/index.tsx", "../providers/cart-checkout/payment-events/index.tsx",
"../providers/cart-checkout/shipping/index.js" "../providers/cart-checkout/shipping/index.js",
"../../../editor-components/utils/*"
], ],
"exclude": [ "**/test/**" ] "exclude": [ "**/test/**" ]
} }

View File

@ -0,0 +1,79 @@
/**
* External dependencies
*/
import { useEffect, useRef, useState } from '@wordpress/element';
import { getAttributes, getTerms } from '@woocommerce/editor-components/utils';
import {
AttributeObject,
AttributeTerm,
AttributeWithTerms,
} from '@woocommerce/types';
import { formatError } from '@woocommerce/base-utils';
export default function useProductAttributes( shouldLoadAttributes: boolean ) {
const [ errorLoadingAttributes, setErrorLoadingAttributes ] =
useState< Awaited< ReturnType< typeof formatError > > | null >( null );
const [ isLoadingAttributes, setIsLoadingAttributes ] = useState( false );
const [ productsAttributes, setProductsAttributes ] = useState<
AttributeWithTerms[]
>( [] );
const hasLoadedAttributes = useRef( false );
useEffect( () => {
if (
! shouldLoadAttributes ||
isLoadingAttributes ||
hasLoadedAttributes.current
)
return;
async function fetchAttributesWithTerms() {
setIsLoadingAttributes( true );
try {
const attributes: AttributeObject[] = await getAttributes();
const attributesWithTerms: AttributeWithTerms[] = [];
for ( const attribute of attributes ) {
const terms: AttributeTerm[] = await getTerms(
attribute.id
);
attributesWithTerms.push( {
...attribute,
// Manually adding the parent id because of a Rest API bug
// returning always `0` as parent.
// see https://github.com/woocommerce/woocommerce-blocks/issues/8501
parent: 0,
terms: terms.map( ( term ) => ( {
...term,
attr_slug: attribute.taxonomy,
parent: attribute.id,
} ) ),
} );
}
setProductsAttributes( attributesWithTerms );
hasLoadedAttributes.current = true;
} catch ( e ) {
if ( e instanceof Error ) {
setErrorLoadingAttributes( await formatError( e ) );
}
} finally {
setIsLoadingAttributes( false );
}
}
fetchAttributesWithTerms();
return () => {
hasLoadedAttributes.current = true;
};
}, [ isLoadingAttributes, shouldLoadAttributes ] );
return {
errorLoadingAttributes,
isLoadingAttributes,
productsAttributes,
};
}

View File

@ -1,6 +1,7 @@
/** /**
* External dependencies * External dependencies
*/ */
import { sortBy } from 'lodash';
import { __, sprintf, _n } from '@wordpress/i18n'; import { __, sprintf, _n } from '@wordpress/i18n';
import { useState } from '@wordpress/element'; import { useState } from '@wordpress/element';
import { import {
@ -10,11 +11,10 @@ import {
} from '@wordpress/block-editor'; } from '@wordpress/block-editor';
import { Icon, category, external } from '@wordpress/icons'; import { Icon, category, external } from '@wordpress/icons';
import { SearchListControl } from '@woocommerce/editor-components/search-list-control'; import { SearchListControl } from '@woocommerce/editor-components/search-list-control';
import { sortBy } from 'lodash';
import { getAdminLink, getSetting } from '@woocommerce/settings'; import { getAdminLink, getSetting } from '@woocommerce/settings';
import BlockTitle from '@woocommerce/editor-components/block-title'; import BlockTitle from '@woocommerce/editor-components/block-title';
import classnames from 'classnames'; import classnames from 'classnames';
import { SearchListItemsType } from '@woocommerce/editor-components/search-list-control/types'; import { SearchListItem } from '@woocommerce/editor-components/search-list-control/types';
import { AttributeSetting } from '@woocommerce/types'; import { AttributeSetting } from '@woocommerce/types';
import { import {
Placeholder, Placeholder,
@ -103,7 +103,7 @@ const Edit = ( {
); );
}; };
const onChange = ( selected: SearchListItemsType ) => { const onChange = ( selected: SearchListItem[] ) => {
if ( ! selected || ! selected.length ) { if ( ! selected || ! selected.length ) {
return; return;
} }

View File

@ -2,6 +2,7 @@
* External dependencies * External dependencies
*/ */
import Label from '@woocommerce/base-components/filter-element-label'; import Label from '@woocommerce/base-components/filter-element-label';
import { AttributeObject } from '@woocommerce/types';
export const previewOptions = [ export const previewOptions = [
{ {
@ -27,9 +28,14 @@ export const previewOptions = [
}, },
]; ];
export const previewAttributeObject = { export const previewAttributeObject: AttributeObject = {
count: 0,
has_archives: true,
id: 0, id: 0,
name: 'preview',
taxonomy: 'preview',
label: 'Preview', label: 'Preview',
name: 'preview',
order: 'menu_order',
parent: 0,
taxonomy: 'preview',
type: '',
}; };

View File

@ -20,6 +20,9 @@ function objectOmit< T, K extends keyof T >( obj: T, key: K ) {
return rest; return rest;
} }
export const EDIT_ATTRIBUTES_URL =
'/wp-admin/edit.php?post_type=product&page=product_attributes';
export const QUERY_LOOP_ID = 'core/query'; export const QUERY_LOOP_ID = 'core/query';
export const DEFAULT_CORE_ALLOWED_CONTROLS = [ 'taxQuery', 'search' ]; export const DEFAULT_CORE_ALLOWED_CONTROLS = [ 'taxQuery', 'search' ];

View File

@ -2,8 +2,10 @@
* External dependencies * External dependencies
*/ */
import { __ } from '@wordpress/i18n'; import { __ } from '@wordpress/i18n';
import { useEffect, useState } from '@wordpress/element';
import ProductAttributeTermControl from '@woocommerce/editor-components/product-attribute-term-control';
import { import {
FormTokenField, ExternalLink,
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis // eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalToolsPanelItem as ToolsPanelItem, __experimentalToolsPanelItem as ToolsPanelItem,
} from '@wordpress/components'; } from '@wordpress/components';
@ -11,146 +13,57 @@ import {
/** /**
* Internal dependencies * Internal dependencies
*/ */
import { import { ProductQueryBlock } from '../types';
AttributeMetadata,
AttributeWithTerms,
ProductQueryBlock,
} from '../types';
import useProductAttributes from '../useProductAttributes';
import { setQueryAttribute } from '../utils'; import { setQueryAttribute } from '../utils';
import { EDIT_ATTRIBUTES_URL } from '../constants';
function getAttributeMetadataFromToken(
token: string,
productsAttributes: AttributeWithTerms[]
) {
const [ attributeLabel, termName ] = token.split( ': ' );
const taxonomy = productsAttributes.find(
( attribute ) => attribute.attribute_label === attributeLabel
);
if ( ! taxonomy )
throw new Error( 'Product Query Filter: Invalid attribute label' );
const term = taxonomy.terms.find(
( currentTerm ) => currentTerm.name === termName
);
if ( ! term ) throw new Error( 'Product Query Filter: Invalid term name' );
return {
taxonomy: `pa_${ taxonomy.attribute_name }`,
termId: term.id,
};
}
function getAttributeFromMetadata(
metadata: AttributeMetadata,
productsAttributes: AttributeWithTerms[]
) {
const taxonomy = productsAttributes.find(
( attribute ) =>
attribute.attribute_name === metadata.taxonomy.slice( 3 )
);
return {
taxonomy,
term: taxonomy?.terms.find( ( term ) => term.id === metadata.termId ),
};
}
function getInputValueFromQueryParam(
queryParam: AttributeMetadata[] | undefined,
productAttributes: AttributeWithTerms[]
): FormTokenField.Value[] {
return (
queryParam?.map( ( metadata ) => {
const { taxonomy, term } = getAttributeFromMetadata(
metadata,
productAttributes
);
return ! taxonomy || ! term
? {
title: __(
'Saved taxonomy was perhaps deleted or the slug was changed.',
'woo-gutenberg-products-block'
),
value: __(
`Error with saved taxonomy`,
'woo-gutenberg-products-block'
),
status: 'error',
}
: `${ taxonomy.attribute_label }: ${ term.name }`;
} ) || []
);
}
export const AttributesFilter = ( props: ProductQueryBlock ) => { export const AttributesFilter = ( props: ProductQueryBlock ) => {
const { query } = props.attributes; const { query } = props.attributes;
const { isLoadingAttributes, productsAttributes } = const [ selected, setSelected ] = useState< { id: number }[] >( [] );
useProductAttributes( true );
const attributesSuggestions = productsAttributes.reduce( ( acc, curr ) => { useEffect( () => {
const namespacedTerms = curr.terms.map( if ( query.__woocommerceAttributes ) {
( term ) => `${ curr.attribute_label }: ${ term.name }` setSelected(
); query.__woocommerceAttributes.map( ( { termId: id } ) => ( {
id,
return [ ...acc, ...namespacedTerms ]; } ) )
}, [] as string[] ); );
}
}, [ query.__woocommerceAttributes ] );
return ( return (
<ToolsPanelItem <ToolsPanelItem
label={ __( 'Product Attributes', 'woo-gutenberg-products-block' ) } label={ __( 'Product Attributes', 'woo-gutenberg-products-block' ) }
hasValue={ () => query.__woocommerceAttributes?.length } hasValue={ () => query.__woocommerceAttributes?.length }
> >
<FormTokenField <ProductAttributeTermControl
disabled={ isLoadingAttributes } messages={ {
label={ __( search: __( 'Attributes', 'woo-gutenberg-products-block' ),
'Product Attributes',
'woo-gutenberg-products-block'
) }
onChange={ ( attributes ) => {
let __woocommerceAttributes;
try {
__woocommerceAttributes = attributes.map(
( attribute ) => {
attribute =
typeof attribute === 'string'
? attribute
: attribute.value;
return getAttributeMetadataFromToken(
attribute,
productsAttributes
);
}
);
setQueryAttribute( props, {
__woocommerceAttributes,
} );
} catch ( ok ) {
// Not required to do anything here
// Input validation is handled by the `validateInput`
// below, and we don't need to save anything.
}
} } } }
suggestions={ attributesSuggestions } selected={ selected }
validateInput={ ( value: string ) => onChange={ ( attributes ) => {
attributesSuggestions.includes( value ) const __woocommerceAttributes = attributes.map(
} // eslint-disable-next-line @typescript-eslint/naming-convention
value={ ( { id, value } ) => ( {
isLoadingAttributes termId: id,
? [ __( 'Loading…', 'woo-gutenberg-products-block' ) ] taxonomy: value,
: getInputValueFromQueryParam( } )
query.__woocommerceAttributes, );
productsAttributes
) setQueryAttribute( props, {
} __woocommerceAttributes,
__experimentalExpandOnFocus={ true } } );
} }
operator={ 'any' }
isCompact={ true }
type={ 'token' }
/> />
<ExternalLink
className="woocommerce-product-query-panel__external-link"
href={ EDIT_ATTRIBUTES_URL }
>
{ __( 'Manage attributes', 'woo-gutenberg-products-block' ) }
</ExternalLink>
</ToolsPanelItem> </ToolsPanelItem>
); );
}; };

View File

@ -3,3 +3,12 @@
grid-column: 1 / -1; grid-column: 1 / -1;
} }
} }
.woocommerce-product-query-panel__external-link {
display: block;
margin-top: $gap-small;
.components-external-link__icon {
margin-left: $gap-smaller;
}
}

View File

@ -1,18 +1,7 @@
/** /**
* External dependencies * External dependencies
*/ */
import type { import type { AttributeMetadata, EditorBlock } from '@woocommerce/types';
AttributeSetting,
AttributeTerm,
EditorBlock,
} from '@woocommerce/types';
export interface AttributeMetadata {
taxonomy: string;
termId: number;
}
export type AttributeWithTerms = AttributeSetting & { terms: AttributeTerm[] };
// The interface below disables the forbidden underscores // The interface below disables the forbidden underscores
// naming convention because we are namespacing our // naming convention because we are namespacing our

View File

@ -1,62 +0,0 @@
/**
* External dependencies
*/
import { useEffect, useRef, useState } from '@wordpress/element';
import { getTerms } from '@woocommerce/editor-components/utils';
import { getSetting } from '@woocommerce/settings';
import { AttributeSetting } from '@woocommerce/types';
/**
* Internal dependencies
*/
import { AttributeWithTerms } from './types';
export default function useProductAttributes( shouldLoadAttributes: boolean ) {
const STORE_ATTRIBUTES = getSetting< AttributeSetting[] >(
'attributes',
[]
);
const [ isLoadingAttributes, setIsLoadingAttributes ] = useState( false );
const [ productsAttributes, setProductsAttributes ] = useState<
AttributeWithTerms[]
>( [] );
const hasLoadedAttributes = useRef( false );
useEffect( () => {
if (
! shouldLoadAttributes ||
isLoadingAttributes ||
hasLoadedAttributes.current
)
return;
async function fetchTerms() {
setIsLoadingAttributes( true );
for ( const attribute of STORE_ATTRIBUTES ) {
const terms = await getTerms(
Number( attribute.attribute_id )
);
setProductsAttributes( ( oldAttributes ) => [
...oldAttributes,
{
...attribute,
terms,
},
] );
}
hasLoadedAttributes.current = true;
setIsLoadingAttributes( false );
}
fetchTerms();
return () => {
hasLoadedAttributes.current = true;
};
}, [ STORE_ATTRIBUTES, isLoadingAttributes, shouldLoadAttributes ] );
return { isLoadingAttributes, productsAttributes };
}

View File

@ -20,7 +20,7 @@ export interface ErrorObject {
/** /**
* Context in which the error was triggered. That will determine how the error is displayed to the user. * Context in which the error was triggered. That will determine how the error is displayed to the user.
*/ */
type: 'api' | 'general'; type: 'api' | 'general' | string;
} }
export interface ErrorPlaceholderProps { export interface ErrorPlaceholderProps {

View File

@ -1,21 +1,13 @@
/** /**
* External dependencies * External dependencies
*/ */
import { SearchListItem } from '@woocommerce/editor-components/search-list-control';
import { Spinner } from '@wordpress/components'; import { Spinner } from '@wordpress/components';
import { SearchListItem } from '@woocommerce/editor-components/search-list-control';
import { renderItemArgs } from '@woocommerce/editor-components/search-list-control/types';
import classNames from 'classnames'; import classNames from 'classnames';
interface SearchListItem { interface ExpandableSearchListItemProps extends renderItemArgs {
id: string;
}
interface ExpandableSearchListItemProps {
className?: string;
item: SearchListItem;
isSelected: boolean;
isLoading: boolean; isLoading: boolean;
onSelect: () => void;
disabled: boolean;
} }
const ExpandableSearchListItem = ( { const ExpandableSearchListItem = ( {
@ -36,7 +28,6 @@ const ExpandableSearchListItem = ( {
isSelected={ isSelected } isSelected={ isSelected }
item={ item } item={ item }
onSelect={ onSelect } onSelect={ onSelect }
isSingle
disabled={ disabled } disabled={ disabled }
/> />
{ isSelected && isLoading && ( { isSelected && isLoading && (

View File

@ -1,40 +1,60 @@
/** /**
* External dependencies * External dependencies
*/ */
import classNames from 'classnames';
import { __, _n, sprintf } from '@wordpress/i18n'; import { __, _n, sprintf } from '@wordpress/i18n';
import PropTypes from 'prop-types';
import { import {
SearchListControl, SearchListControl,
SearchListItem, SearchListItem,
} from '@woocommerce/editor-components/search-list-control'; } from '@woocommerce/editor-components/search-list-control';
import { SelectControl } from '@wordpress/components'; import { SelectControl } from '@wordpress/components';
import { withInstanceId } from '@wordpress/compose'; import { withInstanceId } from '@wordpress/compose';
import { withAttributes } from '@woocommerce/block-hocs'; import useProductAttributes from '@woocommerce/base-context/hooks/use-product-attributes';
import ErrorMessage from '@woocommerce/editor-components/error-placeholder/error-message'; import ErrorMessage from '@woocommerce/editor-components/error-placeholder/error-message';
import classNames from 'classnames'; import ExpandableSearchListItem from '@woocommerce/editor-components/expandable-search-list-item/expandable-search-list-item';
import ExpandableSearchListItem from '@woocommerce/editor-components/expandable-search-list-item/expandable-search-list-item.tsx'; import {
renderItemArgs,
SearchListControlProps,
SearchListItem as SearchListItemProps,
} from '@woocommerce/editor-components/search-list-control/types';
import { convertAttributeObjectToSearchItem } from '@woocommerce/utils';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import './style.scss'; import './style.scss';
interface Props
extends Omit< SearchListControlProps, 'isSingle' | 'list' | 'selected' > {
instanceId?: string;
/**
* Callback to update the category operator. If not passed in, setting is not used.
*/
onOperatorChange?: () => void;
/**
* Setting for whether products should match all or any selected categories.
*/
operator: 'all' | 'any';
/**
* The list of currently selected attribute ids.
*/
selected: { id: number }[];
}
const ProductAttributeTermControl = ( { const ProductAttributeTermControl = ( {
attributes,
error,
expandedAttribute,
onChange, onChange,
onExpandAttribute,
onOperatorChange, onOperatorChange,
instanceId, instanceId,
isCompact, isCompact = false,
isLoading, messages = {},
operator, operator = 'any',
selected, selected,
termsAreLoading, type = 'text',
termsList, }: Props ) => {
} ) => { const { errorLoadingAttributes, isLoadingAttributes, productsAttributes } =
const renderItem = ( args ) => { useProductAttributes( true );
const renderItem = ( args: renderItemArgs ) => {
const { item, search, depth = 0 } = args; const { item, search, depth = 0 } = args;
const classes = [ const classes = [
'woocommerce-product-attributes__item', 'woocommerce-product-attributes__item',
@ -46,23 +66,13 @@ const ProductAttributeTermControl = ( {
]; ];
if ( ! item.breadcrumbs.length ) { if ( ! item.breadcrumbs.length ) {
const isSelected = expandedAttribute === item.id;
return ( return (
<ExpandableSearchListItem <ExpandableSearchListItem
{ ...args } { ...args }
className={ classNames( ...classes, { className={ classNames( classes ) }
'is-selected': isSelected,
} ) }
isSelected={ isSelected }
item={ item } item={ item }
isLoading={ termsAreLoading } isLoading={ isLoadingAttributes }
disabled={ item.count === '0' } disabled={ item.count === 0 }
onSelect={ ( { id } ) => {
return () => {
onChange( [] );
onExpandAttribute( id );
};
} }
name={ `attributes-${ instanceId }` } name={ `attributes-${ instanceId }` }
countLabel={ sprintf( countLabel={ sprintf(
/* translators: %d is the count of terms. */ /* translators: %d is the count of terms. */
@ -121,15 +131,21 @@ const ProductAttributeTermControl = ( {
); );
}; };
const currentTerms = termsList[ expandedAttribute ] || []; const list = productsAttributes.reduce( ( acc, curr ) => {
const currentList = [ ...attributes, ...currentTerms ]; const { terms, ...props } = curr;
const messages = { return [
...acc,
convertAttributeObjectToSearchItem( props ),
...terms.map( convertAttributeObjectToSearchItem ),
];
}, [] as SearchListItemProps[] );
messages = {
clear: __( clear: __(
'Clear all product attributes', 'Clear all product attributes',
'woo-gutenberg-products-block' 'woo-gutenberg-products-block'
), ),
list: __( 'Product Attributes', 'woo-gutenberg-products-block' ),
noItems: __( noItems: __(
"Your store doesn't have any product attributes.", "Your store doesn't have any product attributes.",
'woo-gutenberg-products-block' 'woo-gutenberg-products-block'
@ -138,7 +154,7 @@ const ProductAttributeTermControl = ( {
'Search for product attributes', 'Search for product attributes',
'woo-gutenberg-products-block' 'woo-gutenberg-products-block'
), ),
selected: ( n ) => selected: ( n: number ) =>
sprintf( sprintf(
/* translators: %d is the count of attributes selected. */ /* translators: %d is the count of attributes selected. */
_n( _n(
@ -153,30 +169,33 @@ const ProductAttributeTermControl = ( {
'Product attribute search results updated.', 'Product attribute search results updated.',
'woo-gutenberg-products-block' 'woo-gutenberg-products-block'
), ),
...messages,
}; };
if ( error ) { if ( errorLoadingAttributes ) {
return <ErrorMessage error={ error } />; return <ErrorMessage error={ errorLoadingAttributes } />;
} }
return ( return (
<> <>
<SearchListControl <SearchListControl
className="woocommerce-product-attributes" className="woocommerce-product-attributes"
list={ currentList }
isLoading={ isLoading }
selected={ selected
.map( ( { id } ) =>
currentList.find(
( currentListItem ) => currentListItem.id === id
)
)
.filter( Boolean ) }
onChange={ onChange }
renderItem={ renderItem }
messages={ messages }
isCompact={ isCompact } isCompact={ isCompact }
isHierarchical isHierarchical
isLoading={ isLoadingAttributes }
isSingle={ false }
list={ list }
messages={ messages }
onChange={ onChange }
renderItem={ renderItem }
selected={
selected
.map( ( { id } ) =>
list.find( ( term ) => term.id === id )
)
.filter( Boolean ) as SearchListItemProps[]
}
type={ type }
/> />
{ !! onOperatorChange && ( { !! onOperatorChange && (
<div hidden={ selected.length < 2 }> <div hidden={ selected.length < 2 }>
@ -215,37 +234,4 @@ const ProductAttributeTermControl = ( {
); );
}; };
ProductAttributeTermControl.propTypes = { export default withInstanceId( ProductAttributeTermControl );
/**
* Callback to update the selected product attributes.
*/
onChange: PropTypes.func.isRequired,
/**
* Callback to update the category operator. If not passed in, setting is not used.
*/
onOperatorChange: PropTypes.func,
/**
* Setting for whether products should match all or any selected categories.
*/
operator: PropTypes.oneOf( [ 'all', 'any' ] ),
/**
* The list of currently selected attribute slug/ID pairs.
*/
selected: PropTypes.array.isRequired,
// from withAttributes
attributes: PropTypes.array,
error: PropTypes.object,
expandedAttribute: PropTypes.number,
onExpandAttribute: PropTypes.func,
isCompact: PropTypes.bool,
isLoading: PropTypes.bool,
termsAreLoading: PropTypes.bool,
termsList: PropTypes.object,
};
ProductAttributeTermControl.defaultProps = {
isCompact: false,
operator: 'any',
};
export default withAttributes( withInstanceId( ProductAttributeTermControl ) );

View File

@ -32,25 +32,4 @@
margin-bottom: $gap-small; margin-bottom: $gap-small;
} }
} }
&.depth-0::after {
margin-left: $gap-smaller;
content: "";
height: $gap-large;
width: $gap-large;
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z" fill="#{encode-color($gray-700)}" /></svg>');
background-repeat: no-repeat;
background-position: center right;
background-size: contain;
}
&.depth-0.is-selected::after {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z" fill="#{encode-color($gray-700)}" /></svg>');
}
&[disabled].depth-0::after {
margin-left: 0;
width: auto;
background: none;
}
} }

View File

@ -1,9 +1,44 @@
/**
* External dependencies
*/
import classNames from 'classnames';
import { CheckboxControl } from '@wordpress/components';
import { useCallback } from '@wordpress/element';
import { arrayDifferenceBy, arrayUnionBy } from '@woocommerce/utils';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import type { renderItemArgs } from './types'; import type {
renderItemArgs,
SearchListItem as SearchListItemProps,
} from './types';
import { getHighlightedName, getBreadcrumbsForDisplay } from './utils'; import { getHighlightedName, getBreadcrumbsForDisplay } from './utils';
const Count = ( { label }: { label: string | React.ReactNode | number } ) => {
return (
<span className="woocommerce-search-list__item-count">{ label }</span>
);
};
const ItemLabel = ( props: { item: SearchListItemProps; search: string } ) => {
const { item, search } = props;
const hasBreadcrumbs = item.breadcrumbs && item.breadcrumbs.length;
return (
<span className="woocommerce-search-list__item-label">
{ hasBreadcrumbs ? (
<span className="woocommerce-search-list__item-prefix">
{ getBreadcrumbsForDisplay( item.breadcrumbs ) }
</span>
) : null }
<span className="woocommerce-search-list__item-name">
{ getHighlightedName( item.name, search ) }
</span>
</span>
);
};
export const SearchListItem = ( { export const SearchListItem = ( {
countLabel, countLabel,
className, className,
@ -14,27 +49,115 @@ export const SearchListItem = ( {
isSingle, isSingle,
onSelect, onSelect,
search = '', search = '',
selected,
useExpandedPanelId,
...props ...props
}: renderItemArgs ): JSX.Element => { }: renderItemArgs ): JSX.Element => {
const [ expandedPanelId, setExpandedPanelId ] = useExpandedPanelId;
const showCount = const showCount =
countLabel !== undefined && countLabel !== undefined &&
countLabel !== null && countLabel !== null &&
item.count !== undefined && item.count !== undefined &&
item.count !== null; item.count !== null;
const classes = [ className, 'woocommerce-search-list__item' ]; const hasBreadcrumbs = !! item.breadcrumbs?.length;
classes.push( `depth-${ depth }` ); const hasChildren = !! item.children?.length;
if ( isSingle ) { const isExpanded = expandedPanelId === item.id;
classes.push( 'is-radio-button' ); const classes = classNames(
} [ 'woocommerce-search-list__item', `depth-${ depth }`, className ],
if ( showCount ) { {
classes.push( 'has-count' ); 'has-breadcrumbs': hasBreadcrumbs,
} 'has-children': hasChildren,
const hasBreadcrumbs = item.breadcrumbs && item.breadcrumbs.length; 'has-count': showCount,
'is-expanded': isExpanded,
'is-radio-button': isSingle,
}
);
const name = props.name || `search-list-item-${ controlId }`; const name = props.name || `search-list-item-${ controlId }`;
const id = `${ name }-${ item.id }`; const id = `${ name }-${ item.id }`;
return ( const togglePanel = useCallback( () => {
<label htmlFor={ id } className={ classes.join( ' ' ) }> setExpandedPanelId( isExpanded ? -1 : Number( item.id ) );
}, [ isExpanded, item.id, setExpandedPanelId ] );
return hasChildren ? (
<div
className={ classes }
onClick={ togglePanel }
onKeyDown={ ( e ) =>
e.key === 'Enter' || e.key === ' ' ? togglePanel() : null
}
role="treeitem"
tabIndex={ 0 }
>
{ isSingle ? (
<>
<input
type="radio"
id={ id }
name={ name }
value={ item.value }
onChange={ onSelect( item ) }
onClick={ ( e ) => e.stopPropagation() }
checked={ isSelected }
className="woocommerce-search-list__item-input"
{ ...props }
/>
<ItemLabel item={ item } search={ search } />
{ showCount ? (
<Count label={ countLabel || item.count } />
) : null }
</>
) : (
<>
<CheckboxControl
className="woocommerce-search-list__item-input"
checked={ isSelected }
{ ...( ! isSelected &&
// We know that `item.children` is not `undefined` because
// we are here only if `hasChildren` is `true`.
( item.children as SearchListItemProps[] ).some(
( child ) =>
selected.find(
( selectedItem ) =>
selectedItem.id === child.id
)
)
? { indeterminate: true }
: {} ) }
label={ getHighlightedName( item.name, search ) }
onChange={ () => {
if ( isSelected ) {
onSelect(
arrayDifferenceBy(
selected,
item.children as SearchListItemProps[],
'id'
)
)();
} else {
onSelect(
arrayUnionBy(
selected,
item.children as SearchListItemProps[],
'id'
)
)();
}
} }
onClick={ ( e ) => e.stopPropagation() }
/>
{ showCount ? (
<Count label={ countLabel || item.count } />
) : null }
</>
) }
</div>
) : (
<label htmlFor={ id } className={ classes }>
{ isSingle ? ( { isSingle ? (
<input <input
type="radio" type="radio"
@ -59,22 +182,9 @@ export const SearchListItem = ( {
></input> ></input>
) } ) }
<span className="woocommerce-search-list__item-label"> <ItemLabel item={ item } search={ search } />
{ hasBreadcrumbs ? (
<span className="woocommerce-search-list__item-prefix">
{ getBreadcrumbsForDisplay( item.breadcrumbs ) }
</span>
) : null }
<span className="woocommerce-search-list__item-name">
{ getHighlightedName( item.name, search ) }
</span>
</span>
{ !! showCount && ( { showCount ? <Count label={ countLabel || item.count } /> : null }
<span className="woocommerce-search-list__item-count">
{ countLabel || item.count }
</span>
) }
</label> </label>
); );
}; };

View File

@ -4,6 +4,7 @@
import { __, sprintf } from '@wordpress/i18n'; import { __, sprintf } from '@wordpress/i18n';
import { import {
Button, Button,
FormTokenField,
Spinner, Spinner,
TextControl, TextControl,
withSpokenMessages, withSpokenMessages,
@ -26,27 +27,19 @@ import { getFilteredList, defaultMessages } from './utils';
import SearchListItem from './item'; import SearchListItem from './item';
import Tag from '../tag'; import Tag from '../tag';
import type { import type {
SearchListItemsType, SearchListItem as SearchListItemProps,
SearchListItemType,
SearchListControlProps, SearchListControlProps,
SearchListMessages, SearchListMessages,
renderItemArgs, renderItemArgs,
ListItemsProps,
SearchListItemsContainerProps,
} from './types'; } from './types';
const defaultRenderListItem = ( args: renderItemArgs ): JSX.Element => { const defaultRenderListItem = ( args: renderItemArgs ): JSX.Element => {
return <SearchListItem { ...args } />; return <SearchListItem { ...args } />;
}; };
const ListItems = ( props: { const ListItems = ( props: ListItemsProps ): JSX.Element | null => {
list: SearchListItemsType;
selected: SearchListItemsType;
renderItem: ( args: renderItemArgs ) => JSX.Element;
depth?: number;
onSelect: ( item: SearchListItemType ) => () => void;
instanceId: string | number;
isSingle: boolean;
search: string;
} ): JSX.Element | null => {
const { const {
list, list,
selected, selected,
@ -56,7 +49,11 @@ const ListItems = ( props: {
instanceId, instanceId,
isSingle, isSingle,
search, search,
useExpandedPanelId,
} = props; } = props;
const [ expandedPanelId ] = useExpandedPanelId;
if ( ! list ) { if ( ! list ) {
return null; return null;
} }
@ -64,7 +61,16 @@ const ListItems = ( props: {
<> <>
{ list.map( ( item ) => { { list.map( ( item ) => {
const isSelected = const isSelected =
selected.findIndex( ( { id } ) => id === item.id ) !== -1; item.children?.length && ! isSingle
? item.children.every( ( { id } ) =>
selected.find(
( selectedItem ) => selectedItem.id === id
)
)
: !! selected.find( ( { id } ) => id === item.id );
const isExpanded =
item.children?.length && expandedPanelId === item.id;
return ( return (
<Fragment key={ item.id }> <Fragment key={ item.id }>
<li> <li>
@ -73,16 +79,20 @@ const ListItems = ( props: {
isSelected, isSelected,
onSelect, onSelect,
isSingle, isSingle,
selected,
search, search,
depth, depth,
useExpandedPanelId,
controlId: instanceId, controlId: instanceId,
} ) } } ) }
</li> </li>
<ListItems { isExpanded ? (
{ ...props } <ListItems
list={ item.children } { ...props }
depth={ depth + 1 } list={ item.children as SearchListItemProps[] }
/> depth={ depth + 1 }
/>
) : null }
</Fragment> </Fragment>
); );
} ) } } ) }
@ -142,14 +152,9 @@ const ListItemsContainer = ( {
search, search,
onSelect, onSelect,
instanceId, instanceId,
useExpandedPanelId,
...props ...props
}: SearchListControlProps & { }: SearchListItemsContainerProps ) => {
messages: SearchListMessages;
filteredList: SearchListItemsType;
search: string;
instanceId: string | number;
onSelect: ( item: SearchListItemType ) => () => void;
} ) => {
const { messages, renderItem, selected, isSingle } = props; const { messages, renderItem, selected, isSingle } = props;
const renderItemCallback = renderItem || defaultRenderListItem; const renderItemCallback = renderItem || defaultRenderListItem;
@ -172,6 +177,7 @@ const ListItemsContainer = ( {
return ( return (
<ul className="woocommerce-search-list__list"> <ul className="woocommerce-search-list__list">
<ListItems <ListItems
useExpandedPanelId={ useExpandedPanelId }
list={ filteredList } list={ filteredList }
selected={ selected } selected={ selected }
renderItem={ renderItemCallback } renderItem={ renderItemCallback }
@ -187,9 +193,7 @@ const ListItemsContainer = ( {
/** /**
* Component to display a searchable, selectable list of items. * Component to display a searchable, selectable list of items.
*/ */
export const SearchListControl = ( export const SearchListControl = ( props: SearchListControlProps ) => {
props: SearchListControlProps
): JSX.Element => {
const { const {
className = '', className = '',
isCompact, isCompact,
@ -201,9 +205,12 @@ export const SearchListControl = (
onChange, onChange,
onSearch, onSearch,
selected, selected,
type = 'text',
debouncedSpeak, debouncedSpeak,
} = props; } = props;
const [ search, setSearch ] = useState( '' ); const [ search, setSearch ] = useState( '' );
const useExpandedPanelId = useState< number >( -1 );
const instanceId = useInstanceId( SearchListControl ); const instanceId = useInstanceId( SearchListControl );
const messages = useMemo( const messages = useMemo(
() => ( { ...defaultMessages, ...customMessages } ), () => ( { ...defaultMessages, ...customMessages } ),
@ -242,7 +249,12 @@ export const SearchListControl = (
); );
const onSelect = useCallback( const onSelect = useCallback(
( item: SearchListItemType ) => () => { ( item: SearchListItemProps | SearchListItemProps[] ) => () => {
if ( Array.isArray( item ) ) {
onChange( item );
return;
}
if ( selected.findIndex( ( { id } ) => id === item.id ) !== -1 ) { if ( selected.findIndex( ( { id } ) => id === item.id ) !== -1 ) {
onRemove( item.id )(); onRemove( item.id )();
return; return;
@ -256,27 +268,69 @@ export const SearchListControl = (
[ isSingle, onRemove, onChange, selected ] [ isSingle, onRemove, onChange, selected ]
); );
const onRemoveToken = useCallback(
( tokens: Array< SearchListItemProps & { value: string } > ) => {
const [ removedItem ] = selected.filter(
( item ) => ! tokens.find( ( token ) => item.id === token.id )
);
onRemove( removedItem.id )();
},
[ onRemove, selected ]
);
return ( return (
<div <div
className={ classnames( 'woocommerce-search-list', className, { className={ classnames( 'woocommerce-search-list', className, {
'is-compact': isCompact, 'is-compact': isCompact,
'is-loading': isLoading,
'is-token': type === 'token',
} ) } } ) }
> >
<SelectedListItems { type === 'text' && (
{ ...props } <SelectedListItems
onRemove={ onRemove } { ...props }
messages={ messages } onRemove={ onRemove }
/> messages={ messages }
<div className="woocommerce-search-list__search">
<TextControl
label={ messages.search }
type="search"
value={ search }
onChange={ ( value ) => setSearch( value ) }
/> />
) }
<div className="woocommerce-search-list__search">
{ type === 'text' ? (
<TextControl
label={ messages.search }
type="search"
value={ search }
onChange={ ( value ) => setSearch( value ) }
/>
) : (
<FormTokenField
disabled={ isLoading }
label={ messages.search }
onChange={ onRemoveToken }
onInputChange={ ( value ) => setSearch( value ) }
suggestions={ [] }
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - Ignoring because `__experimentalValidateInput` is not yet in the type definitions.
__experimentalValidateInput={ () => false }
value={
isLoading
? [
__(
'Loading…',
'woo-gutenberg-products-block'
),
]
: selected.map( ( token ) => ( {
...token,
value: token.name,
} ) )
}
__experimentalShowHowTo={ false }
/>
) }
</div> </div>
{ isLoading ? ( { isLoading ? (
<div className="woocommerce-search-list__list is-loading"> <div className="woocommerce-search-list__list">
<Spinner /> <Spinner />
</div> </div>
) : ( ) : (
@ -287,6 +341,7 @@ export const SearchListControl = (
messages={ messages } messages={ messages }
onSelect={ onSelect } onSelect={ onSelect }
instanceId={ instanceId } instanceId={ instanceId }
useExpandedPanelId={ useExpandedPanelId }
/> />
) } ) }
</div> </div>

View File

@ -2,6 +2,56 @@
width: 100%; width: 100%;
padding: 0 0 $gap; padding: 0 0 $gap;
text-align: left; text-align: left;
&.is-compact {
.woocommerce-search-list__selected {
margin: 0 0 $gap;
padding: 0;
border-top: none;
// 54px is the height of 1 row of tags in the sidebar.
min-height: 54px;
}
.woocommerce-search-list__search {
margin: 0 0 $gap;
padding: 0;
border-top: none;
&.is-token {
margin-bottom: 0;
}
}
}
&.is-loading {
.woocommerce-search-list__list {
padding: $gap-small 0;
text-align: center;
border: none;
}
.components-form-token-field__remove-token {
// We use a placeholder Loading text when loading passed
// as a value to the `FormTokenField`, so we hide the X.
display: none;
}
}
&.is-token {
.woocommerce-search-list__list {
border-top: 0;
}
.woocommerce-search-list__search {
margin-bottom: 0;
.components-form-token-field__input-container {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
margin-bottom: 0;
}
}
}
} }
.woocommerce-search-list__selected { .woocommerce-search-list__selected {
@ -56,12 +106,6 @@
margin-bottom: 0; margin-bottom: 0;
} }
&.is-loading {
padding: $gap-small 0;
text-align: center;
border: none;
}
&.is-not-found { &.is-not-found {
padding: $gap-small 0; padding: $gap-small 0;
text-align: center; text-align: center;
@ -117,6 +161,31 @@
box-shadow: none; box-shadow: none;
} }
&.has-children {
cursor: pointer;
&::after {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z" fill="#{encode-color($gray-700)}" /></svg>');
background-position: center right;
background-repeat: no-repeat;
background-size: contain;
content: "";
height: $gap-large;
margin-left: $gap-smaller;
width: $gap-large;
}
&[disabled]::after {
background: none;
margin-left: 0;
width: auto;
}
&.is-expanded::after {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z" fill="#{encode-color($gray-700)}" /></svg>');
}
}
.woocommerce-search-list__item-input { .woocommerce-search-list__item-input {
margin: 0 $gap-smaller 0 0; margin: 0 $gap-smaller 0 0;
} }
@ -151,6 +220,10 @@
} }
@for $i from 1 to 5 { @for $i from 1 to 5 {
&.depth-#{$i} {
padding-left: $gap * ( $i + 1 );
}
&.depth-#{$i} .woocommerce-search-list__item-label::before { &.depth-#{$i} .woocommerce-search-list__item-label::before {
content: str-repeat("", $i); content: str-repeat("", $i);
} }
@ -201,6 +274,7 @@
border-radius: 12px; border-radius: 12px;
font-size: 0.8em; font-size: 0.8em;
line-height: 1.4; line-height: 1.4;
margin-left: auto;
color: $gray-700; color: $gray-700;
background: $studio-white; background: $studio-white;
white-space: nowrap; white-space: nowrap;
@ -211,19 +285,3 @@
border-bottom: none; border-bottom: none;
} }
} }
.woocommerce-search-list.is-compact {
.woocommerce-search-list__selected {
margin: 0 0 $gap;
padding: 0;
border-top: none;
// 54px is the height of 1 row of tags in the sidebar.
min-height: 54px;
}
.woocommerce-search-list__search {
margin: 0 0 $gap;
padding: 0;
border-top: none;
}
}

View File

@ -206,11 +206,14 @@ describe( 'SearchListControl', () => {
test( 'should render a search box and list of hierarchical options', () => { test( 'should render a search box and list of hierarchical options', () => {
const component = render( const component = render(
<SearchListControl <SearchListControl
instanceId={ 1 } isCompact
list={ hierarchicalList }
selected={ [] }
onChange={ noop }
isHierarchical isHierarchical
instanceId={ 1 }
isSingle={ false }
list={ hierarchicalList }
onChange={ noop }
selected={ [] }
type={ 'text' }
/> />
); );
expect( component ).toMatchSnapshot(); expect( component ).toMatchSnapshot();

View File

@ -2,18 +2,55 @@
* External dependencies * External dependencies
*/ */
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { Require } from '@woocommerce/types';
export type SearchListItemType = { interface ItemProps {
// Depth, non-zero if the list is hierarchical.
depth?: number;
// Callback for selecting the item.
onSelect: ( item: SearchListItem | SearchListItem[] ) => () => void;
// Search string, used to highlight the substring in the item name.
search: string;
useExpandedPanelId: [
number,
React.Dispatch< React.SetStateAction< number > >
];
}
interface SearchListProps {
//Restrict selections to one item.
isSingle: boolean;
// A complete list of item objects, each with id, name properties. This is displayed as a clickable/keyboard-able list, and possibly filtered by the search term (searches name).
list: SearchListItem[];
// Callback to render each item in the selection list, allows any custom object-type rendering.
renderItem?: ( args: renderItemArgs ) => JSX.Element;
// The list of currently selected items.
selected: SearchListItem[];
}
export interface ListItemsProps
extends Require< SearchListProps, 'renderItem' >,
ItemProps {
instanceId: string | number;
}
export type SearchListItem = {
breadcrumbs: string[];
children?: SearchListItem[];
count: number;
id: string | number; id: string | number;
name: string; name: string;
value: string;
parent: number; parent: number;
count: number; value: string;
children: SearchListItemsType;
breadcrumbs: string[];
}; };
export type SearchListItemsType = SearchListItemType[] | []; export interface SearchListItemsContainerProps
extends SearchListControlProps,
ItemProps {
instanceId: string | number;
filteredList: SearchListItem[];
messages: SearchListMessages;
}
export interface SearchListMessages { export interface SearchListMessages {
// A more detailed label for the "Clear all" button, read to screen reader users. // A more detailed label for the "Clear all" button, read to screen reader users.
@ -30,21 +67,19 @@ export interface SearchListMessages {
updated: string; updated: string;
} }
export interface renderItemArgs { export interface renderItemArgs extends ItemProps {
// Additional CSS classes. // Additional CSS classes.
className?: string; className?: string | undefined;
// Whether the item is disable.
disabled?: boolean | undefined;
// Current item to display. // Current item to display.
item: SearchListItemType; item: SearchListItem;
// Whether this item is selected. // Whether this item is selected.
isSelected: boolean; isSelected: boolean;
// Callback for selecting the item.
onSelect: ( item: SearchListItemType ) => () => void;
// Whether this should only display a single item (controls radio vs checkbox icon). // Whether this should only display a single item (controls radio vs checkbox icon).
isSingle: boolean; isSingle: boolean;
// Search string, used to highlight the substring in the item name. // The list of currently selected items.
search: string; selected: SearchListItem[];
// Depth, non-zero if the list is hierarchical.
depth: number;
// Unique id of the parent control. // Unique id of the parent control.
controlId: string | number; controlId: string | number;
// Label to display in the count bubble. Takes preference over `item.count`. // Label to display in the count bubble. Takes preference over `item.count`.
@ -59,7 +94,7 @@ export interface renderItemArgs {
export interface SearchListControlProps { export interface SearchListControlProps {
// Additional CSS classes. // Additional CSS classes.
className: string; className?: string;
// Whether it should be displayed in a compact way, so it occupies less space. // Whether it should be displayed in a compact way, so it occupies less space.
isCompact: boolean; isCompact: boolean;
// Whether the list of items is hierarchical or not. If true, each list item is expected to have a parent property. // Whether the list of items is hierarchical or not. If true, each list item is expected to have a parent property.
@ -69,17 +104,20 @@ export interface SearchListControlProps {
//Restrict selections to one item. //Restrict selections to one item.
isSingle: boolean; isSingle: boolean;
// A complete list of item objects, each with id, name properties. This is displayed as a clickable/keyboard-able list, and possibly filtered by the search term (searches name). // A complete list of item objects, each with id, name properties. This is displayed as a clickable/keyboard-able list, and possibly filtered by the search term (searches name).
list: SearchListItemsType; list: SearchListItem[];
// Messages displayed or read to the user. Configure these to reflect your object type. // Messages displayed or read to the user. Configure these to reflect your object type.
messages?: Partial< SearchListMessages >; messages?: Partial< SearchListMessages >;
// Callback fired when selected items change, whether added, cleared, or removed. Passed an array of item objects (as passed in via props.list). // Callback fired when selected items change, whether added, cleared, or removed. Passed an array of item objects (as passed in via props.list).
onChange: ( search: SearchListItemsType ) => void; onChange: ( search: SearchListItem[] ) => void;
// Callback fired when the search field is used. // Callback fired when the search field is used.
onSearch?: ( search: string ) => void; onSearch?: ( search: string ) => void;
// Callback to render each item in the selection list, allows any custom object-type rendering. // Callback to render each item in the selection list, allows any custom object-type rendering.
renderItem?: ( args: renderItemArgs ) => JSX.Element; renderItem?: ( args: renderItemArgs ) => JSX.Element;
// The list of currently selected items. // The list of currently selected items.
selected: SearchListItemsType; selected: SearchListItem[];
// Whether to show a text field or a token field as search
// Defaults to `'text'`
type?: 'text' | 'token';
// from withSpokenMessages // from withSpokenMessages
debouncedSpeak?: ( message: string ) => void; debouncedSpeak?: ( message: string ) => void;
} }

View File

@ -8,7 +8,7 @@ import { __, _n, sprintf } from '@wordpress/i18n';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import type { SearchListItemType, SearchListItemsType } from './types'; import type { SearchListItem } from './types';
export const defaultMessages = { export const defaultMessages = {
clear: __( 'Clear all selected items', 'woo-gutenberg-products-block' ), clear: __( 'Clear all selected items', 'woo-gutenberg-products-block' ),
@ -39,14 +39,14 @@ export const defaultMessages = {
* @return {Array} Array of terms in tree format. * @return {Array} Array of terms in tree format.
*/ */
export const buildTermsTree = ( export const buildTermsTree = (
filteredList: SearchListItemsType, filteredList: SearchListItem[],
list = filteredList list = filteredList
): SearchListItemType[] | [] => { ): SearchListItem[] | [] => {
const termsByParent = groupBy( filteredList, 'parent' ); const termsByParent = groupBy( filteredList, 'parent' );
const listById = keyBy( list, 'id' ); const listById = keyBy( list, 'id' );
const builtParents = [ '0' ]; const builtParents = [ '0' ];
const getParentsName = ( term = {} as SearchListItemType ): string[] => { const getParentsName = ( term = {} as SearchListItem ): string[] => {
if ( ! term.parent ) { if ( ! term.parent ) {
return term.name ? [ term.name ] : []; return term.name ? [ term.name ] : [];
} }
@ -55,11 +55,7 @@ export const buildTermsTree = (
return [ ...parentName, term.name ]; return [ ...parentName, term.name ];
}; };
const fillWithChildren = ( const fillWithChildren = ( terms: SearchListItem[] ): SearchListItem[] => {
terms: SearchListItemType[]
): ( SearchListItemType & {
breadcrumbs: string[];
} )[] => {
return terms.map( ( term ) => { return terms.map( ( term ) => {
const children = termsByParent[ term.id ]; const children = termsByParent[ term.id ];
builtParents.push( '' + term.id ); builtParents.push( '' + term.id );
@ -87,10 +83,10 @@ export const buildTermsTree = (
}; };
export const getFilteredList = ( export const getFilteredList = (
list: SearchListItemsType, list: SearchListItem[],
search: string, search: string,
isHierarchical: boolean isHierarchical?: boolean | undefined
): SearchListItemType[] | [] => { ) => {
if ( ! search ) { if ( ! search ) {
return isHierarchical ? buildTermsTree( list ) : list; return isHierarchical ? buildTermsTree( list ) : list;
} }
@ -100,7 +96,7 @@ export const getFilteredList = (
); );
const filteredList = list const filteredList = list
.map( ( item ) => ( re.test( item.name ) ? item : false ) ) .map( ( item ) => ( re.test( item.name ) ? item : false ) )
.filter( Boolean ) as SearchListItemsType; .filter( Boolean ) as SearchListItem[];
return isHierarchical ? buildTermsTree( filteredList, list ) : filteredList; return isHierarchical ? buildTermsTree( filteredList, list ) : filteredList;
}; };

View File

@ -2,16 +2,21 @@ export interface AttributeSetting {
attribute_id: string; attribute_id: string;
attribute_name: string; attribute_name: string;
attribute_label: string; attribute_label: string;
attribute_type: string;
attribute_orderby: 'menu_order' | 'name' | 'name_num' | 'id'; attribute_orderby: 'menu_order' | 'name' | 'name_num' | 'id';
attribute_public: 0 | 1; attribute_public: 0 | 1;
attribute_type: string;
} }
export interface AttributeObject { export interface AttributeObject {
count: number;
has_archives: boolean;
id: number; id: number;
name: string;
taxonomy: string;
label: string; label: string;
name: string;
order: 'menu_order' | 'name' | 'name_num' | 'id';
parent: number;
taxonomy: string;
type: string;
} }
export interface AttributeQuery { export interface AttributeQuery {
@ -21,6 +26,7 @@ export interface AttributeQuery {
} }
export interface AttributeTerm { export interface AttributeTerm {
attr_slug: string;
count: number; count: number;
description: string; description: string;
id: number; id: number;
@ -28,3 +34,10 @@ export interface AttributeTerm {
parent: number; parent: number;
slug: string; slug: string;
} }
export interface AttributeMetadata {
taxonomy: string;
termId: number;
}
export type AttributeWithTerms = AttributeObject & { terms: AttributeTerm[] };

View File

@ -7,6 +7,10 @@ export type Dictionary = Record< string, string >;
export type LooselyMustHave< T, K extends keyof T > = Partial< T > & export type LooselyMustHave< T, K extends keyof T > = Partial< T > &
Pick< T, K >; Pick< T, K >;
export type Require< T, K extends keyof T > = T & {
[ P in K ]-?: T[ K ];
};
export type HTMLElementEvent< T extends HTMLElement > = Event & { export type HTMLElementEvent< T extends HTMLElement > = Event & {
target: T; target: T;
}; };

View File

@ -0,0 +1,17 @@
/**
* Returns the difference between two arrays (A - B)
*/
export function arrayDifferenceBy< T >( a: T[], b: T[], key: keyof T ) {
const keys = new Set( b.map( ( item ) => item[ key ] ) );
return a.filter( ( item ) => ! keys.has( item[ key ] ) );
}
/**
* Returns the union of two arrays (A B)
*/
export function arrayUnionBy< T >( a: T[], b: T[], key: keyof T ) {
const difference = arrayDifferenceBy( b, a, key );
return [ ...a, ...difference ];
}

View File

@ -1,8 +1,15 @@
/** /**
* External dependencies * External dependencies
*/ */
import { SearchListItem } from '@woocommerce/editor-components/search-list-control/types';
import { getSetting } from '@woocommerce/settings'; import { getSetting } from '@woocommerce/settings';
import { AttributeObject, AttributeSetting } from '@woocommerce/types'; import {
AttributeObject,
AttributeSetting,
AttributeTerm,
AttributeWithTerms,
isAttributeTerm,
} from '@woocommerce/types';
const ATTRIBUTES = getSetting< AttributeSetting[] >( 'attributes', [] ); const ATTRIBUTES = getSetting< AttributeSetting[] >( 'attributes', [] );
@ -27,7 +34,7 @@ const attributeSettingToObject = ( attribute: AttributeSetting ) => {
* Format all attribute settings into objects. * Format all attribute settings into objects.
*/ */
const attributeObjects = ATTRIBUTES.reduce( const attributeObjects = ATTRIBUTES.reduce(
( acc: AttributeObject[], current ) => { ( acc: Partial< AttributeObject >[], current ) => {
const attributeObject = attributeSettingToObject( current ); const attributeObject = attributeSettingToObject( current );
if ( attributeObject && attributeObject.id ) { if ( attributeObject && attributeObject.id ) {
@ -39,6 +46,25 @@ const attributeObjects = ATTRIBUTES.reduce(
[] []
); );
/**
* Converts an Attribute object into a shape compatible with the `SearchListControl`
*/
export const convertAttributeObjectToSearchItem = (
attribute: AttributeObject | AttributeTerm | AttributeWithTerms
): SearchListItem => {
const { count, id, name, parent } = attribute;
return {
count,
id,
name,
parent,
breadcrumbs: [],
children: [],
value: isAttributeTerm( attribute ) ? attribute.attr_slug : '',
};
};
/** /**
* Get attribute data by taxonomy. * Get attribute data by taxonomy.
* *

View File

@ -1,3 +1,4 @@
export * from './array-operations';
export * from './attributes-query'; export * from './attributes-query';
export * from './attributes'; export * from './attributes';
export * from './filters'; export * from './filters';