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:
parent
3de39425c2
commit
61eeeb3c68
|
@ -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/**" ]
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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: '',
|
||||||
};
|
};
|
||||||
|
|
|
@ -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' ];
|
||||||
|
|
|
@ -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 ];
|
}, [ query.__woocommerceAttributes ] );
|
||||||
}, [] as string[] );
|
|
||||||
|
|
||||||
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'
|
selected={ selected }
|
||||||
) }
|
|
||||||
onChange={ ( attributes ) => {
|
onChange={ ( attributes ) => {
|
||||||
let __woocommerceAttributes;
|
const __woocommerceAttributes = attributes.map(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
try {
|
( { id, value } ) => ( {
|
||||||
__woocommerceAttributes = attributes.map(
|
termId: id,
|
||||||
( attribute ) => {
|
taxonomy: value,
|
||||||
attribute =
|
} )
|
||||||
typeof attribute === 'string'
|
|
||||||
? attribute
|
|
||||||
: attribute.value;
|
|
||||||
|
|
||||||
return getAttributeMetadataFromToken(
|
|
||||||
attribute,
|
|
||||||
productsAttributes
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
setQueryAttribute( props, {
|
setQueryAttribute( props, {
|
||||||
__woocommerceAttributes,
|
__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 }
|
operator={ 'any' }
|
||||||
validateInput={ ( value: string ) =>
|
isCompact={ true }
|
||||||
attributesSuggestions.includes( value )
|
type={ 'token' }
|
||||||
}
|
|
||||||
value={
|
|
||||||
isLoadingAttributes
|
|
||||||
? [ __( 'Loading…', 'woo-gutenberg-products-block' ) ]
|
|
||||||
: getInputValueFromQueryParam(
|
|
||||||
query.__woocommerceAttributes,
|
|
||||||
productsAttributes
|
|
||||||
)
|
|
||||||
}
|
|
||||||
__experimentalExpandOnFocus={ true }
|
|
||||||
/>
|
/>
|
||||||
|
<ExternalLink
|
||||||
|
className="woocommerce-product-query-panel__external-link"
|
||||||
|
href={ EDIT_ATTRIBUTES_URL }
|
||||||
|
>
|
||||||
|
{ __( 'Manage attributes', 'woo-gutenberg-products-block' ) }
|
||||||
|
</ExternalLink>
|
||||||
</ToolsPanelItem>
|
</ToolsPanelItem>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 };
|
|
||||||
}
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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 && (
|
||||||
|
|
|
@ -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 ) );
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 ],
|
||||||
|
{
|
||||||
|
'has-breadcrumbs': hasBreadcrumbs,
|
||||||
|
'has-children': hasChildren,
|
||||||
|
'has-count': showCount,
|
||||||
|
'is-expanded': isExpanded,
|
||||||
|
'is-radio-button': isSingle,
|
||||||
}
|
}
|
||||||
if ( showCount ) {
|
);
|
||||||
classes.push( 'has-count' );
|
|
||||||
}
|
|
||||||
const hasBreadcrumbs = item.breadcrumbs && item.breadcrumbs.length;
|
|
||||||
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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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>
|
||||||
|
{ isExpanded ? (
|
||||||
<ListItems
|
<ListItems
|
||||||
{ ...props }
|
{ ...props }
|
||||||
list={ item.children }
|
list={ item.children as SearchListItemProps[] }
|
||||||
depth={ depth + 1 }
|
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',
|
||||||
} ) }
|
} ) }
|
||||||
>
|
>
|
||||||
|
{ type === 'text' && (
|
||||||
<SelectedListItems
|
<SelectedListItems
|
||||||
{ ...props }
|
{ ...props }
|
||||||
onRemove={ onRemove }
|
onRemove={ onRemove }
|
||||||
messages={ messages }
|
messages={ messages }
|
||||||
/>
|
/>
|
||||||
|
) }
|
||||||
<div className="woocommerce-search-list__search">
|
<div className="woocommerce-search-list__search">
|
||||||
|
{ type === 'text' ? (
|
||||||
<TextControl
|
<TextControl
|
||||||
label={ messages.search }
|
label={ messages.search }
|
||||||
type="search"
|
type="search"
|
||||||
value={ search }
|
value={ search }
|
||||||
onChange={ ( value ) => setSearch( value ) }
|
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>
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -30,7 +30,7 @@ Object {
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
class="woocommerce-search-list"
|
class="woocommerce-search-list is-compact"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="woocommerce-search-list__selected"
|
class="woocommerce-search-list__selected"
|
||||||
|
@ -71,111 +71,36 @@ Object {
|
||||||
class="woocommerce-search-list__list"
|
class="woocommerce-search-list__list"
|
||||||
>
|
>
|
||||||
<li>
|
<li>
|
||||||
<label
|
<div
|
||||||
class=" woocommerce-search-list__item depth-0"
|
class="woocommerce-search-list__item depth-0 has-children"
|
||||||
for="search-list-item-11-1"
|
role="treeitem"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="components-base-control components-checkbox-control woocommerce-search-list__item-input css-wdf2ti-Wrapper e1puf3u3"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="components-base-control__field css-igk9ll-StyledField e1puf3u2"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="components-checkbox-control__input-container"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
class="woocommerce-search-list__item-input"
|
class="components-checkbox-control__input"
|
||||||
id="search-list-item-11-1"
|
id="inspector-checkbox-control-0"
|
||||||
name="search-list-item-11"
|
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
value=""
|
value="1"
|
||||||
/>
|
/>
|
||||||
<span
|
</span>
|
||||||
class="woocommerce-search-list__item-label"
|
<label
|
||||||
>
|
class="components-checkbox-control__label"
|
||||||
<span
|
for="inspector-checkbox-control-0"
|
||||||
class="woocommerce-search-list__item-name"
|
|
||||||
>
|
>
|
||||||
Apricots
|
Apricots
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<label
|
|
||||||
class=" woocommerce-search-list__item depth-1"
|
|
||||||
for="search-list-item-11-2"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
class="woocommerce-search-list__item-input"
|
|
||||||
id="search-list-item-11-2"
|
|
||||||
name="search-list-item-11"
|
|
||||||
type="checkbox"
|
|
||||||
value=""
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
class="woocommerce-search-list__item-label"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="woocommerce-search-list__item-prefix"
|
|
||||||
>
|
|
||||||
Apricots
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="woocommerce-search-list__item-name"
|
|
||||||
>
|
|
||||||
Clementine
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<label
|
|
||||||
class=" woocommerce-search-list__item depth-1"
|
|
||||||
for="search-list-item-11-3"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
class="woocommerce-search-list__item-input"
|
|
||||||
id="search-list-item-11-3"
|
|
||||||
name="search-list-item-11"
|
|
||||||
type="checkbox"
|
|
||||||
value=""
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
class="woocommerce-search-list__item-label"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="woocommerce-search-list__item-prefix"
|
|
||||||
>
|
|
||||||
Apricots
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="woocommerce-search-list__item-name"
|
|
||||||
>
|
|
||||||
Elderberry
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<label
|
|
||||||
class=" woocommerce-search-list__item depth-2"
|
|
||||||
for="search-list-item-11-4"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
class="woocommerce-search-list__item-input"
|
|
||||||
id="search-list-item-11-4"
|
|
||||||
name="search-list-item-11"
|
|
||||||
type="checkbox"
|
|
||||||
value=""
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
class="woocommerce-search-list__item-label"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="woocommerce-search-list__item-prefix"
|
|
||||||
>
|
|
||||||
Apricots › Elderberry
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="woocommerce-search-list__item-name"
|
|
||||||
>
|
|
||||||
Guava
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<label
|
<label
|
||||||
|
@ -229,7 +154,7 @@ Object {
|
||||||
</body>,
|
</body>,
|
||||||
"container": <div>
|
"container": <div>
|
||||||
<div
|
<div
|
||||||
class="woocommerce-search-list"
|
class="woocommerce-search-list is-compact"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="woocommerce-search-list__selected"
|
class="woocommerce-search-list__selected"
|
||||||
|
@ -270,111 +195,36 @@ Object {
|
||||||
class="woocommerce-search-list__list"
|
class="woocommerce-search-list__list"
|
||||||
>
|
>
|
||||||
<li>
|
<li>
|
||||||
<label
|
<div
|
||||||
class=" woocommerce-search-list__item depth-0"
|
class="woocommerce-search-list__item depth-0 has-children"
|
||||||
for="search-list-item-11-1"
|
role="treeitem"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="components-base-control components-checkbox-control woocommerce-search-list__item-input css-wdf2ti-Wrapper e1puf3u3"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="components-base-control__field css-igk9ll-StyledField e1puf3u2"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="components-checkbox-control__input-container"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
class="woocommerce-search-list__item-input"
|
class="components-checkbox-control__input"
|
||||||
id="search-list-item-11-1"
|
id="inspector-checkbox-control-0"
|
||||||
name="search-list-item-11"
|
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
value=""
|
value="1"
|
||||||
/>
|
/>
|
||||||
<span
|
</span>
|
||||||
class="woocommerce-search-list__item-label"
|
<label
|
||||||
>
|
class="components-checkbox-control__label"
|
||||||
<span
|
for="inspector-checkbox-control-0"
|
||||||
class="woocommerce-search-list__item-name"
|
|
||||||
>
|
>
|
||||||
Apricots
|
Apricots
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<label
|
|
||||||
class=" woocommerce-search-list__item depth-1"
|
|
||||||
for="search-list-item-11-2"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
class="woocommerce-search-list__item-input"
|
|
||||||
id="search-list-item-11-2"
|
|
||||||
name="search-list-item-11"
|
|
||||||
type="checkbox"
|
|
||||||
value=""
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
class="woocommerce-search-list__item-label"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="woocommerce-search-list__item-prefix"
|
|
||||||
>
|
|
||||||
Apricots
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="woocommerce-search-list__item-name"
|
|
||||||
>
|
|
||||||
Clementine
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<label
|
|
||||||
class=" woocommerce-search-list__item depth-1"
|
|
||||||
for="search-list-item-11-3"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
class="woocommerce-search-list__item-input"
|
|
||||||
id="search-list-item-11-3"
|
|
||||||
name="search-list-item-11"
|
|
||||||
type="checkbox"
|
|
||||||
value=""
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
class="woocommerce-search-list__item-label"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="woocommerce-search-list__item-prefix"
|
|
||||||
>
|
|
||||||
Apricots
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="woocommerce-search-list__item-name"
|
|
||||||
>
|
|
||||||
Elderberry
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<label
|
|
||||||
class=" woocommerce-search-list__item depth-2"
|
|
||||||
for="search-list-item-11-4"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
class="woocommerce-search-list__item-input"
|
|
||||||
id="search-list-item-11-4"
|
|
||||||
name="search-list-item-11"
|
|
||||||
type="checkbox"
|
|
||||||
value=""
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
class="woocommerce-search-list__item-label"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="woocommerce-search-list__item-prefix"
|
|
||||||
>
|
|
||||||
Apricots › Elderberry
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="woocommerce-search-list__item-name"
|
|
||||||
>
|
|
||||||
Guava
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<label
|
<label
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
|
@ -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[] };
|
||||||
|
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
|
@ -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 ];
|
||||||
|
}
|
|
@ -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.
|
||||||
*
|
*
|
||||||
|
|
|
@ -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';
|
||||||
|
|
Loading…
Reference in New Issue