Skip to content
This repository has been archived by the owner on Feb 23, 2024. It is now read-only.

Commit

Permalink
Improve Products block Attributes Filter Inspector Controls (#8583)
Browse files Browse the repository at this point in the history
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`
  • Loading branch information
sunyatasattva authored Mar 8, 2023
1 parent cb8b1ed commit dab5622
Show file tree
Hide file tree
Showing 25 changed files with 814 additions and 749 deletions.
3 changes: 2 additions & 1 deletion assets/js/base/context/hooks/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/**" ]
}
79 changes: 79 additions & 0 deletions assets/js/base/context/hooks/use-product-attributes.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
6 changes: 3 additions & 3 deletions assets/js/blocks/attribute-filter/edit.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/**
* External dependencies
*/
import { sortBy } from 'lodash';
import { __, sprintf, _n } from '@wordpress/i18n';
import { useState } from '@wordpress/element';
import {
Expand All @@ -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,
Expand Down Expand Up @@ -103,7 +103,7 @@ const Edit = ( {
);
};

const onChange = ( selected: SearchListItemsType ) => {
const onChange = ( selected: SearchListItem[] ) => {
if ( ! selected || ! selected.length ) {
return;
}
Expand Down
10 changes: 8 additions & 2 deletions assets/js/blocks/attribute-filter/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* External dependencies
*/
import Label from '@woocommerce/base-components/filter-element-label';
import { AttributeObject } from '@woocommerce/types';

export const previewOptions = [
{
Expand All @@ -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: '',
};
3 changes: 3 additions & 0 deletions assets/js/blocks/product-query/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' ];
Expand Down
169 changes: 41 additions & 128 deletions assets/js/blocks/product-query/inspector-controls/attributes-filter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,155 +2,68 @@
* 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';

/**
* 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 (
<ToolsPanelItem
label={ __( 'Product Attributes', 'woo-gutenberg-products-block' ) }
hasValue={ () => query.__woocommerceAttributes?.length }
>
<FormTokenField
disabled={ isLoadingAttributes }
label={ __(
'Product Attributes',
'woo-gutenberg-products-block'
) }
<ProductAttributeTermControl
messages={ {
search: __( 'Attributes', 'woo-gutenberg-products-block' ),
} }
selected={ selected }
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.
}
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' }
/>
<ExternalLink
className="woocommerce-product-query-panel__external-link"
href={ EDIT_ATTRIBUTES_URL }
>
{ __( 'Manage attributes', 'woo-gutenberg-products-block' ) }
</ExternalLink>
</ToolsPanelItem>
);
};
9 changes: 9 additions & 0 deletions assets/js/blocks/product-query/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
13 changes: 1 addition & 12 deletions assets/js/blocks/product-query/types.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading

0 comments on commit dab5622

Please sign in to comment.