diff --git a/assets/js/base/context/hooks/tsconfig.json b/assets/js/base/context/hooks/tsconfig.json index da9dfb458fc..1c84e4557f6 100644 --- a/assets/js/base/context/hooks/tsconfig.json +++ b/assets/js/base/context/hooks/tsconfig.json @@ -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/**" ] } diff --git a/assets/js/base/context/hooks/use-product-attributes.ts b/assets/js/base/context/hooks/use-product-attributes.ts new file mode 100644 index 00000000000..be1e5e0f24a --- /dev/null +++ b/assets/js/base/context/hooks/use-product-attributes.ts @@ -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, + }; +} diff --git a/assets/js/blocks/attribute-filter/edit.tsx b/assets/js/blocks/attribute-filter/edit.tsx index da2138d2ea4..346d7551ad0 100644 --- a/assets/js/blocks/attribute-filter/edit.tsx +++ b/assets/js/blocks/attribute-filter/edit.tsx @@ -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; } diff --git a/assets/js/blocks/attribute-filter/preview.tsx b/assets/js/blocks/attribute-filter/preview.tsx index 2007a249273..4bf05ac7795 100644 --- a/assets/js/blocks/attribute-filter/preview.tsx +++ b/assets/js/blocks/attribute-filter/preview.tsx @@ -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, + label: 'Preview', name: 'preview', + order: 'menu_order', + parent: 0, taxonomy: 'preview', - label: 'Preview', + type: '', }; diff --git a/assets/js/blocks/product-query/constants.ts b/assets/js/blocks/product-query/constants.ts index e10dbb7ebc3..13637e26087 100644 --- a/assets/js/blocks/product-query/constants.ts +++ b/assets/js/blocks/product-query/constants.ts @@ -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' ]; diff --git a/assets/js/blocks/product-query/inspector-controls/attributes-filter.tsx b/assets/js/blocks/product-query/inspector-controls/attributes-filter.tsx index 2ef1b645465..a3aeabf0852 100644 --- a/assets/js/blocks/product-query/inspector-controls/attributes-filter.tsx +++ b/assets/js/blocks/product-query/inspector-controls/attributes-filter.tsx @@ -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 attributesSuggestions = productsAttributes.reduce( ( acc, curr ) => { - const namespacedTerms = curr.terms.map( - ( term ) => `${ curr.attribute_label }: ${ term.name }` - ); - - return [ ...acc, ...namespacedTerms ]; - }, [] as string[] ); + const [ selected, setSelected ] = useState< { id: number }[] >( [] ); + + useEffect( () => { + if ( query.__woocommerceAttributes ) { + setSelected( + query.__woocommerceAttributes.map( ( { termId: id } ) => ( { + id, + } ) ) + ); + } + }, [ query.__woocommerceAttributes ] ); return ( query.__woocommerceAttributes?.length } > - { - 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. - } + const __woocommerceAttributes = attributes.map( + // eslint-disable-next-line @typescript-eslint/naming-convention + ( { id, value } ) => ( { + termId: id, + taxonomy: value, + } ) + ); + + setQueryAttribute( props, { + __woocommerceAttributes, + } ); } } - suggestions={ attributesSuggestions } - validateInput={ ( value: string ) => - attributesSuggestions.includes( value ) - } - value={ - isLoadingAttributes - ? [ __( 'Loading…', 'woo-gutenberg-products-block' ) ] - : getInputValueFromQueryParam( - query.__woocommerceAttributes, - productsAttributes - ) - } - __experimentalExpandOnFocus={ true } + operator={ 'any' } + isCompact={ true } + type={ 'token' } /> + + { __( 'Manage attributes', 'woo-gutenberg-products-block' ) } + ); }; diff --git a/assets/js/blocks/product-query/style.scss b/assets/js/blocks/product-query/style.scss index 0c345569197..4e473a1b1d9 100644 --- a/assets/js/blocks/product-query/style.scss +++ b/assets/js/blocks/product-query/style.scss @@ -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; + } +} diff --git a/assets/js/blocks/product-query/types.ts b/assets/js/blocks/product-query/types.ts index cbee9c48cc8..0cfbf6fcfe1 100644 --- a/assets/js/blocks/product-query/types.ts +++ b/assets/js/blocks/product-query/types.ts @@ -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 diff --git a/assets/js/blocks/product-query/useProductAttributes.ts b/assets/js/blocks/product-query/useProductAttributes.ts deleted file mode 100644 index b2da1786109..00000000000 --- a/assets/js/blocks/product-query/useProductAttributes.ts +++ /dev/null @@ -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 }; -} diff --git a/assets/js/editor-components/error-placeholder/index.tsx b/assets/js/editor-components/error-placeholder/index.tsx index 3a9828e5562..ff363b93e86 100644 --- a/assets/js/editor-components/error-placeholder/index.tsx +++ b/assets/js/editor-components/error-placeholder/index.tsx @@ -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 { diff --git a/assets/js/editor-components/expandable-search-list-item/expandable-search-list-item.tsx b/assets/js/editor-components/expandable-search-list-item/expandable-search-list-item.tsx index 79f71c61e2c..8ca4231011c 100644 --- a/assets/js/editor-components/expandable-search-list-item/expandable-search-list-item.tsx +++ b/assets/js/editor-components/expandable-search-list-item/expandable-search-list-item.tsx @@ -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 && ( diff --git a/assets/js/editor-components/product-attribute-term-control/index.js b/assets/js/editor-components/product-attribute-term-control/index.tsx similarity index 67% rename from assets/js/editor-components/product-attribute-term-control/index.js rename to assets/js/editor-components/product-attribute-term-control/index.tsx index fae36c0db18..5f6f515926a 100644 --- a/assets/js/editor-components/product-attribute-term-control/index.js +++ b/assets/js/editor-components/product-attribute-term-control/index.tsx @@ -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 ( { - 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; + + return [ + ...acc, + convertAttributeObjectToSearchItem( props ), + ...terms.map( convertAttributeObjectToSearchItem ), + ]; + }, [] as SearchListItemProps[] ); - const messages = { + 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 ; + if ( errorLoadingAttributes ) { + return ; } return ( <> - 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 && (