The main aim of this PR was to convert the product-control component to TypeScript. However, since the component is coupled with many other parts (it is itself wrapped into 4 HOCs), many refactoring changes were needed to make the types work.

In particular:

* Add `convertProductResponseItemToSearchItem` utility function

The function allows to clean up the `ProductResponseItem` in order
to make its shape compatible with the `SearchListItem`, instead of
passing the entire object.

Because data from the object might need to be used in certain
circumstance (e.g. render functions), the data is now allowed to be
scoped within a `details` key.

This commit also converts `utils/products` to TypeScript.

* Fix `ProductResponseItem` type missing `slug`

The `slug` is returned by the API but didn't appear in the type def.

* Improve certain HOCs types

The `withSearchedProducts` and `withTransformSingleSelectToMultipleSelect`
HOCs are now typed in such a way that it is clear what incoming props
they expect and what they inject.

* Refactor `ProductAttrributeTermControl` for further type safety

Make sure that `count` is always a `number` as required by the
component types.

* Refactor `search-list-control.tsx` and `item.tsx`

The new `SearchItem` allows for the `details` key to be passed.
All the components are now aware of it.
This commit is contained in:
Lucio Giannotta 2023-08-29 08:01:31 +02:00 committed by GitHub
parent a898a5b5ee
commit 5c4a757b6e
13 changed files with 272 additions and 179 deletions

View File

@ -56,6 +56,7 @@ const ProductAttributeTermControl = ( {
const renderItem = ( args: renderItemArgs ) => {
const { item, search, depth = 0 } = args;
const count = item.count || 0;
const classes = [
'woocommerce-product-attributes__item',
'woocommerce-search-list__item',
@ -79,21 +80,21 @@ const ProductAttributeTermControl = ( {
_n(
'%d term',
'%d terms',
item.count,
count,
'woo-gutenberg-products-block'
),
item.count
count
) }
aria-label={ sprintf(
/* translators: %1$s is the item name, %2$d is the count of terms for the item. */
_n(
'%1$s, has %2$d term',
'%1$s, has %2$d terms',
item.count,
count,
'woo-gutenberg-products-block'
),
item.name,
item.count
count
) }
/>
);
@ -111,21 +112,21 @@ const ProductAttributeTermControl = ( {
_n(
'%d product',
'%d products',
item.count,
count,
'woo-gutenberg-products-block'
),
item.count
count
) }
aria-label={ sprintf(
/* translators: %1$s is the attribute name, %2$d is the count of products for that attribute. */
_n(
'%1$s, has %2$d product',
'%1$s, has %2$d products',
item.count,
count,
'woo-gutenberg-products-block'
),
itemName,
item.count
count
) }
/>
);

View File

@ -3,26 +3,64 @@
*/
import { __, _n, sprintf } from '@wordpress/i18n';
import { isEmpty } from '@woocommerce/types';
import PropTypes from 'prop-types';
import {
SearchListControl,
SearchListItem,
} from '@woocommerce/editor-components/search-list-control';
import type {
SearchListControlProps,
renderItemArgs,
} from '@woocommerce/editor-components/search-list-control/types';
import { withInstanceId } from '@wordpress/compose';
import {
withProductVariations,
withSearchedProducts,
withTransformSingleSelectToMultipleSelect,
} from '@woocommerce/block-hocs';
import type {
ProductResponseItem,
WithInjectedInstanceId,
WithInjectedProductVariations,
WithInjectedSearchedProducts,
} from '@woocommerce/types';
import { convertProductResponseItemToSearchItem } from '@woocommerce/utils';
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';
/**
* Internal dependencies
*/
import './style.scss';
interface ProductControlProps {
/**
* Callback to update the selected products.
*/
onChange: () => void;
isCompact?: boolean;
/**
* The ID of the currently expanded product.
*/
expandedProduct: number | null;
/**
* Callback to search products by their name.
*/
onSearch: () => void;
/**
* Callback to render each item in the selection list, allows any custom object-type rendering.
*/
renderItem: SearchListControlProps[ 'renderItem' ] | null;
/**
* The ID of the currently selected item (product or variation).
*/
selected: number[];
/**
* Whether to show variations in the list of items available.
*/
showVariations?: boolean;
}
const messages = {
list: __( 'Products', 'woo-gutenberg-products-block' ),
noItems: __(
@ -39,26 +77,35 @@ const messages = {
),
};
const ProductControl = ( {
expandedProduct,
error,
instanceId,
isCompact,
isLoading,
onChange,
onSearch,
products,
renderItem,
selected,
showVariations,
variations,
variationsLoading,
} ) => {
const renderItemWithVariations = ( args ) => {
const ProductControl = (
props: ProductControlProps &
WithInjectedSearchedProducts &
WithInjectedProductVariations &
WithInjectedInstanceId
) => {
const {
expandedProduct = null,
error,
instanceId,
isCompact = false,
isLoading,
onChange,
onSearch,
products,
renderItem,
selected = [],
showVariations = false,
variations,
variationsLoading,
} = props;
const renderItemWithVariations = (
args: renderItemArgs< ProductResponseItem >
) => {
const { item, search, depth = 0, isSelected, onSelect } = args;
const variationsCount =
item.variations && Array.isArray( item.variations )
? item.variations.length
item.details?.variations && Array.isArray( item.details.variations )
? item.details.variations.length
: 0;
const classes = classNames(
'woocommerce-search-product__item',
@ -74,6 +121,9 @@ const ProductControl = ( {
// Top level items custom rendering based on SearchListItem.
if ( ! item.breadcrumbs.length ) {
const hasVariations =
item.details?.variations && item.details.variations.length > 0;
return (
<ExpandableSearchListItem
{ ...args }
@ -89,42 +139,47 @@ const ProductControl = ( {
} }
isLoading={ isLoading || variationsLoading }
countLabel={
item.variations.length > 0
hasVariations
? sprintf(
/* translators: %1$d is the number of variations of a product product. */
__(
'%1$d variations',
'woo-gutenberg-products-block'
),
item.variations.length
item.details?.variations.length
)
: null
}
name={ `products-${ instanceId }` }
aria-label={ sprintf(
/* translators: %1$s is the product name, %2$d is the number of variations of that product. */
_n(
'%1$s, has %2$d variation',
'%1$s, has %2$d variations',
item.variations.length,
'woo-gutenberg-products-block'
),
item.name,
item.variations.length
) }
aria-label={
hasVariations
? sprintf(
/* translators: %1$s is the product name, %2$d is the number of variations of that product. */
_n(
'%1$s, has %2$d variation',
'%1$s, has %2$d variations',
item.details?.variations
?.length as number,
'woo-gutenberg-products-block'
),
item.name,
item.details?.variations.length
)
: undefined
}
/>
);
}
const itemArgs = isEmpty( item.variation )
const itemArgs = isEmpty( item.details?.variation )
? args
: {
...args,
item: {
...args.item,
name: item.variation,
name: item.details?.variation as string,
},
'aria-label': `${ item.breadcrumbs[ 0 ] }: ${ item.variation }`,
'aria-label': `${ item.breadcrumbs[ 0 ] }: ${ item.details?.variation }`,
};
return (
@ -142,7 +197,7 @@ const ProductControl = ( {
} else if ( showVariations ) {
return renderItemWithVariations;
}
return null;
return undefined;
};
if ( error ) {
@ -150,10 +205,12 @@ const ProductControl = ( {
}
const currentVariations =
variations && variations[ expandedProduct ]
variations && expandedProduct && variations[ expandedProduct ]
? variations[ expandedProduct ]
: [];
const currentList = [ ...products, ...currentVariations ];
const currentList = [ ...products, ...currentVariations ].map(
convertProductResponseItemToSearchItem
);
return (
<SearchListControl
@ -163,7 +220,7 @@ const ProductControl = ( {
isLoading={ isLoading }
isSingle
selected={ currentList.filter( ( { id } ) =>
selected.includes( id )
selected.includes( Number( id ) )
) }
onChange={ onChange }
renderItem={ getRenderItemFunc() }
@ -174,45 +231,6 @@ const ProductControl = ( {
);
};
ProductControl.propTypes = {
/**
* Callback to update the selected products.
*/
onChange: PropTypes.func.isRequired,
isCompact: PropTypes.bool,
/**
* The ID of the currently expanded product.
*/
expandedProduct: PropTypes.number,
/**
* Callback to search products by their name.
*/
onSearch: PropTypes.func,
/**
* Query args to pass to getProducts.
*/
queryArgs: PropTypes.object,
/**
* Callback to render each item in the selection list, allows any custom object-type rendering.
*/
renderItem: PropTypes.func,
/**
* The ID of the currently selected item (product or variation).
*/
selected: PropTypes.arrayOf( PropTypes.number ),
/**
* Whether to show variations in the list of items available.
*/
showVariations: PropTypes.bool,
};
ProductControl.defaultProps = {
isCompact: false,
expandedProduct: null,
selected: [],
showVariations: false,
};
export default withTransformSingleSelectToMultipleSelect(
withSearchedProducts(
withProductVariations( withInstanceId( ProductControl ) )

View File

@ -40,7 +40,7 @@ const ItemLabel = ( props: { item: SearchListItemProps; search: string } ) => {
);
};
export const SearchListItem = ( {
export const SearchListItem = < T extends object = object >( {
countLabel,
className,
depth = 0,
@ -53,7 +53,7 @@ export const SearchListItem = ( {
selected,
useExpandedPanelId,
...props
}: renderItemArgs ): JSX.Element => {
}: renderItemArgs< T > ): JSX.Element => {
const [ expandedPanelId, setExpandedPanelId ] = useExpandedPanelId;
const showCount =
countLabel !== undefined &&
@ -165,6 +165,7 @@ export const SearchListItem = ( {
{ isSingle ? (
<>
<input
{ ...props }
type="radio"
id={ id }
name={ name }
@ -172,13 +173,13 @@ export const SearchListItem = ( {
onChange={ onSelect( item ) }
checked={ isSelected }
className="woocommerce-search-list__item-input"
{ ...props }
></input>
<ItemLabel item={ item } search={ search } />
</>
) : (
<CheckboxControl
{ ...props }
id={ id }
name={ name }
className="woocommerce-search-list__item-input"
@ -189,7 +190,6 @@ export const SearchListItem = ( {
) }
onChange={ onSelect( item ) }
checked={ isSelected }
{ ...props }
/>
) }

View File

@ -101,14 +101,14 @@ const ListItems = ( props: ListItemsProps ): JSX.Element | null => {
);
};
const SelectedListItems = ( {
const SelectedListItems = < T extends object = object >( {
isLoading,
isSingle,
selected,
messages,
onChange,
onRemove,
}: SearchListControlProps & {
}: SearchListControlProps< T > & {
messages: SearchListMessages;
onRemove: ( itemId: string | number ) => () => void;
} ) => {
@ -148,14 +148,14 @@ const SelectedListItems = ( {
);
};
const ListItemsContainer = ( {
const ListItemsContainer = < T extends object = object >( {
filteredList,
search,
onSelect,
instanceId,
useExpandedPanelId,
...props
}: SearchListItemsContainerProps ) => {
}: SearchListItemsContainerProps< T > ) => {
const { messages, renderItem, selected, isSingle } = props;
const renderItemCallback = renderItem || defaultRenderListItem;
@ -194,7 +194,9 @@ const ListItemsContainer = ( {
/**
* Component to display a searchable, selectable list of items.
*/
export const SearchListControl = ( props: SearchListControlProps ) => {
export const SearchListControl = < T extends object = object >(
props: SearchListControlProps< T >
) => {
const {
className = '',
isCompact,
@ -250,22 +252,25 @@ export const SearchListControl = ( props: SearchListControlProps ) => {
);
const onSelect = useCallback(
( item: SearchListItemProps | SearchListItemProps[] ) => () => {
if ( Array.isArray( item ) ) {
onChange( item );
return;
}
( item: SearchListItemProps< T > | SearchListItemProps< T >[] ) =>
() => {
if ( Array.isArray( item ) ) {
onChange( item );
return;
}
if ( selected.findIndex( ( { id } ) => id === item.id ) !== -1 ) {
onRemove( item.id )();
return;
}
if ( isSingle ) {
onChange( [ item ] );
} else {
onChange( [ ...selected, item ] );
}
},
if (
selected.findIndex( ( { id } ) => id === item.id ) !== -1
) {
onRemove( item.id )();
return;
}
if ( isSingle ) {
onChange( [ item ] );
} else {
onChange( [ ...selected, item ] );
}
},
[ isSingle, onRemove, onChange, selected ]
);

View File

@ -1,14 +1,16 @@
/**
* External dependencies
*/
import type { ReactNode } from 'react';
import type { InputHTMLAttributes, ReactNode } from 'react';
import { Require } from '@woocommerce/types';
interface ItemProps {
interface ItemProps< T extends object = object > {
// Depth, non-zero if the list is hierarchical.
depth?: number;
// Callback for selecting the item.
onSelect: ( item: SearchListItem | SearchListItem[] ) => () => void;
onSelect: (
item: SearchListItem< T > | SearchListItem< T >[]
) => () => void;
// Search string, used to highlight the substring in the item name.
search: string;
useExpandedPanelId: [
@ -17,15 +19,15 @@ interface ItemProps {
];
}
interface SearchListProps {
interface SearchListProps< T extends object = object > {
//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[];
list: SearchListItem< T >[];
// Callback to render each item in the selection list, allows any custom object-type rendering.
renderItem?: ( args: renderItemArgs ) => JSX.Element;
renderItem?: ( args: renderItemArgs< T > ) => JSX.Element;
// The list of currently selected items.
selected: SearchListItem[];
selected: SearchListItem< T >[];
}
export interface ListItemsProps
@ -34,21 +36,22 @@ export interface ListItemsProps
instanceId: string | number;
}
export type SearchListItem = {
export type SearchListItem< T extends object = object > = {
breadcrumbs: string[];
children?: SearchListItem[];
count: number;
children?: SearchListItem< T >[];
count?: number;
details?: T;
id: string | number;
name: string;
parent: number;
value: string;
};
export interface SearchListItemsContainerProps
export interface SearchListItemsContainerProps< T extends object = object >
extends SearchListControlProps,
ItemProps {
instanceId: string | number;
filteredList: SearchListItem[];
filteredList: SearchListItem< T >[];
messages: SearchListMessages;
}
@ -67,23 +70,30 @@ export interface SearchListMessages {
updated: string;
}
export interface renderItemArgs extends ItemProps {
export interface renderItemArgs< T extends object = object >
extends ItemProps,
Partial<
Omit<
InputHTMLAttributes< HTMLInputElement >,
'onChange' | 'onSelect'
>
> {
// Additional CSS classes.
className?: string | undefined;
// Whether the item is disable.
disabled?: boolean | undefined;
className?: string;
// Unique id of the parent control.
controlId: string | number;
// Label to display in the count bubble. Takes preference over `item.count`.
countLabel?: ReactNode;
// Whether the item is disabled.
disabled?: boolean;
// Current item to display.
item: SearchListItem;
item: SearchListItem< T >;
// Whether this item is selected.
isSelected: boolean;
// Whether this should only display a single item (controls radio vs checkbox icon).
isSingle: boolean;
// 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`.
countLabel?: ReactNode;
selected: SearchListItem< T >[];
/**
* Name of the inputs. Used to group input controls together. See:
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#attr-name
@ -94,7 +104,7 @@ export interface renderItemArgs extends ItemProps {
ariaLabel?: string;
}
export interface SearchListControlProps {
export interface SearchListControlProps< T extends object = object > {
// Additional CSS classes.
className?: string;
// Whether it should be displayed in a compact way, so it occupies less space.
@ -106,17 +116,17 @@ 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: SearchListItem[];
list: SearchListItem< T >[];
// 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: SearchListItem[] ) => void;
onChange: ( search: SearchListItem< T >[] ) => void;
// Callback fired when the search field is used.
onSearch?: ( ( search: string ) => void ) | undefined;
// Callback to render each item in the selection list, allows any custom object-type rendering.
renderItem?: ( args: renderItemArgs ) => JSX.Element;
renderItem?: ( ( args: renderItemArgs< T > ) => JSX.Element ) | undefined;
// The list of currently selected items.
selected: SearchListItem[];
selected: SearchListItem< T >[];
// Whether to show a text field or a token field as search
// Defaults to `'text'`
type?: 'text' | 'token';

View File

@ -5,3 +5,7 @@ export { default as withProduct } from './with-product';
export { default as withProductVariations } from './with-product-variations';
export { default as withSearchedProducts } from './with-searched-products';
export { default as withTransformSingleSelectToMultipleSelect } from './with-transform-single-select-to-multiple-select';
export {
SelectedOption,
WithMaybeSelectedOption,
} from './with-transform-single-select-to-multiple-select';

View File

@ -5,20 +5,29 @@ import { useEffect, useState, useCallback, useRef } from '@wordpress/element';
import { blocksConfig } from '@woocommerce/block-settings';
import { getProducts } from '@woocommerce/editor-components/utils';
import { useDebouncedCallback } from 'use-debounce';
import type { ProductResponseItem } from '@woocommerce/types';
import type {
ProductResponseItem,
WithInjectedSearchedProducts,
} from '@woocommerce/types';
/**
* Internal dependencies
*/
import { formatError } from '../base/utils/errors.js';
interface WithSearchedProductProps {
selected: number[];
}
/**
* A higher order component that enhances the provided component with products from a search query.
*/
const withSearchedProducts = (
OriginalComponent: React.FunctionComponent< Record< string, unknown > >
const withSearchedProducts = <
T extends Record< string, unknown > & WithSearchedProductProps
>(
OriginalComponent: React.ComponentType< T & WithInjectedSearchedProducts >
) => {
return ( { selected, ...props }: { selected: number[] } ): JSX.Element => {
return ( { selected, ...props }: T ): JSX.Element => {
const [ isLoading, setIsLoading ] = useState( true );
const [ error, setError ] = useState< {
message: string;
@ -71,7 +80,7 @@ const withSearchedProducts = (
return (
<OriginalComponent
{ ...props }
{ ...( props as T ) }
selected={ selected }
error={ error }
products={ productsList }

View File

@ -6,21 +6,21 @@
// eslint-disable-next-line
import { FunctionComponent } from '@wordpress/element';
type SelectedOption = number | string | null | number[] | string[];
export type SelectedOption = number | string | null | number[] | string[];
interface OriginalComponentProps {
export interface WithMaybeSelectedOption {
selected?: SelectedOption;
}
/**
* HOC that transforms a single select to a multiple select.
*
* @param {FunctionComponent< Record< string, unknown > >} OriginalComponent Component being wrapped.
*/
const withTransformSingleSelectToMultipleSelect = (
OriginalComponent: FunctionComponent< Record< string, unknown > >
const withTransformSingleSelectToMultipleSelect = <
T extends Record< string, unknown >
>(
OriginalComponent: FunctionComponent< T & WithMaybeSelectedOption >
) => {
return ( props: OriginalComponentProps ): JSX.Element => {
return ( props: T & WithMaybeSelectedOption ): JSX.Element => {
let { selected } = props;
selected = selected === undefined ? null : selected;
const isNil = selected === null;

View File

@ -0,0 +1,27 @@
/**
* External dependencies
*/
import { ErrorObject } from '@woocommerce/editor-components/error-placeholder';
import type { ProductResponseItem } from '@woocommerce/types';
export interface WithInjectedProductVariations {
error: ErrorObject | null;
/**
* The id of the currently expanded product
*/
expandedProduct: number | null;
variations: Record< number, ProductResponseItem[] >;
variationsLoading: boolean;
}
export interface WithInjectedSearchedProducts {
error: ErrorObject | null;
isLoading: boolean;
onSearch: ( ( search: string ) => void ) | null;
products: ProductResponseItem[];
selected: number[];
}
export interface WithInjectedInstanceId {
instanceId: string | number;
}

View File

@ -8,6 +8,7 @@ export * from './checkout';
export * from './contexts';
export * from './currency';
export * from './events';
export * from './hocs';
export * from './hooks';
export * from './notices';
export * from './objects';

View File

@ -90,4 +90,5 @@ export interface ProductResponseItem {
maximum: number;
multiple_of: number;
};
slug: string;
}

View File

@ -1,29 +0,0 @@
/**
* Get the src of the first image attached to a product (the featured image).
*
* @param {Object} product The product object to get the images from.
* @param {Array} product.images The array of images, destructured from the product object.
* @return {string} The full URL to the image.
*/
export function getImageSrcFromProduct( product ) {
if ( ! product || ! product.images || ! product.images.length ) {
return '';
}
return product.images[ 0 ].src || '';
}
/**
* Get the ID of the first image attached to a product (the featured image).
*
* @param {Object} product The product object to get the images from.
* @param {Array} product.images The array of images, destructured from the product object.
* @return {number} The ID of the image.
*/
export function getImageIdFromProduct( product ) {
if ( ! product || ! product.images || ! product.images.length ) {
return 0;
}
return product.images[ 0 ].id || 0;
}

View File

@ -0,0 +1,46 @@
/**
* External dependencies
*/
import type { SearchListItem } from '@woocommerce/editor-components/search-list-control/types';
import type { ProductResponseItem } from '@woocommerce/types';
/**
* Converts a Product object into a shape compatible with the `SearchListControl`
*/
export const convertProductResponseItemToSearchItem = (
product: ProductResponseItem
): SearchListItem< ProductResponseItem > => {
const { id, name, parent } = product;
return {
id,
name,
parent,
breadcrumbs: [],
children: [],
details: product,
value: product.slug,
};
};
/**
* Get the src of the first image attached to a product (the featured image).
*/
export function getImageSrcFromProduct( product: ProductResponseItem ) {
if ( ! product || ! product.images || ! product.images.length ) {
return '';
}
return product.images[ 0 ].src || '';
}
/**
* Get the ID of the first image attached to a product (the featured image).
*/
export function getImageIdFromProduct( product: ProductResponseItem ) {
if ( ! product || ! product.images || ! product.images.length ) {
return 0;
}
return product.images[ 0 ].id || 0;
}