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",
|
||||
"../providers/cart-checkout/checkout-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/**" ]
|
||||
}
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
import { sortBy } from 'lodash';
|
||||
import { __, sprintf, _n } from '@wordpress/i18n';
|
||||
import { useState } from '@wordpress/element';
|
||||
import {
|
||||
|
@ -10,11 +11,10 @@ import {
|
|||
} from '@wordpress/block-editor';
|
||||
import { Icon, category, external } from '@wordpress/icons';
|
||||
import { SearchListControl } from '@woocommerce/editor-components/search-list-control';
|
||||
import { sortBy } from 'lodash';
|
||||
import { getAdminLink, getSetting } from '@woocommerce/settings';
|
||||
import BlockTitle from '@woocommerce/editor-components/block-title';
|
||||
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 {
|
||||
Placeholder,
|
||||
|
@ -103,7 +103,7 @@ const Edit = ( {
|
|||
);
|
||||
};
|
||||
|
||||
const onChange = ( selected: SearchListItemsType ) => {
|
||||
const onChange = ( selected: SearchListItem[] ) => {
|
||||
if ( ! selected || ! selected.length ) {
|
||||
return;
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
* External dependencies
|
||||
*/
|
||||
import Label from '@woocommerce/base-components/filter-element-label';
|
||||
import { AttributeObject } from '@woocommerce/types';
|
||||
|
||||
export const previewOptions = [
|
||||
{
|
||||
|
@ -27,9 +28,14 @@ export const previewOptions = [
|
|||
},
|
||||
];
|
||||
|
||||
export const previewAttributeObject = {
|
||||
export const previewAttributeObject: AttributeObject = {
|
||||
count: 0,
|
||||
has_archives: true,
|
||||
id: 0,
|
||||
name: 'preview',
|
||||
taxonomy: '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;
|
||||
}
|
||||
|
||||
export const EDIT_ATTRIBUTES_URL =
|
||||
'/wp-admin/edit.php?post_type=product&page=product_attributes';
|
||||
|
||||
export const QUERY_LOOP_ID = 'core/query';
|
||||
|
||||
export const DEFAULT_CORE_ALLOWED_CONTROLS = [ 'taxQuery', 'search' ];
|
||||
|
|
|
@ -2,8 +2,10 @@
|
|||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { useEffect, useState } from '@wordpress/element';
|
||||
import ProductAttributeTermControl from '@woocommerce/editor-components/product-attribute-term-control';
|
||||
import {
|
||||
FormTokenField,
|
||||
ExternalLink,
|
||||
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
|
||||
__experimentalToolsPanelItem as ToolsPanelItem,
|
||||
} from '@wordpress/components';
|
||||
|
@ -11,146 +13,57 @@ import {
|
|||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import {
|
||||
AttributeMetadata,
|
||||
AttributeWithTerms,
|
||||
ProductQueryBlock,
|
||||
} from '../types';
|
||||
import useProductAttributes from '../useProductAttributes';
|
||||
import { ProductQueryBlock } from '../types';
|
||||
import { setQueryAttribute } from '../utils';
|
||||
|
||||
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 }`;
|
||||
} ) || []
|
||||
);
|
||||
}
|
||||
import { EDIT_ATTRIBUTES_URL } from '../constants';
|
||||
|
||||
export const AttributesFilter = ( props: ProductQueryBlock ) => {
|
||||
const { query } = props.attributes;
|
||||
const { isLoadingAttributes, productsAttributes } =
|
||||
useProductAttributes( true );
|
||||
const [ selected, setSelected ] = useState< { id: number }[] >( [] );
|
||||
|
||||
const attributesSuggestions = productsAttributes.reduce( ( acc, curr ) => {
|
||||
const namespacedTerms = curr.terms.map(
|
||||
( term ) => `${ curr.attribute_label }: ${ term.name }`
|
||||
);
|
||||
|
||||
return [ ...acc, ...namespacedTerms ];
|
||||
}, [] as string[] );
|
||||
useEffect( () => {
|
||||
if ( query.__woocommerceAttributes ) {
|
||||
setSelected(
|
||||
query.__woocommerceAttributes.map( ( { termId: id } ) => ( {
|
||||
id,
|
||||
} ) )
|
||||
);
|
||||
}
|
||||
}, [ query.__woocommerceAttributes ] );
|
||||
|
||||
return (
|
||||
<ToolsPanelItem
|
||||
label={ __( 'Product Attributes', 'woo-gutenberg-products-block' ) }
|
||||
hasValue={ () => query.__woocommerceAttributes?.length }
|
||||
>
|
||||
<FormTokenField
|
||||
disabled={ isLoadingAttributes }
|
||||
label={ __(
|
||||
'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.
|
||||
}
|
||||
<ProductAttributeTermControl
|
||||
messages={ {
|
||||
search: __( 'Attributes', 'woo-gutenberg-products-block' ),
|
||||
} }
|
||||
suggestions={ attributesSuggestions }
|
||||
validateInput={ ( value: string ) =>
|
||||
attributesSuggestions.includes( value )
|
||||
}
|
||||
value={
|
||||
isLoadingAttributes
|
||||
? [ __( 'Loading…', 'woo-gutenberg-products-block' ) ]
|
||||
: getInputValueFromQueryParam(
|
||||
query.__woocommerceAttributes,
|
||||
productsAttributes
|
||||
)
|
||||
}
|
||||
__experimentalExpandOnFocus={ true }
|
||||
selected={ selected }
|
||||
onChange={ ( attributes ) => {
|
||||
const __woocommerceAttributes = attributes.map(
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
( { id, value } ) => ( {
|
||||
termId: id,
|
||||
taxonomy: value,
|
||||
} )
|
||||
);
|
||||
|
||||
setQueryAttribute( props, {
|
||||
__woocommerceAttributes,
|
||||
} );
|
||||
} }
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -3,3 +3,12 @@
|
|||
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
|
||||
*/
|
||||
import type {
|
||||
AttributeSetting,
|
||||
AttributeTerm,
|
||||
EditorBlock,
|
||||
} from '@woocommerce/types';
|
||||
|
||||
export interface AttributeMetadata {
|
||||
taxonomy: string;
|
||||
termId: number;
|
||||
}
|
||||
|
||||
export type AttributeWithTerms = AttributeSetting & { terms: AttributeTerm[] };
|
||||
import type { AttributeMetadata, EditorBlock } from '@woocommerce/types';
|
||||
|
||||
// The interface below disables the forbidden underscores
|
||||
// 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.
|
||||
*/
|
||||
type: 'api' | 'general';
|
||||
type: 'api' | 'general' | string;
|
||||
}
|
||||
|
||||
export interface ErrorPlaceholderProps {
|
||||
|
|
|
@ -1,21 +1,13 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { SearchListItem } from '@woocommerce/editor-components/search-list-control';
|
||||
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';
|
||||
|
||||
interface SearchListItem {
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface ExpandableSearchListItemProps {
|
||||
className?: string;
|
||||
item: SearchListItem;
|
||||
isSelected: boolean;
|
||||
interface ExpandableSearchListItemProps extends renderItemArgs {
|
||||
isLoading: boolean;
|
||||
onSelect: () => void;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
const ExpandableSearchListItem = ( {
|
||||
|
@ -36,7 +28,6 @@ const ExpandableSearchListItem = ( {
|
|||
isSelected={ isSelected }
|
||||
item={ item }
|
||||
onSelect={ onSelect }
|
||||
isSingle
|
||||
disabled={ disabled }
|
||||
/>
|
||||
{ isSelected && isLoading && (
|
||||
|
|
|
@ -1,40 +1,60 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import classNames from 'classnames';
|
||||
import { __, _n, sprintf } from '@wordpress/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
SearchListControl,
|
||||
SearchListItem,
|
||||
} from '@woocommerce/editor-components/search-list-control';
|
||||
import { SelectControl } from '@wordpress/components';
|
||||
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 classNames from 'classnames';
|
||||
import ExpandableSearchListItem from '@woocommerce/editor-components/expandable-search-list-item/expandable-search-list-item.tsx';
|
||||
import ExpandableSearchListItem from '@woocommerce/editor-components/expandable-search-list-item/expandable-search-list-item';
|
||||
import {
|
||||
renderItemArgs,
|
||||
SearchListControlProps,
|
||||
SearchListItem as SearchListItemProps,
|
||||
} from '@woocommerce/editor-components/search-list-control/types';
|
||||
import { convertAttributeObjectToSearchItem } from '@woocommerce/utils';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
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 = ( {
|
||||
attributes,
|
||||
error,
|
||||
expandedAttribute,
|
||||
onChange,
|
||||
onExpandAttribute,
|
||||
onOperatorChange,
|
||||
instanceId,
|
||||
isCompact,
|
||||
isLoading,
|
||||
operator,
|
||||
isCompact = false,
|
||||
messages = {},
|
||||
operator = 'any',
|
||||
selected,
|
||||
termsAreLoading,
|
||||
termsList,
|
||||
} ) => {
|
||||
const renderItem = ( args ) => {
|
||||
type = 'text',
|
||||
}: Props ) => {
|
||||
const { errorLoadingAttributes, isLoadingAttributes, productsAttributes } =
|
||||
useProductAttributes( true );
|
||||
|
||||
const renderItem = ( args: renderItemArgs ) => {
|
||||
const { item, search, depth = 0 } = args;
|
||||
const classes = [
|
||||
'woocommerce-product-attributes__item',
|
||||
|
@ -46,23 +66,13 @@ const ProductAttributeTermControl = ( {
|
|||
];
|
||||
|
||||
if ( ! item.breadcrumbs.length ) {
|
||||
const isSelected = expandedAttribute === item.id;
|
||||
return (
|
||||
<ExpandableSearchListItem
|
||||
{ ...args }
|
||||
className={ classNames( ...classes, {
|
||||
'is-selected': isSelected,
|
||||
} ) }
|
||||
isSelected={ isSelected }
|
||||
className={ classNames( classes ) }
|
||||
item={ item }
|
||||
isLoading={ termsAreLoading }
|
||||
disabled={ item.count === '0' }
|
||||
onSelect={ ( { id } ) => {
|
||||
return () => {
|
||||
onChange( [] );
|
||||
onExpandAttribute( id );
|
||||
};
|
||||
} }
|
||||
isLoading={ isLoadingAttributes }
|
||||
disabled={ item.count === 0 }
|
||||
name={ `attributes-${ instanceId }` }
|
||||
countLabel={ sprintf(
|
||||
/* translators: %d is the count of terms. */
|
||||
|
@ -121,15 +131,21 @@ const ProductAttributeTermControl = ( {
|
|||
);
|
||||
};
|
||||
|
||||
const currentTerms = termsList[ expandedAttribute ] || [];
|
||||
const currentList = [ ...attributes, ...currentTerms ];
|
||||
const list = productsAttributes.reduce( ( acc, curr ) => {
|
||||
const { terms, ...props } = curr;
|
||||
|
||||
const messages = {
|
||||
return [
|
||||
...acc,
|
||||
convertAttributeObjectToSearchItem( props ),
|
||||
...terms.map( convertAttributeObjectToSearchItem ),
|
||||
];
|
||||
}, [] as SearchListItemProps[] );
|
||||
|
||||
messages = {
|
||||
clear: __(
|
||||
'Clear all product attributes',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
list: __( 'Product Attributes', 'woo-gutenberg-products-block' ),
|
||||
noItems: __(
|
||||
"Your store doesn't have any product attributes.",
|
||||
'woo-gutenberg-products-block'
|
||||
|
@ -138,7 +154,7 @@ const ProductAttributeTermControl = ( {
|
|||
'Search for product attributes',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
selected: ( n ) =>
|
||||
selected: ( n: number ) =>
|
||||
sprintf(
|
||||
/* translators: %d is the count of attributes selected. */
|
||||
_n(
|
||||
|
@ -153,30 +169,33 @@ const ProductAttributeTermControl = ( {
|
|||
'Product attribute search results updated.',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
...messages,
|
||||
};
|
||||
|
||||
if ( error ) {
|
||||
return <ErrorMessage error={ error } />;
|
||||
if ( errorLoadingAttributes ) {
|
||||
return <ErrorMessage error={ errorLoadingAttributes } />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SearchListControl
|
||||
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 }
|
||||
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 && (
|
||||
<div hidden={ selected.length < 2 }>
|
||||
|
@ -215,37 +234,4 @@ const ProductAttributeTermControl = ( {
|
|||
);
|
||||
};
|
||||
|
||||
ProductAttributeTermControl.propTypes = {
|
||||
/**
|
||||
* 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 ) );
|
||||
export default withInstanceId( ProductAttributeTermControl );
|
|
@ -32,25 +32,4 @@
|
|||
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
|
||||
*/
|
||||
import type { renderItemArgs } from './types';
|
||||
import type {
|
||||
renderItemArgs,
|
||||
SearchListItem as SearchListItemProps,
|
||||
} from './types';
|
||||
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 = ( {
|
||||
countLabel,
|
||||
className,
|
||||
|
@ -14,27 +49,115 @@ export const SearchListItem = ( {
|
|||
isSingle,
|
||||
onSelect,
|
||||
search = '',
|
||||
selected,
|
||||
useExpandedPanelId,
|
||||
...props
|
||||
}: renderItemArgs ): JSX.Element => {
|
||||
const [ expandedPanelId, setExpandedPanelId ] = useExpandedPanelId;
|
||||
const showCount =
|
||||
countLabel !== undefined &&
|
||||
countLabel !== null &&
|
||||
item.count !== undefined &&
|
||||
item.count !== null;
|
||||
const classes = [ className, 'woocommerce-search-list__item' ];
|
||||
classes.push( `depth-${ depth }` );
|
||||
if ( isSingle ) {
|
||||
classes.push( 'is-radio-button' );
|
||||
}
|
||||
if ( showCount ) {
|
||||
classes.push( 'has-count' );
|
||||
}
|
||||
const hasBreadcrumbs = item.breadcrumbs && item.breadcrumbs.length;
|
||||
const hasBreadcrumbs = !! item.breadcrumbs?.length;
|
||||
const hasChildren = !! item.children?.length;
|
||||
const isExpanded = expandedPanelId === item.id;
|
||||
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,
|
||||
}
|
||||
);
|
||||
|
||||
const name = props.name || `search-list-item-${ controlId }`;
|
||||
const id = `${ name }-${ item.id }`;
|
||||
|
||||
return (
|
||||
<label htmlFor={ id } className={ classes.join( ' ' ) }>
|
||||
const togglePanel = useCallback( () => {
|
||||
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 ? (
|
||||
<input
|
||||
type="radio"
|
||||
|
@ -59,22 +182,9 @@ export const SearchListItem = ( {
|
|||
></input>
|
||||
) }
|
||||
|
||||
<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>
|
||||
<ItemLabel item={ item } search={ search } />
|
||||
|
||||
{ !! showCount && (
|
||||
<span className="woocommerce-search-list__item-count">
|
||||
{ countLabel || item.count }
|
||||
</span>
|
||||
) }
|
||||
{ showCount ? <Count label={ countLabel || item.count } /> : null }
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
import { __, sprintf } from '@wordpress/i18n';
|
||||
import {
|
||||
Button,
|
||||
FormTokenField,
|
||||
Spinner,
|
||||
TextControl,
|
||||
withSpokenMessages,
|
||||
|
@ -26,27 +27,19 @@ import { getFilteredList, defaultMessages } from './utils';
|
|||
import SearchListItem from './item';
|
||||
import Tag from '../tag';
|
||||
import type {
|
||||
SearchListItemsType,
|
||||
SearchListItemType,
|
||||
SearchListItem as SearchListItemProps,
|
||||
SearchListControlProps,
|
||||
SearchListMessages,
|
||||
renderItemArgs,
|
||||
ListItemsProps,
|
||||
SearchListItemsContainerProps,
|
||||
} from './types';
|
||||
|
||||
const defaultRenderListItem = ( args: renderItemArgs ): JSX.Element => {
|
||||
return <SearchListItem { ...args } />;
|
||||
};
|
||||
|
||||
const ListItems = ( props: {
|
||||
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 ListItems = ( props: ListItemsProps ): JSX.Element | null => {
|
||||
const {
|
||||
list,
|
||||
selected,
|
||||
|
@ -56,7 +49,11 @@ const ListItems = ( props: {
|
|||
instanceId,
|
||||
isSingle,
|
||||
search,
|
||||
useExpandedPanelId,
|
||||
} = props;
|
||||
|
||||
const [ expandedPanelId ] = useExpandedPanelId;
|
||||
|
||||
if ( ! list ) {
|
||||
return null;
|
||||
}
|
||||
|
@ -64,7 +61,16 @@ const ListItems = ( props: {
|
|||
<>
|
||||
{ list.map( ( item ) => {
|
||||
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 (
|
||||
<Fragment key={ item.id }>
|
||||
<li>
|
||||
|
@ -73,16 +79,20 @@ const ListItems = ( props: {
|
|||
isSelected,
|
||||
onSelect,
|
||||
isSingle,
|
||||
selected,
|
||||
search,
|
||||
depth,
|
||||
useExpandedPanelId,
|
||||
controlId: instanceId,
|
||||
} ) }
|
||||
</li>
|
||||
<ListItems
|
||||
{ ...props }
|
||||
list={ item.children }
|
||||
depth={ depth + 1 }
|
||||
/>
|
||||
{ isExpanded ? (
|
||||
<ListItems
|
||||
{ ...props }
|
||||
list={ item.children as SearchListItemProps[] }
|
||||
depth={ depth + 1 }
|
||||
/>
|
||||
) : null }
|
||||
</Fragment>
|
||||
);
|
||||
} ) }
|
||||
|
@ -142,14 +152,9 @@ const ListItemsContainer = ( {
|
|||
search,
|
||||
onSelect,
|
||||
instanceId,
|
||||
useExpandedPanelId,
|
||||
...props
|
||||
}: SearchListControlProps & {
|
||||
messages: SearchListMessages;
|
||||
filteredList: SearchListItemsType;
|
||||
search: string;
|
||||
instanceId: string | number;
|
||||
onSelect: ( item: SearchListItemType ) => () => void;
|
||||
} ) => {
|
||||
}: SearchListItemsContainerProps ) => {
|
||||
const { messages, renderItem, selected, isSingle } = props;
|
||||
const renderItemCallback = renderItem || defaultRenderListItem;
|
||||
|
||||
|
@ -172,6 +177,7 @@ const ListItemsContainer = ( {
|
|||
return (
|
||||
<ul className="woocommerce-search-list__list">
|
||||
<ListItems
|
||||
useExpandedPanelId={ useExpandedPanelId }
|
||||
list={ filteredList }
|
||||
selected={ selected }
|
||||
renderItem={ renderItemCallback }
|
||||
|
@ -187,9 +193,7 @@ const ListItemsContainer = ( {
|
|||
/**
|
||||
* Component to display a searchable, selectable list of items.
|
||||
*/
|
||||
export const SearchListControl = (
|
||||
props: SearchListControlProps
|
||||
): JSX.Element => {
|
||||
export const SearchListControl = ( props: SearchListControlProps ) => {
|
||||
const {
|
||||
className = '',
|
||||
isCompact,
|
||||
|
@ -201,9 +205,12 @@ export const SearchListControl = (
|
|||
onChange,
|
||||
onSearch,
|
||||
selected,
|
||||
type = 'text',
|
||||
debouncedSpeak,
|
||||
} = props;
|
||||
|
||||
const [ search, setSearch ] = useState( '' );
|
||||
const useExpandedPanelId = useState< number >( -1 );
|
||||
const instanceId = useInstanceId( SearchListControl );
|
||||
const messages = useMemo(
|
||||
() => ( { ...defaultMessages, ...customMessages } ),
|
||||
|
@ -242,7 +249,12 @@ export const SearchListControl = (
|
|||
);
|
||||
|
||||
const onSelect = useCallback(
|
||||
( item: SearchListItemType ) => () => {
|
||||
( item: SearchListItemProps | SearchListItemProps[] ) => () => {
|
||||
if ( Array.isArray( item ) ) {
|
||||
onChange( item );
|
||||
return;
|
||||
}
|
||||
|
||||
if ( selected.findIndex( ( { id } ) => id === item.id ) !== -1 ) {
|
||||
onRemove( item.id )();
|
||||
return;
|
||||
|
@ -256,27 +268,69 @@ export const SearchListControl = (
|
|||
[ 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 (
|
||||
<div
|
||||
className={ classnames( 'woocommerce-search-list', className, {
|
||||
'is-compact': isCompact,
|
||||
'is-loading': isLoading,
|
||||
'is-token': type === 'token',
|
||||
} ) }
|
||||
>
|
||||
<SelectedListItems
|
||||
{ ...props }
|
||||
onRemove={ onRemove }
|
||||
messages={ messages }
|
||||
/>
|
||||
<div className="woocommerce-search-list__search">
|
||||
<TextControl
|
||||
label={ messages.search }
|
||||
type="search"
|
||||
value={ search }
|
||||
onChange={ ( value ) => setSearch( value ) }
|
||||
{ type === 'text' && (
|
||||
<SelectedListItems
|
||||
{ ...props }
|
||||
onRemove={ onRemove }
|
||||
messages={ messages }
|
||||
/>
|
||||
) }
|
||||
<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>
|
||||
{ isLoading ? (
|
||||
<div className="woocommerce-search-list__list is-loading">
|
||||
<div className="woocommerce-search-list__list">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : (
|
||||
|
@ -287,6 +341,7 @@ export const SearchListControl = (
|
|||
messages={ messages }
|
||||
onSelect={ onSelect }
|
||||
instanceId={ instanceId }
|
||||
useExpandedPanelId={ useExpandedPanelId }
|
||||
/>
|
||||
) }
|
||||
</div>
|
||||
|
|
|
@ -2,6 +2,56 @@
|
|||
width: 100%;
|
||||
padding: 0 0 $gap;
|
||||
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 {
|
||||
|
@ -56,12 +106,6 @@
|
|||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&.is-loading {
|
||||
padding: $gap-small 0;
|
||||
text-align: center;
|
||||
border: none;
|
||||
}
|
||||
|
||||
&.is-not-found {
|
||||
padding: $gap-small 0;
|
||||
text-align: center;
|
||||
|
@ -117,6 +161,31 @@
|
|||
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 {
|
||||
margin: 0 $gap-smaller 0 0;
|
||||
}
|
||||
|
@ -151,6 +220,10 @@
|
|||
}
|
||||
|
||||
@for $i from 1 to 5 {
|
||||
&.depth-#{$i} {
|
||||
padding-left: $gap * ( $i + 1 );
|
||||
}
|
||||
|
||||
&.depth-#{$i} .woocommerce-search-list__item-label::before {
|
||||
content: str-repeat("— ", $i);
|
||||
}
|
||||
|
@ -201,6 +274,7 @@
|
|||
border-radius: 12px;
|
||||
font-size: 0.8em;
|
||||
line-height: 1.4;
|
||||
margin-left: auto;
|
||||
color: $gray-700;
|
||||
background: $studio-white;
|
||||
white-space: nowrap;
|
||||
|
@ -211,19 +285,3 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -206,11 +206,14 @@ describe( 'SearchListControl', () => {
|
|||
test( 'should render a search box and list of hierarchical options', () => {
|
||||
const component = render(
|
||||
<SearchListControl
|
||||
instanceId={ 1 }
|
||||
list={ hierarchicalList }
|
||||
selected={ [] }
|
||||
onChange={ noop }
|
||||
isCompact
|
||||
isHierarchical
|
||||
instanceId={ 1 }
|
||||
isSingle={ false }
|
||||
list={ hierarchicalList }
|
||||
onChange={ noop }
|
||||
selected={ [] }
|
||||
type={ 'text' }
|
||||
/>
|
||||
);
|
||||
expect( component ).toMatchSnapshot();
|
||||
|
|
|
@ -2,18 +2,55 @@
|
|||
* External dependencies
|
||||
*/
|
||||
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;
|
||||
name: string;
|
||||
value: string;
|
||||
parent: number;
|
||||
count: number;
|
||||
children: SearchListItemsType;
|
||||
breadcrumbs: string[];
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type SearchListItemsType = SearchListItemType[] | [];
|
||||
export interface SearchListItemsContainerProps
|
||||
extends SearchListControlProps,
|
||||
ItemProps {
|
||||
instanceId: string | number;
|
||||
filteredList: SearchListItem[];
|
||||
messages: SearchListMessages;
|
||||
}
|
||||
|
||||
export interface SearchListMessages {
|
||||
// A more detailed label for the "Clear all" button, read to screen reader users.
|
||||
|
@ -30,21 +67,19 @@ export interface SearchListMessages {
|
|||
updated: string;
|
||||
}
|
||||
|
||||
export interface renderItemArgs {
|
||||
export interface renderItemArgs extends ItemProps {
|
||||
// Additional CSS classes.
|
||||
className?: string;
|
||||
className?: string | undefined;
|
||||
// Whether the item is disable.
|
||||
disabled?: boolean | undefined;
|
||||
// Current item to display.
|
||||
item: SearchListItemType;
|
||||
item: SearchListItem;
|
||||
// Whether this item is selected.
|
||||
isSelected: boolean;
|
||||
// Callback for selecting the item.
|
||||
onSelect: ( item: SearchListItemType ) => () => void;
|
||||
// Whether this should only display a single item (controls radio vs checkbox icon).
|
||||
isSingle: boolean;
|
||||
// Search string, used to highlight the substring in the item name.
|
||||
search: string;
|
||||
// Depth, non-zero if the list is hierarchical.
|
||||
depth: number;
|
||||
// The list of currently selected items.
|
||||
selected: SearchListItem[];
|
||||
// Unique id of the parent control.
|
||||
controlId: string | number;
|
||||
// Label to display in the count bubble. Takes preference over `item.count`.
|
||||
|
@ -59,7 +94,7 @@ export interface renderItemArgs {
|
|||
|
||||
export interface SearchListControlProps {
|
||||
// Additional CSS classes.
|
||||
className: string;
|
||||
className?: string;
|
||||
// Whether it should be displayed in a compact way, so it occupies less space.
|
||||
isCompact: boolean;
|
||||
// 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.
|
||||
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: SearchListItemsType;
|
||||
list: SearchListItem[];
|
||||
// Messages displayed or read to the user. Configure these to reflect your object type.
|
||||
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).
|
||||
onChange: ( search: SearchListItemsType ) => void;
|
||||
onChange: ( search: SearchListItem[] ) => void;
|
||||
// Callback fired when the search field is used.
|
||||
onSearch?: ( search: string ) => void;
|
||||
// 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: SearchListItemsType;
|
||||
selected: SearchListItem[];
|
||||
// Whether to show a text field or a token field as search
|
||||
// Defaults to `'text'`
|
||||
type?: 'text' | 'token';
|
||||
// from withSpokenMessages
|
||||
debouncedSpeak?: ( message: string ) => void;
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@ import { __, _n, sprintf } from '@wordpress/i18n';
|
|||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import type { SearchListItemType, SearchListItemsType } from './types';
|
||||
import type { SearchListItem } from './types';
|
||||
|
||||
export const defaultMessages = {
|
||||
clear: __( 'Clear all selected items', 'woo-gutenberg-products-block' ),
|
||||
|
@ -39,14 +39,14 @@ export const defaultMessages = {
|
|||
* @return {Array} Array of terms in tree format.
|
||||
*/
|
||||
export const buildTermsTree = (
|
||||
filteredList: SearchListItemsType,
|
||||
filteredList: SearchListItem[],
|
||||
list = filteredList
|
||||
): SearchListItemType[] | [] => {
|
||||
): SearchListItem[] | [] => {
|
||||
const termsByParent = groupBy( filteredList, 'parent' );
|
||||
const listById = keyBy( list, 'id' );
|
||||
const builtParents = [ '0' ];
|
||||
|
||||
const getParentsName = ( term = {} as SearchListItemType ): string[] => {
|
||||
const getParentsName = ( term = {} as SearchListItem ): string[] => {
|
||||
if ( ! term.parent ) {
|
||||
return term.name ? [ term.name ] : [];
|
||||
}
|
||||
|
@ -55,11 +55,7 @@ export const buildTermsTree = (
|
|||
return [ ...parentName, term.name ];
|
||||
};
|
||||
|
||||
const fillWithChildren = (
|
||||
terms: SearchListItemType[]
|
||||
): ( SearchListItemType & {
|
||||
breadcrumbs: string[];
|
||||
} )[] => {
|
||||
const fillWithChildren = ( terms: SearchListItem[] ): SearchListItem[] => {
|
||||
return terms.map( ( term ) => {
|
||||
const children = termsByParent[ term.id ];
|
||||
builtParents.push( '' + term.id );
|
||||
|
@ -87,10 +83,10 @@ export const buildTermsTree = (
|
|||
};
|
||||
|
||||
export const getFilteredList = (
|
||||
list: SearchListItemsType,
|
||||
list: SearchListItem[],
|
||||
search: string,
|
||||
isHierarchical: boolean
|
||||
): SearchListItemType[] | [] => {
|
||||
isHierarchical?: boolean | undefined
|
||||
) => {
|
||||
if ( ! search ) {
|
||||
return isHierarchical ? buildTermsTree( list ) : list;
|
||||
}
|
||||
|
@ -100,7 +96,7 @@ export const getFilteredList = (
|
|||
);
|
||||
const filteredList = list
|
||||
.map( ( item ) => ( re.test( item.name ) ? item : false ) )
|
||||
.filter( Boolean ) as SearchListItemsType;
|
||||
.filter( Boolean ) as SearchListItem[];
|
||||
|
||||
return isHierarchical ? buildTermsTree( filteredList, list ) : filteredList;
|
||||
};
|
||||
|
|
|
@ -2,16 +2,21 @@ export interface AttributeSetting {
|
|||
attribute_id: string;
|
||||
attribute_name: string;
|
||||
attribute_label: string;
|
||||
attribute_type: string;
|
||||
attribute_orderby: 'menu_order' | 'name' | 'name_num' | 'id';
|
||||
attribute_public: 0 | 1;
|
||||
attribute_type: string;
|
||||
}
|
||||
|
||||
export interface AttributeObject {
|
||||
count: number;
|
||||
has_archives: boolean;
|
||||
id: number;
|
||||
name: string;
|
||||
taxonomy: string;
|
||||
label: string;
|
||||
name: string;
|
||||
order: 'menu_order' | 'name' | 'name_num' | 'id';
|
||||
parent: number;
|
||||
taxonomy: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface AttributeQuery {
|
||||
|
@ -21,6 +26,7 @@ export interface AttributeQuery {
|
|||
}
|
||||
|
||||
export interface AttributeTerm {
|
||||
attr_slug: string;
|
||||
count: number;
|
||||
description: string;
|
||||
id: number;
|
||||
|
@ -28,3 +34,10 @@ export interface AttributeTerm {
|
|||
parent: number;
|
||||
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 > &
|
||||
Pick< T, K >;
|
||||
|
||||
export type Require< T, K extends keyof T > = T & {
|
||||
[ P in K ]-?: T[ K ];
|
||||
};
|
||||
|
||||
export type HTMLElementEvent< T extends HTMLElement > = Event & {
|
||||
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
|
||||
*/
|
||||
import { SearchListItem } from '@woocommerce/editor-components/search-list-control/types';
|
||||
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', [] );
|
||||
|
||||
|
@ -27,7 +34,7 @@ const attributeSettingToObject = ( attribute: AttributeSetting ) => {
|
|||
* Format all attribute settings into objects.
|
||||
*/
|
||||
const attributeObjects = ATTRIBUTES.reduce(
|
||||
( acc: AttributeObject[], current ) => {
|
||||
( acc: Partial< AttributeObject >[], current ) => {
|
||||
const attributeObject = attributeSettingToObject( current );
|
||||
|
||||
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.
|
||||
*
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
export * from './array-operations';
|
||||
export * from './attributes-query';
|
||||
export * from './attributes';
|
||||
export * from './filters';
|
||||
|
|
Loading…
Reference in New Issue