Add category field dropdown field (#34400)
* Add initial category field component with new typeahead Move search logic to useCategorySearch hook Add initial add new category logic Add parent category field to add new category modal Adding some debug changes Update category control to make use of internal selectItem function of select control Add changelogs Update pagesize back to 100 Add placeholder Empty placeholder Fix input and icon sizes Fix input underline Add max height and scroll to category dropdown Add sorting of category items Auto open parents when traversing up the tree using arrow keys Add several comments Add some initial unit tests for the category field component Add tests for useCategorySearch hook and fixed minor bug Update styling and autoselect parent if child is selected Fix styling issues for the select control dropdown inside a modal Fix issue with creating new category with parent Add function comment and fixed border styling Prune out create new category logic Fix minor css issue with border Revert some of the select control changes and make use of the custom type Fix up some styling changes * Fix type conflict * Revert change in state reducer * Add cursor pointer * Fix styling errors * Fix broken category tests * Fix merge conflict
This commit is contained in:
parent
9b9abd1eae
commit
c55c91d7e0
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: update
|
||||
|
||||
Update experimental SelectControl compoment to expose a couple extra combobox functions from Downshift.
|
|
@ -1,36 +1,37 @@
|
|||
.woocommerce-experimental-select-control__combo-box-wrapper {
|
||||
cursor: text;
|
||||
border: 1px solid $studio-gray-20;
|
||||
border-radius: 3px;
|
||||
background: $studio-white;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
padding: $gap-smallest 36px $gap-smallest $gap-smaller;
|
||||
margin-bottom: 4px;
|
||||
cursor: text;
|
||||
border: 1px solid $studio-gray-20;
|
||||
border-radius: 2px;
|
||||
background: $studio-white;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
padding: $gap-smallest 36px $gap-smallest $gap-smaller;
|
||||
margin-bottom: 4px;
|
||||
|
||||
> * {
|
||||
display: inline-flex;
|
||||
}
|
||||
> * {
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
|
||||
.woocommerce-experimental-select-control__combox-box {
|
||||
flex-grow: 1;
|
||||
align-items: center;
|
||||
flex-basis: 120px;
|
||||
display: inline-flex;
|
||||
flex-grow: 1;
|
||||
align-items: center;
|
||||
flex-basis: 120px;
|
||||
display: inline-flex;
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
margin-top: 2px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
margin-top: 2px;
|
||||
margin-bottom: 2px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.woocommerce-experimental-select-control__combox-box-icon {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 50%;
|
||||
transform: translateY( -50% );
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@ import { createElement, ReactElement } from 'react';
|
|||
*/
|
||||
import { getItemPropsType } from './types';
|
||||
|
||||
type MenuItemProps< ItemType > = {
|
||||
export type MenuItemProps< ItemType > = {
|
||||
index: number;
|
||||
isActive: boolean;
|
||||
item: ItemType;
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
* External dependencies
|
||||
*/
|
||||
import classnames from 'classnames';
|
||||
import { createElement, ReactElement } from 'react';
|
||||
import { createElement } from '@wordpress/element';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
|
@ -13,14 +13,21 @@ type MenuProps = {
|
|||
children?: JSX.Element | JSX.Element[];
|
||||
getMenuProps: getMenuPropsType;
|
||||
isOpen: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const Menu = ( { children, getMenuProps, isOpen }: MenuProps ) => {
|
||||
export const Menu = ( {
|
||||
children,
|
||||
getMenuProps,
|
||||
isOpen,
|
||||
className,
|
||||
}: MenuProps ) => {
|
||||
return (
|
||||
<ul
|
||||
{ ...getMenuProps() }
|
||||
className={ classnames(
|
||||
'woocommerce-experimental-select-control__menu',
|
||||
className,
|
||||
{
|
||||
'is-open': isOpen,
|
||||
'has-results': Array.isArray( children )
|
||||
|
|
|
@ -4,19 +4,24 @@
|
|||
@import './selected-items.scss';
|
||||
|
||||
.woocommerce-experimental-select-control {
|
||||
position: relative;
|
||||
position: relative;
|
||||
|
||||
&.is-focused .woocommerce-experimental-select-control__combo-box-wrapper {
|
||||
box-shadow: 0 0 0 1px var(--wp-admin-theme-color);
|
||||
border-color: var(--wp-admin-theme-color);
|
||||
}
|
||||
&.is-focused .woocommerce-experimental-select-control__combo-box-wrapper {
|
||||
box-shadow: 0 0 0 1px var( --wp-admin-theme-color );
|
||||
border-color: var( --wp-admin-theme-color );
|
||||
}
|
||||
|
||||
.woocommerce-experimental-select-control__input {
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
&__label {
|
||||
display: inline-block;
|
||||
margin-bottom: $gap-smaller;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
.woocommerce-experimental-select-control__input {
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -58,6 +58,7 @@ type SelectControlProps< ItemType > = {
|
|||
) => Partial< UseComboboxState< ItemType | null > >;
|
||||
placeholder?: string;
|
||||
selected: ItemType | ItemType[] | null;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const selectControlStateChangeTypes = useCombobox.stateChangeTypes;
|
||||
|
@ -100,6 +101,7 @@ function SelectControl< ItemType = DefaultItemType >( {
|
|||
stateReducer = ( state, actionAndChanges ) => actionAndChanges.changes,
|
||||
placeholder,
|
||||
selected,
|
||||
className,
|
||||
}: SelectControlProps< ItemType > ) {
|
||||
const [ isFocused, setIsFocused ] = useState( false );
|
||||
const [ inputValue, setInputValue ] = useState( '' );
|
||||
|
@ -213,13 +215,22 @@ function SelectControl< ItemType = DefaultItemType >( {
|
|||
|
||||
return (
|
||||
<div
|
||||
className={ classnames( 'woocommerce-experimental-select-control', {
|
||||
'is-focused': isFocused,
|
||||
} ) }
|
||||
className={ classnames(
|
||||
'woocommerce-experimental-select-control',
|
||||
className,
|
||||
{
|
||||
'is-focused': isFocused,
|
||||
}
|
||||
) }
|
||||
>
|
||||
{ /* Downshift's getLabelProps handles the necessary label attributes. */ }
|
||||
{ /* eslint-disable jsx-a11y/label-has-for */ }
|
||||
<label { ...getLabelProps() }>{ label }</label>
|
||||
<label
|
||||
{ ...getLabelProps() }
|
||||
className="woocommerce-experimental-select-control__label"
|
||||
>
|
||||
{ label }
|
||||
</label>
|
||||
{ /* eslint-enable jsx-a11y/label-has-for */ }
|
||||
<ComboBox
|
||||
comboBoxProps={ getComboboxProps() }
|
||||
|
|
|
@ -6,7 +6,6 @@ import {
|
|||
UseComboboxGetItemPropsOptions,
|
||||
UseComboboxGetMenuPropsOptions,
|
||||
GetPropsCommonOptions,
|
||||
UseComboboxGetToggleButtonPropsOptions,
|
||||
} from 'downshift';
|
||||
|
||||
export type DefaultItemType = {
|
||||
|
@ -26,12 +25,6 @@ export type getItemPropsType< ItemType > = (
|
|||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) => any;
|
||||
|
||||
export type getToggleButtonPropsType = (
|
||||
options?: UseComboboxGetToggleButtonPropsOptions
|
||||
// These are the types provided by Downshift.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) => any;
|
||||
|
||||
export type getMenuPropsType = (
|
||||
options?: UseComboboxGetMenuPropsOptions,
|
||||
otherOptions?: GetPropsCommonOptions
|
||||
|
|
|
@ -45,7 +45,11 @@ export {
|
|||
SelectControl as __experimentalSelectControl,
|
||||
selectControlStateChangeTypes,
|
||||
} from './experimental-select-control';
|
||||
export { MenuItem as __experimentalSelectControlMenuItem } from './experimental-select-control/menu-item';
|
||||
export {
|
||||
MenuItem as __experimentalSelectControlMenuItem,
|
||||
MenuItemProps as __experimentalSelectControlMenuItemProps,
|
||||
} from './experimental-select-control/menu-item';
|
||||
export { Menu as __experimentalSelectControlMenu } from './experimental-select-control/menu';
|
||||
export { default as ScrollTo } from './scroll-to';
|
||||
export { Sortable } from './sortable';
|
||||
export { ListItem } from './list-item';
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: add
|
||||
|
||||
Add getItemsTotalCount selector to the crud data store.
|
|
@ -78,6 +78,11 @@ export * from './plugins/types';
|
|||
export * from './products/types';
|
||||
export * from './product-shipping-classes/types';
|
||||
export * from './orders/types';
|
||||
export {
|
||||
ProductCategory,
|
||||
ProductCategoryImage,
|
||||
ProductCategorySelectors,
|
||||
} from './product-categories/types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
|
|
|
@ -9,7 +9,7 @@ import { DispatchFromMap } from '@automattic/data-stores';
|
|||
import { CrudActions, CrudSelectors } from '../crud/types';
|
||||
import { BaseQueryParams } from '../types';
|
||||
|
||||
type ProductCategoryImage = {
|
||||
export type ProductCategoryImage = {
|
||||
id: number;
|
||||
date_created: string;
|
||||
date_created_gmt: string;
|
||||
|
@ -20,7 +20,7 @@ type ProductCategoryImage = {
|
|||
alt: string;
|
||||
};
|
||||
|
||||
type ProductCategory = {
|
||||
export type ProductCategory = {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
|
|
|
@ -6,6 +6,7 @@ import { Schema } from '@wordpress/core-data';
|
|||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { ProductCategory } from '../product-categories/types';
|
||||
import { BaseQueryParams } from '../types';
|
||||
|
||||
export type ProductType = 'simple' | 'grouped' | 'external' | 'variable';
|
||||
|
@ -34,6 +35,12 @@ export type ProductAttribute = {
|
|||
options: string[];
|
||||
};
|
||||
|
||||
export type ProductDimensions = {
|
||||
width: string;
|
||||
height: string;
|
||||
length: string;
|
||||
};
|
||||
|
||||
export type Product< Status = ProductStatus, Type = ProductType > = Omit<
|
||||
Schema.Post,
|
||||
'status' | 'categories'
|
||||
|
@ -89,7 +96,7 @@ export type Product< Status = ProductStatus, Type = ProductType > = Omit<
|
|||
attributes: ProductAttribute[];
|
||||
dimensions: ProductDimensions;
|
||||
weight: string;
|
||||
categories: ProductCategory[];
|
||||
categories: Pick< ProductCategory, 'id' | 'name' | 'slug' >[];
|
||||
};
|
||||
|
||||
export const productReadOnlyProperties = [
|
||||
|
@ -148,15 +155,3 @@ export type ProductQuery<
|
|||
max_price: string;
|
||||
stock_status: 'instock' | 'outofstock' | 'onbackorder';
|
||||
};
|
||||
|
||||
export type ProductDimensions = {
|
||||
width: string;
|
||||
height: string;
|
||||
length: string;
|
||||
};
|
||||
|
||||
export type ProductCategory = {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
|
|
|
@ -0,0 +1,121 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { CheckboxControl, Icon } from '@wordpress/components';
|
||||
import { useEffect, useState } from '@wordpress/element';
|
||||
import { chevronDown, chevronUp } from '@wordpress/icons';
|
||||
import { ProductCategory } from '@woocommerce/data';
|
||||
import { __experimentalSelectControlMenuItemProps as MenuItemProps } from '@woocommerce/components';
|
||||
import classNames from 'classnames';
|
||||
|
||||
export type CategoryTreeItem = {
|
||||
data: ProductCategory;
|
||||
children: CategoryTreeItem[];
|
||||
parentID: number;
|
||||
isOpen: boolean;
|
||||
};
|
||||
|
||||
type CategoryFieldItemProps = {
|
||||
item: CategoryTreeItem;
|
||||
selectedIds: number[];
|
||||
onSelect: ( item: ProductCategory ) => void;
|
||||
items: Pick< ProductCategory, 'id' | 'name' >[];
|
||||
highlightedIndex: number;
|
||||
openParent?: () => void;
|
||||
} & Pick<
|
||||
MenuItemProps< Pick< ProductCategory, 'id' | 'name' > >,
|
||||
'getItemProps'
|
||||
>;
|
||||
|
||||
export const CategoryFieldItem: React.FC< CategoryFieldItemProps > = ( {
|
||||
item,
|
||||
selectedIds = [],
|
||||
onSelect,
|
||||
items,
|
||||
highlightedIndex,
|
||||
openParent,
|
||||
getItemProps,
|
||||
} ) => {
|
||||
const [ isOpen, setIsOpen ] = useState( item.isOpen || false );
|
||||
const index = items.findIndex( ( i ) => i.id === item.data.id );
|
||||
const children = item.children.filter( ( child ) =>
|
||||
items.includes( child.data )
|
||||
);
|
||||
|
||||
useEffect( () => {
|
||||
if ( highlightedIndex === index && children.length > 0 && ! isOpen ) {
|
||||
setIsOpen( true );
|
||||
} else if ( highlightedIndex === index && openParent ) {
|
||||
// Make sure the parent is also open when the item is highlighted.
|
||||
openParent();
|
||||
}
|
||||
}, [ highlightedIndex ] );
|
||||
|
||||
useEffect( () => {
|
||||
if ( item.isOpen !== isOpen ) {
|
||||
setIsOpen( item.isOpen );
|
||||
}
|
||||
}, [ item.isOpen ] );
|
||||
|
||||
return (
|
||||
<li
|
||||
className={ classNames(
|
||||
'woocommerce-category-field-dropdown__item',
|
||||
{
|
||||
item_highlighted: index === highlightedIndex,
|
||||
}
|
||||
) }
|
||||
>
|
||||
<div
|
||||
className="woocommerce-category-field-dropdown__item-content"
|
||||
{ ...getItemProps( {
|
||||
item: item.data,
|
||||
index,
|
||||
} ) }
|
||||
>
|
||||
{ children.length > 0 ? (
|
||||
<Icon
|
||||
className="woocommerce-category-field-dropdown__toggle"
|
||||
icon={ isOpen ? chevronUp : chevronDown }
|
||||
size={ 20 }
|
||||
onClick={ ( e: React.MouseEvent ) => {
|
||||
e.stopPropagation();
|
||||
setIsOpen( ! isOpen );
|
||||
} }
|
||||
/>
|
||||
) : (
|
||||
<div className="woocommerce-category-field-dropdown__toggle-placeholder"></div>
|
||||
) }
|
||||
<CheckboxControl
|
||||
label={ item.data.name }
|
||||
checked={ selectedIds.includes( item.data.id ) }
|
||||
onChange={ () => item.data /*&& onSelect( item.data )*/ }
|
||||
/>
|
||||
</div>
|
||||
{ children.length > 0 ? (
|
||||
<ul
|
||||
className={ classNames(
|
||||
'woocommerce-category-field-dropdown__item-children',
|
||||
{
|
||||
'woocommerce-category-field-dropdown__item-open':
|
||||
isOpen,
|
||||
}
|
||||
) }
|
||||
>
|
||||
{ children.map( ( child ) => (
|
||||
<CategoryFieldItem
|
||||
key={ child.data.id }
|
||||
item={ child }
|
||||
selectedIds={ selectedIds }
|
||||
onSelect={ onSelect }
|
||||
items={ items }
|
||||
highlightedIndex={ highlightedIndex }
|
||||
openParent={ () => ! isOpen && setIsOpen( true ) }
|
||||
getItemProps={ getItemProps }
|
||||
/>
|
||||
) ) }
|
||||
</ul>
|
||||
) : null }
|
||||
</li>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,67 @@
|
|||
.woocommerce-category-field-dropdown {
|
||||
.woocommerce-experimental-select-control__input {
|
||||
height: auto;
|
||||
}
|
||||
.woocommerce-experimental-select-control__combo-box-wrapper {
|
||||
border-color: $gray-700;
|
||||
}
|
||||
&__menu {
|
||||
padding: 0 $gap-small;
|
||||
max-height: 300px;
|
||||
overflow-y: scroll;
|
||||
|
||||
> .woocommerce-category-field-dropdown__item:not(:first-child) {
|
||||
> .woocommerce-category-field-dropdown__item-content {
|
||||
border-top: 1px solid $gray-200;
|
||||
}
|
||||
}
|
||||
}
|
||||
&__item {
|
||||
margin-bottom: 0;
|
||||
|
||||
.woocommerce-category-field-dropdown__item-content {
|
||||
.components-base-control {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
&__item-content {
|
||||
height: 48px;
|
||||
padding: $gap-smaller 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
|
||||
.components-base-control__field {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
&__item-children {
|
||||
margin-left: $gap-larger;
|
||||
display: none;
|
||||
}
|
||||
&__item-open {
|
||||
display: block;
|
||||
}
|
||||
&__toggle {
|
||||
margin-right: $gap-smaller;
|
||||
cursor: pointer;
|
||||
}
|
||||
&__toggle-placeholder {
|
||||
width: $gap + $gap-small;
|
||||
}
|
||||
}
|
||||
|
||||
.woocommerce-category-field-dropdown__item.item_highlighted > .woocommerce-category-field-dropdown__item-content {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.woocommerce-experimental-select-control {
|
||||
&__input {
|
||||
height: 30px;
|
||||
}
|
||||
&__combox-box-icon {
|
||||
box-sizing: unset;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,194 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useMemo } from '@wordpress/element';
|
||||
import {
|
||||
selectControlStateChangeTypes,
|
||||
Spinner,
|
||||
__experimentalSelectControl as SelectControl,
|
||||
__experimentalSelectControlMenu as Menu,
|
||||
} from '@woocommerce/components';
|
||||
import { ProductCategory } from '@woocommerce/data';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './category-field.scss';
|
||||
import { CategoryFieldItem, CategoryTreeItem } from './category-field-item';
|
||||
import { useCategorySearch } from './use-category-search';
|
||||
|
||||
type CategoryFieldProps = {
|
||||
label: string;
|
||||
placeholder: string;
|
||||
value?: Pick< ProductCategory, 'id' | 'name' >[];
|
||||
onChange: ( value: Pick< ProductCategory, 'id' | 'name' >[] ) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Recursive function that adds the current item to the selected list and all it's parents
|
||||
* if not included already.
|
||||
*/
|
||||
function getSelectedWithParents(
|
||||
selected: Pick< ProductCategory, 'id' | 'name' >[] = [],
|
||||
item: ProductCategory,
|
||||
treeKeyValues: Record< number, CategoryTreeItem >
|
||||
): Pick< ProductCategory, 'id' | 'name' >[] {
|
||||
selected.push( { id: item.id, name: item.name } );
|
||||
|
||||
const parentId = item.parent
|
||||
? item.parent
|
||||
: treeKeyValues[ item.id ].parentID;
|
||||
if (
|
||||
parentId > 0 &&
|
||||
treeKeyValues[ parentId ] &&
|
||||
! selected.find(
|
||||
( selectedCategory ) => selectedCategory.id === parentId
|
||||
)
|
||||
) {
|
||||
getSelectedWithParents(
|
||||
selected,
|
||||
treeKeyValues[ parentId ].data,
|
||||
treeKeyValues
|
||||
);
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
export const CategoryField: React.FC< CategoryFieldProps > = ( {
|
||||
label,
|
||||
placeholder,
|
||||
value = [],
|
||||
onChange,
|
||||
} ) => {
|
||||
const {
|
||||
isSearching,
|
||||
categoriesSelectList,
|
||||
categoryTreeKeyValues,
|
||||
searchCategories,
|
||||
getFilteredItems,
|
||||
} = useCategorySearch();
|
||||
|
||||
const onInputChange = ( searchString?: string ) => {
|
||||
searchCategories( searchString || '' );
|
||||
};
|
||||
|
||||
const searchDelayed = useMemo(
|
||||
() => debounce( onInputChange, 150 ),
|
||||
[ onInputChange ]
|
||||
);
|
||||
|
||||
const onSelect = ( itemId: number, selected: boolean ) => {
|
||||
if ( selected ) {
|
||||
const item = categoryTreeKeyValues[ itemId ].data;
|
||||
if ( item ) {
|
||||
onChange(
|
||||
getSelectedWithParents(
|
||||
[ ...value ],
|
||||
item,
|
||||
categoryTreeKeyValues
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
onChange( value.filter( ( i ) => i.id !== itemId ) );
|
||||
}
|
||||
};
|
||||
|
||||
const selectedIds = value.map( ( item ) => item.id );
|
||||
const selectControlItems = categoriesSelectList;
|
||||
|
||||
return (
|
||||
<SelectControl< Pick< ProductCategory, 'id' | 'name' > >
|
||||
className="woocommerce-category-field-dropdown components-base-control"
|
||||
multiple
|
||||
items={ selectControlItems }
|
||||
label={ label }
|
||||
selected={ value }
|
||||
getItemLabel={ ( item ) => item?.name || '' }
|
||||
getItemValue={ ( item ) => item?.id || '' }
|
||||
onSelect={ ( item ) => {
|
||||
if ( item ) {
|
||||
onSelect( item.id, ! selectedIds.includes( item.id ) );
|
||||
}
|
||||
} }
|
||||
onRemove={ ( item ) => item && onSelect( item.id, false ) }
|
||||
onInputChange={ searchDelayed }
|
||||
getFilteredItems={ getFilteredItems }
|
||||
placeholder={ value.length === 0 ? placeholder : '' }
|
||||
stateReducer={ ( state, actionAndChanges ) => {
|
||||
const { changes, type } = actionAndChanges;
|
||||
switch ( type ) {
|
||||
case selectControlStateChangeTypes.ControlledPropUpdatedSelectedItem:
|
||||
return {
|
||||
...changes,
|
||||
inputValue: state.inputValue,
|
||||
};
|
||||
case selectControlStateChangeTypes.ItemClick:
|
||||
return {
|
||||
...changes,
|
||||
isOpen: true,
|
||||
inputValue: state.inputValue,
|
||||
highlightedIndex: state.highlightedIndex,
|
||||
};
|
||||
default:
|
||||
return changes;
|
||||
}
|
||||
} }
|
||||
>
|
||||
{ ( {
|
||||
items,
|
||||
isOpen,
|
||||
getMenuProps,
|
||||
getItemProps,
|
||||
selectItem,
|
||||
highlightedIndex,
|
||||
} ) => {
|
||||
const rootItems =
|
||||
items.length > 0
|
||||
? items.filter(
|
||||
( item ) =>
|
||||
categoryTreeKeyValues[ item.id ]
|
||||
?.parentID === 0
|
||||
)
|
||||
: [];
|
||||
return (
|
||||
<>
|
||||
<Menu
|
||||
isOpen={ isOpen }
|
||||
getMenuProps={ getMenuProps }
|
||||
className="woocommerce-category-field-dropdown__menu"
|
||||
>
|
||||
<>
|
||||
{ isOpen && isSearching && items.length === 0 && (
|
||||
<li className="woocommerce-category-field-dropdown__item">
|
||||
<div className="woocommerce-category-field-dropdown__item-content">
|
||||
<Spinner />
|
||||
</div>
|
||||
</li>
|
||||
) }
|
||||
{ isOpen &&
|
||||
rootItems.map( ( item ) => (
|
||||
<CategoryFieldItem
|
||||
key={ `${ item.id }` }
|
||||
item={
|
||||
categoryTreeKeyValues[ item.id ]
|
||||
}
|
||||
highlightedIndex={
|
||||
highlightedIndex
|
||||
}
|
||||
selectedIds={ selectedIds }
|
||||
onSelect={ selectItem }
|
||||
items={ items }
|
||||
getItemProps={ getItemProps }
|
||||
/>
|
||||
) ) }
|
||||
</>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
} }
|
||||
</SelectControl>
|
||||
);
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export * from './category-field';
|
|
@ -0,0 +1,329 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { ReactElement, Component } from 'react';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import { Form, FormContext } from '@woocommerce/components';
|
||||
import { Product, ProductCategory } from '@woocommerce/data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { CategoryField } from '../category-field';
|
||||
import {
|
||||
getCategoriesTreeWithMissingParents,
|
||||
useCategorySearch,
|
||||
} from '../use-category-search';
|
||||
|
||||
const mockCategoryList = [
|
||||
{ id: 1, name: 'Clothing', parent: 0 },
|
||||
{ id: 2, name: 'Hoodies', parent: 1 },
|
||||
{ id: 3, name: 'Rain gear', parent: 0 },
|
||||
] as ProductCategory[];
|
||||
|
||||
jest.mock( '@woocommerce/components', () => {
|
||||
const originalModule = jest.requireActual( '@woocommerce/components' );
|
||||
|
||||
type ChildrenProps = {
|
||||
items: ProductCategory[];
|
||||
isOpen: boolean;
|
||||
highlightedIndex: number;
|
||||
getMenuProps: () => Record< string, string >;
|
||||
getItemProps: () => Record< string, string >;
|
||||
selectItem: ( item: ProductCategory ) => void;
|
||||
setInputValue: ( value: string ) => void;
|
||||
};
|
||||
type SelectControlProps = {
|
||||
children: ( {}: ChildrenProps ) => ReactElement | Component;
|
||||
items: ProductCategory[];
|
||||
label: string;
|
||||
initialSelectedItems?: ProductCategory[];
|
||||
itemToString?: ( item: ProductCategory | null ) => string;
|
||||
getFilteredItems?: (
|
||||
allItems: ProductCategory[],
|
||||
inputValue: string,
|
||||
selectedItems: ProductCategory[]
|
||||
) => ProductCategory[];
|
||||
multiple?: boolean;
|
||||
onInputChange?: ( value: string | undefined ) => void;
|
||||
onRemove?: ( item: ProductCategory ) => void;
|
||||
onSelect?: ( selected: ProductCategory ) => void;
|
||||
placeholder?: string;
|
||||
selected: ProductCategory[];
|
||||
};
|
||||
|
||||
return {
|
||||
...originalModule,
|
||||
__experimentalSelectControlMenu: ( {
|
||||
children,
|
||||
}: {
|
||||
children: JSX.Element;
|
||||
} ) => children,
|
||||
__experimentalSelectControl: ( {
|
||||
children,
|
||||
items,
|
||||
selected,
|
||||
}: SelectControlProps ) => {
|
||||
return (
|
||||
<div>
|
||||
[select-control]
|
||||
<div className="selected">
|
||||
{ selected.map( ( item ) => (
|
||||
<div key={ item.id }>{ item.name }</div>
|
||||
) ) }
|
||||
</div>
|
||||
<div className="children">
|
||||
{ children( {
|
||||
items,
|
||||
isOpen: true,
|
||||
getMenuProps: () => ( {} ),
|
||||
selectItem: () => {},
|
||||
highlightedIndex: -1,
|
||||
setInputValue: () => {},
|
||||
getItemProps: () => ( {} ),
|
||||
} ) }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
} );
|
||||
jest.mock( '@woocommerce/tracks', () => ( { recordEvent: jest.fn() } ) );
|
||||
|
||||
jest.mock( '../use-category-search', () => {
|
||||
const originalModule = jest.requireActual( '../use-category-search' );
|
||||
return {
|
||||
getCategoriesTreeWithMissingParents:
|
||||
originalModule.getCategoriesTreeWithMissingParents,
|
||||
useCategorySearch: jest.fn().mockReturnValue( {
|
||||
searchCategories: jest.fn(),
|
||||
getFilteredItems: jest.fn(),
|
||||
isSearching: false,
|
||||
categoriesSelectList: [],
|
||||
categoryTreeKeyValues: {},
|
||||
} ),
|
||||
};
|
||||
} );
|
||||
|
||||
describe( 'CategoryField', () => {
|
||||
beforeEach( () => {
|
||||
jest.clearAllMocks();
|
||||
} );
|
||||
|
||||
it( 'should render a dropdown select control', () => {
|
||||
const { queryByText } = render(
|
||||
<Form initialValues={ { categories: [] } }>
|
||||
{ ( { getInputProps }: FormContext< Product > ) => (
|
||||
<CategoryField
|
||||
label="Categories"
|
||||
placeholder="Search or create category…"
|
||||
{ ...getInputProps<
|
||||
Pick< ProductCategory, 'id' | 'name' >[]
|
||||
>( 'categories' ) }
|
||||
/>
|
||||
) }
|
||||
</Form>
|
||||
);
|
||||
expect( queryByText( '[select-control]' ) ).toBeInTheDocument();
|
||||
} );
|
||||
|
||||
it( 'should pass in the selected categories as select control items', () => {
|
||||
const { queryByText } = render(
|
||||
<Form
|
||||
initialValues={ {
|
||||
categories: [
|
||||
{ id: 2, name: 'Test' },
|
||||
{ id: 5, name: 'Clothing' },
|
||||
],
|
||||
} }
|
||||
>
|
||||
{ ( { getInputProps }: FormContext< Product > ) => (
|
||||
<CategoryField
|
||||
label="Categories"
|
||||
placeholder="Search or create category…"
|
||||
{ ...getInputProps<
|
||||
Pick< ProductCategory, 'id' | 'name' >[]
|
||||
>( 'categories' ) }
|
||||
/>
|
||||
) }
|
||||
</Form>
|
||||
);
|
||||
expect( queryByText( 'Test' ) ).toBeInTheDocument();
|
||||
expect( queryByText( 'Clothing' ) ).toBeInTheDocument();
|
||||
} );
|
||||
|
||||
describe( 'search values', () => {
|
||||
beforeEach( async () => {
|
||||
const items = await getCategoriesTreeWithMissingParents(
|
||||
mockCategoryList,
|
||||
''
|
||||
);
|
||||
( useCategorySearch as jest.Mock ).mockReturnValue( {
|
||||
searchCategories: jest.fn(),
|
||||
getFilteredItems: jest.fn(),
|
||||
isSearching: false,
|
||||
categoriesSelectList: items[ 0 ],
|
||||
categoryTreeKeyValues: items[ 2 ],
|
||||
} );
|
||||
} );
|
||||
|
||||
it( 'should display only the parent categories passed in to the categoriesSelectList', () => {
|
||||
const { queryByText } = render(
|
||||
<Form
|
||||
initialValues={ {
|
||||
categories: [],
|
||||
} }
|
||||
>
|
||||
{ ( { getInputProps }: FormContext< Product > ) => (
|
||||
<CategoryField
|
||||
label="Categories"
|
||||
placeholder="Search or create category…"
|
||||
{ ...getInputProps<
|
||||
Pick< ProductCategory, 'id' | 'name' >[]
|
||||
>( 'categories' ) }
|
||||
/>
|
||||
) }
|
||||
</Form>
|
||||
);
|
||||
expect(
|
||||
queryByText( mockCategoryList[ 0 ].name )
|
||||
).toBeInTheDocument();
|
||||
const childParent = queryByText(
|
||||
mockCategoryList[ 1 ].name
|
||||
)?.parentElement?.closest(
|
||||
'.woocommerce-category-field-dropdown__item-children'
|
||||
);
|
||||
expect( childParent ).toBeInTheDocument();
|
||||
expect( childParent?.className ).not.toMatch(
|
||||
'woocommerce-category-field-dropdown__item-open'
|
||||
);
|
||||
expect(
|
||||
queryByText( mockCategoryList[ 2 ].name )
|
||||
).toBeInTheDocument();
|
||||
} );
|
||||
|
||||
it( 'should show selected categories as selected', () => {
|
||||
const { getByLabelText } = render(
|
||||
<Form
|
||||
initialValues={ {
|
||||
categories: [ mockCategoryList[ 2 ] ],
|
||||
} }
|
||||
>
|
||||
{ ( { getInputProps }: FormContext< Product > ) => (
|
||||
<CategoryField
|
||||
label="Categories"
|
||||
placeholder="Search or create category…"
|
||||
{ ...getInputProps<
|
||||
Pick< ProductCategory, 'id' | 'name' >[]
|
||||
>( 'categories' ) }
|
||||
/>
|
||||
) }
|
||||
</Form>
|
||||
);
|
||||
const rainGearCheckbox = getByLabelText(
|
||||
mockCategoryList[ 2 ].name
|
||||
);
|
||||
expect( rainGearCheckbox ).toBeChecked();
|
||||
const clothingCheckbox = getByLabelText(
|
||||
mockCategoryList[ 0 ].name
|
||||
);
|
||||
expect( clothingCheckbox ).not.toBeChecked();
|
||||
} );
|
||||
|
||||
it( 'should show selected categories as selected', () => {
|
||||
const { getByLabelText } = render(
|
||||
<Form
|
||||
initialValues={ {
|
||||
categories: [ mockCategoryList[ 2 ] ],
|
||||
} }
|
||||
>
|
||||
{ ( { getInputProps }: FormContext< Product > ) => (
|
||||
<CategoryField
|
||||
label="Categories"
|
||||
placeholder="Search or create category…"
|
||||
{ ...getInputProps<
|
||||
Pick< ProductCategory, 'id' | 'name' >[]
|
||||
>( 'categories' ) }
|
||||
/>
|
||||
) }
|
||||
</Form>
|
||||
);
|
||||
const rainGearCheckbox = getByLabelText(
|
||||
mockCategoryList[ 2 ].name
|
||||
);
|
||||
expect( rainGearCheckbox ).toBeChecked();
|
||||
const clothingCheckbox = getByLabelText(
|
||||
mockCategoryList[ 0 ].name
|
||||
);
|
||||
expect( clothingCheckbox ).not.toBeChecked();
|
||||
} );
|
||||
|
||||
it( 'should include a toggle icon for parents that contain children', () => {
|
||||
const { getByLabelText } = render(
|
||||
<Form
|
||||
initialValues={ {
|
||||
categories: [ mockCategoryList[ 2 ] ],
|
||||
} }
|
||||
>
|
||||
{ ( { getInputProps }: FormContext< Product > ) => (
|
||||
<CategoryField
|
||||
label="Categories"
|
||||
placeholder="Search or create category…"
|
||||
{ ...getInputProps<
|
||||
Pick< ProductCategory, 'id' | 'name' >[]
|
||||
>( 'categories' ) }
|
||||
/>
|
||||
) }
|
||||
</Form>
|
||||
);
|
||||
const rainGearCheckboxParent = getByLabelText(
|
||||
mockCategoryList[ 0 ].name
|
||||
).parentElement?.closest(
|
||||
'.woocommerce-category-field-dropdown__item-content'
|
||||
);
|
||||
|
||||
expect(
|
||||
rainGearCheckboxParent?.querySelector( 'svg' )
|
||||
).toBeInTheDocument();
|
||||
} );
|
||||
|
||||
it( 'should allow user to toggle the parents using the svg button', () => {
|
||||
const { getByLabelText, queryByText } = render(
|
||||
<Form
|
||||
initialValues={ {
|
||||
categories: [ mockCategoryList[ 2 ] ],
|
||||
} }
|
||||
>
|
||||
{ ( { getInputProps }: FormContext< Product > ) => (
|
||||
<CategoryField
|
||||
label="Categories"
|
||||
placeholder="Search or create category…"
|
||||
{ ...getInputProps<
|
||||
Pick< ProductCategory, 'id' | 'name' >[]
|
||||
>( 'categories' ) }
|
||||
/>
|
||||
) }
|
||||
</Form>
|
||||
);
|
||||
const rainGearCheckboxParent = getByLabelText(
|
||||
mockCategoryList[ 0 ].name
|
||||
).parentElement?.closest(
|
||||
'.woocommerce-category-field-dropdown__item-content'
|
||||
);
|
||||
|
||||
const toggle = rainGearCheckboxParent?.querySelector( 'svg' );
|
||||
if ( toggle ) {
|
||||
fireEvent.click( toggle );
|
||||
}
|
||||
const childParent = queryByText(
|
||||
mockCategoryList[ 1 ].name
|
||||
)?.parentElement?.closest(
|
||||
'.woocommerce-category-field-dropdown__item-children'
|
||||
);
|
||||
expect( childParent ).toBeInTheDocument();
|
||||
expect( childParent?.className ).toMatch(
|
||||
'woocommerce-category-field-dropdown__item-open'
|
||||
);
|
||||
} );
|
||||
} );
|
||||
} );
|
|
@ -0,0 +1,408 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { act, renderHook } from '@testing-library/react-hooks';
|
||||
import { useSelect, resolveSelect } from '@wordpress/data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useCategorySearch } from '../use-category-search';
|
||||
|
||||
jest.mock( '@wordpress/data', () => ( {
|
||||
...jest.requireActual( '@wordpress/data' ),
|
||||
useSelect: jest.fn(),
|
||||
resolveSelect: jest.fn(),
|
||||
} ) );
|
||||
|
||||
const mockCategoryList = [
|
||||
{ id: 1, name: 'Clothing', parent: 0, count: 0 },
|
||||
{ id: 2, name: 'Hoodies', parent: 1, count: 0 },
|
||||
{ id: 4, name: 'Accessories', parent: 1, count: 0 },
|
||||
{ id: 5, name: 'Belts', parent: 4, count: 0 },
|
||||
{ id: 3, name: 'Rain gear', parent: 0, count: 0 },
|
||||
{ id: 6, name: 'Furniture', parent: 0, count: 0 },
|
||||
];
|
||||
|
||||
describe( 'useCategorySearch', () => {
|
||||
const getProductCategoriesMock = jest
|
||||
.fn()
|
||||
.mockReturnValue( [ ...mockCategoryList ] );
|
||||
const getProductCategoriesTotalCountMock = jest
|
||||
.fn()
|
||||
.mockReturnValue( mockCategoryList.length );
|
||||
const getProductCategoriesResolveMock = jest.fn();
|
||||
|
||||
beforeEach( () => {
|
||||
jest.clearAllMocks();
|
||||
( useSelect as jest.Mock ).mockImplementation( ( callback ) => {
|
||||
return callback( () => ( {
|
||||
getProductCategories: getProductCategoriesMock,
|
||||
getProductCategoriesTotalCount:
|
||||
getProductCategoriesTotalCountMock,
|
||||
} ) );
|
||||
} );
|
||||
( resolveSelect as jest.Mock ).mockImplementation( () => ( {
|
||||
getProductCategories: getProductCategoriesResolveMock,
|
||||
} ) );
|
||||
} );
|
||||
|
||||
it( 'should retrieve an initial list of product categories and generate a tree', async () => {
|
||||
getProductCategoriesMock.mockReturnValue( undefined );
|
||||
getProductCategoriesTotalCountMock.mockReturnValue(
|
||||
mockCategoryList.length
|
||||
);
|
||||
const { result, rerender, waitForNextUpdate } = renderHook( () =>
|
||||
useCategorySearch()
|
||||
);
|
||||
|
||||
act( () => {
|
||||
getProductCategoriesMock.mockReturnValue( [ ...mockCategoryList ] );
|
||||
rerender();
|
||||
} );
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect( result.current.categoriesSelectList.length ).toEqual(
|
||||
mockCategoryList.length
|
||||
);
|
||||
expect( result.current.categories.length ).toEqual(
|
||||
mockCategoryList.filter( ( c ) => c.parent === 0 ).length
|
||||
);
|
||||
} );
|
||||
|
||||
it( 'should return a correct tree for categories with each item containing a childrens property', async () => {
|
||||
getProductCategoriesMock.mockReturnValue( [ ...mockCategoryList ] );
|
||||
getProductCategoriesTotalCountMock.mockReturnValue(
|
||||
mockCategoryList.length
|
||||
);
|
||||
const { result, waitForNextUpdate } = renderHook( () =>
|
||||
useCategorySearch()
|
||||
);
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
const clothing = result.current.categories.find(
|
||||
( cat ) => cat.data.name === 'Clothing'
|
||||
);
|
||||
expect( clothing?.children[ 0 ].data.name ).toEqual( 'Accessories' );
|
||||
expect( clothing?.children[ 0 ].children[ 0 ].data.name ).toEqual(
|
||||
'Belts'
|
||||
);
|
||||
} );
|
||||
|
||||
it( 'should sort items by count first and then alphabetical', async () => {
|
||||
getProductCategoriesMock.mockReturnValue( [
|
||||
...mockCategoryList,
|
||||
{ id: 12, name: 'BB', parent: 0, count: 0 },
|
||||
{ id: 13, name: 'AA', parent: 0, count: 0 },
|
||||
{ id: 11, name: 'ZZZ', parent: 0, count: 20 },
|
||||
] );
|
||||
|
||||
getProductCategoriesTotalCountMock.mockReturnValue(
|
||||
mockCategoryList.length
|
||||
);
|
||||
const { result, waitForNextUpdate } = renderHook( () =>
|
||||
useCategorySearch()
|
||||
);
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect( result.current.categoriesSelectList[ 0 ].name ).toEqual(
|
||||
'ZZZ'
|
||||
);
|
||||
expect( result.current.categoriesSelectList[ 1 ].name ).toEqual( 'AA' );
|
||||
expect( result.current.categoriesSelectList[ 2 ].name ).toEqual( 'BB' );
|
||||
} );
|
||||
|
||||
it( 'should also sort children by count first and then alphabetical', async () => {
|
||||
getProductCategoriesMock.mockReturnValue( [
|
||||
...mockCategoryList,
|
||||
{ id: 12, name: 'AB', parent: 1, count: 0 },
|
||||
{ id: 13, name: 'AA', parent: 1, count: 0 },
|
||||
{ id: 11, name: 'ZZZ', parent: 1, count: 20 },
|
||||
] );
|
||||
|
||||
getProductCategoriesTotalCountMock.mockReturnValue(
|
||||
mockCategoryList.length
|
||||
);
|
||||
const { result, waitForNextUpdate } = renderHook( () =>
|
||||
useCategorySearch()
|
||||
);
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
const clothing = result.current.categories.find(
|
||||
( cat ) => cat.data.name === 'Clothing'
|
||||
);
|
||||
expect( clothing?.children[ 0 ].data.name ).toEqual( 'ZZZ' );
|
||||
expect( clothing?.children[ 1 ].data.name ).toEqual( 'AA' );
|
||||
expect( clothing?.children[ 2 ].data.name ).toEqual( 'AB' );
|
||||
} );
|
||||
|
||||
it( 'should order the select list by parent, child, nested child, parent', async () => {
|
||||
getProductCategoriesMock.mockReturnValue( [
|
||||
...mockCategoryList,
|
||||
{ id: 13, name: 'AA', parent: 1, count: 0 },
|
||||
{ id: 11, name: 'ZZ', parent: 1, count: 20 },
|
||||
] );
|
||||
|
||||
getProductCategoriesTotalCountMock.mockReturnValue(
|
||||
mockCategoryList.length
|
||||
);
|
||||
const { result, waitForNextUpdate } = renderHook( () =>
|
||||
useCategorySearch()
|
||||
);
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect( result.current.categoriesSelectList[ 0 ].name ).toEqual(
|
||||
'Clothing'
|
||||
);
|
||||
// child of clothing.
|
||||
expect( result.current.categoriesSelectList[ 1 ].name ).toEqual( 'ZZ' );
|
||||
expect( result.current.categoriesSelectList[ 2 ].name ).toEqual( 'AA' );
|
||||
expect( result.current.categoriesSelectList[ 3 ].name ).toEqual(
|
||||
'Accessories'
|
||||
);
|
||||
// child of accessories.
|
||||
expect( result.current.categoriesSelectList[ 4 ].name ).toEqual(
|
||||
'Belts'
|
||||
);
|
||||
// child of clothing.
|
||||
expect( result.current.categoriesSelectList[ 5 ].name ).toEqual(
|
||||
'Hoodies'
|
||||
);
|
||||
// top level.
|
||||
expect( result.current.categoriesSelectList[ 6 ].name ).toEqual(
|
||||
'Furniture'
|
||||
);
|
||||
} );
|
||||
|
||||
describe( 'getFilteredItems', () => {
|
||||
it( 'should filter items by label, matching input value, and if selected', async () => {
|
||||
getProductCategoriesMock.mockReturnValue( [ ...mockCategoryList ] );
|
||||
|
||||
getProductCategoriesTotalCountMock.mockReturnValue(
|
||||
mockCategoryList.length
|
||||
);
|
||||
const { result, waitForNextUpdate } = renderHook( () =>
|
||||
useCategorySearch()
|
||||
);
|
||||
await waitForNextUpdate();
|
||||
|
||||
const filteredItems = result.current.getFilteredItems(
|
||||
result.current.categoriesSelectList,
|
||||
'Rain',
|
||||
[]
|
||||
);
|
||||
expect( filteredItems.length ).toEqual( 1 );
|
||||
expect( filteredItems[ 0 ].name ).toEqual( 'Rain gear' );
|
||||
} );
|
||||
|
||||
it( 'should filter items by isOpen as well, keeping them if isOpen is true', async () => {
|
||||
getProductCategoriesMock.mockReturnValue( [ ...mockCategoryList ] );
|
||||
|
||||
getProductCategoriesTotalCountMock.mockReturnValue(
|
||||
mockCategoryList.length
|
||||
);
|
||||
const { result, waitForNextUpdate } = renderHook( () =>
|
||||
useCategorySearch()
|
||||
);
|
||||
await waitForNextUpdate();
|
||||
act( () => {
|
||||
result.current.searchCategories( 'Bel' );
|
||||
} );
|
||||
await waitForNextUpdate();
|
||||
expect( result.current.categoriesSelectList.length ).toEqual( 6 );
|
||||
const filteredItems = result.current.getFilteredItems(
|
||||
result.current.categoriesSelectList,
|
||||
'Bel',
|
||||
[]
|
||||
);
|
||||
expect( filteredItems.length ).toEqual( 3 );
|
||||
expect( filteredItems[ 0 ].name ).toEqual( 'Clothing' );
|
||||
expect( filteredItems[ 1 ].name ).toEqual( 'Accessories' );
|
||||
expect( filteredItems[ 2 ].name ).toEqual( 'Belts' );
|
||||
} );
|
||||
} );
|
||||
|
||||
describe( 'searchCategories', () => {
|
||||
it( 'should not use async when total categories is less then page size', async () => {
|
||||
getProductCategoriesResolveMock.mockClear();
|
||||
getProductCategoriesMock.mockReturnValue( [ ...mockCategoryList ] );
|
||||
|
||||
getProductCategoriesTotalCountMock.mockReturnValue(
|
||||
mockCategoryList.length
|
||||
);
|
||||
const { result, waitForNextUpdate } = renderHook( () =>
|
||||
useCategorySearch()
|
||||
);
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
act( () => {
|
||||
result.current.searchCategories( 'Clo' );
|
||||
} );
|
||||
|
||||
await waitForNextUpdate();
|
||||
expect( getProductCategoriesResolveMock ).not.toHaveBeenCalled();
|
||||
} );
|
||||
|
||||
it( 'should use async when total categories is more then page size', async () => {
|
||||
getProductCategoriesResolveMock
|
||||
.mockClear()
|
||||
.mockResolvedValue(
|
||||
mockCategoryList.filter( ( c ) => c.name === 'Clothing' )
|
||||
);
|
||||
getProductCategoriesMock.mockReturnValue( [ ...mockCategoryList ] );
|
||||
|
||||
getProductCategoriesTotalCountMock.mockReturnValue( 200 );
|
||||
const { result, waitForNextUpdate } = renderHook( () =>
|
||||
useCategorySearch()
|
||||
);
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
act( () => {
|
||||
result.current.searchCategories( 'Clo' );
|
||||
} );
|
||||
|
||||
await waitForNextUpdate();
|
||||
expect( getProductCategoriesResolveMock ).toHaveBeenCalledWith( {
|
||||
search: 'Clo',
|
||||
per_page: 100,
|
||||
} );
|
||||
expect( result.current.categoriesSelectList.length ).toEqual( 1 );
|
||||
} );
|
||||
|
||||
it( 'should update isSearching when async is enabled', async () => {
|
||||
let finish: () => void = () => {};
|
||||
getProductCategoriesResolveMock.mockClear().mockReturnValue(
|
||||
new Promise( ( resolve ) => {
|
||||
finish = () =>
|
||||
resolve(
|
||||
mockCategoryList.filter(
|
||||
( c ) => c.name === 'Clothing'
|
||||
)
|
||||
);
|
||||
} )
|
||||
);
|
||||
getProductCategoriesMock.mockReturnValue( [ ...mockCategoryList ] );
|
||||
getProductCategoriesTotalCountMock.mockReturnValue( 200 );
|
||||
|
||||
const { result, waitForNextUpdate } = renderHook( () =>
|
||||
useCategorySearch()
|
||||
);
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
act( () => {
|
||||
result.current.searchCategories( 'Clo' );
|
||||
} );
|
||||
expect( result.current.isSearching ).toBe( true );
|
||||
|
||||
act( () => {
|
||||
finish();
|
||||
} );
|
||||
await waitForNextUpdate();
|
||||
expect( result.current.isSearching ).toBe( false );
|
||||
expect( getProductCategoriesResolveMock ).toHaveBeenCalledWith( {
|
||||
search: 'Clo',
|
||||
per_page: 100,
|
||||
} );
|
||||
expect( result.current.categoriesSelectList.length ).toEqual( 1 );
|
||||
} );
|
||||
|
||||
it( 'should set isSearching back to false if search failed and keep last results', async () => {
|
||||
let finish: () => void = () => {};
|
||||
getProductCategoriesResolveMock.mockClear().mockReturnValue(
|
||||
new Promise( ( resolve, reject ) => {
|
||||
finish = () => reject();
|
||||
} )
|
||||
);
|
||||
getProductCategoriesMock.mockReturnValue( [ ...mockCategoryList ] );
|
||||
getProductCategoriesTotalCountMock.mockReturnValue( 200 );
|
||||
|
||||
const { result, waitForNextUpdate } = renderHook( () =>
|
||||
useCategorySearch()
|
||||
);
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
act( () => {
|
||||
result.current.searchCategories( 'Clo' );
|
||||
} );
|
||||
expect( result.current.isSearching ).toBe( true );
|
||||
|
||||
act( () => {
|
||||
finish();
|
||||
} );
|
||||
await waitForNextUpdate();
|
||||
expect( result.current.isSearching ).toBe( false );
|
||||
expect( getProductCategoriesResolveMock ).toHaveBeenCalledWith( {
|
||||
search: 'Clo',
|
||||
per_page: 100,
|
||||
} );
|
||||
expect( result.current.categoriesSelectList.length ).toEqual( 6 );
|
||||
} );
|
||||
|
||||
it( 'should keep parent in the list if only child matches search value', async () => {
|
||||
getProductCategoriesResolveMock
|
||||
.mockClear()
|
||||
.mockResolvedValue( [
|
||||
mockCategoryList.find( ( c ) => c.name === 'Hoodies' ),
|
||||
] );
|
||||
getProductCategoriesMock.mockReturnValue( [ ...mockCategoryList ] );
|
||||
getProductCategoriesTotalCountMock.mockReturnValue( 200 );
|
||||
|
||||
const { result, waitForNextUpdate } = renderHook( () =>
|
||||
useCategorySearch()
|
||||
);
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
act( () => {
|
||||
result.current.searchCategories( 'Hood' );
|
||||
} );
|
||||
await waitForNextUpdate();
|
||||
expect( getProductCategoriesResolveMock ).toHaveBeenCalledWith( {
|
||||
search: 'Hood',
|
||||
per_page: 100,
|
||||
} );
|
||||
expect( result.current.categoriesSelectList.length ).toEqual( 2 );
|
||||
expect( result.current.categoriesSelectList[ 0 ].name ).toEqual(
|
||||
'Clothing'
|
||||
);
|
||||
expect( result.current.categoriesSelectList[ 1 ].name ).toEqual(
|
||||
'Hoodies'
|
||||
);
|
||||
} );
|
||||
|
||||
it( 'should set parent isOpen to true if child matches search value', async () => {
|
||||
getProductCategoriesResolveMock
|
||||
.mockClear()
|
||||
.mockResolvedValue( [
|
||||
mockCategoryList.find( ( c ) => c.name === 'Hoodies' ),
|
||||
] );
|
||||
getProductCategoriesMock.mockReturnValue( [ ...mockCategoryList ] );
|
||||
getProductCategoriesTotalCountMock.mockReturnValue( 200 );
|
||||
|
||||
const { result, waitForNextUpdate } = renderHook( () =>
|
||||
useCategorySearch()
|
||||
);
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
act( () => {
|
||||
result.current.searchCategories( 'Hood' );
|
||||
} );
|
||||
await waitForNextUpdate();
|
||||
expect( getProductCategoriesResolveMock ).toHaveBeenCalledWith( {
|
||||
search: 'Hood',
|
||||
per_page: 100,
|
||||
} );
|
||||
expect( result.current.categories[ 0 ].isOpen ).toEqual( true );
|
||||
} );
|
||||
} );
|
||||
} );
|
|
@ -0,0 +1,277 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from '@wordpress/element';
|
||||
import { useSelect, resolveSelect } from '@wordpress/data';
|
||||
import {
|
||||
EXPERIMENTAL_PRODUCT_CATEGORIES_STORE_NAME,
|
||||
WCDataSelector,
|
||||
ProductCategory,
|
||||
} from '@woocommerce/data';
|
||||
import { escapeRegExp } from 'lodash';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { CategoryTreeItem } from './category-field-item';
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
const parentCategoryCache: Record< number, ProductCategory > = {};
|
||||
|
||||
/**
|
||||
* Recursive function to set isOpen to true for all the childrens parents.
|
||||
*/
|
||||
function openParents(
|
||||
treeList: Record< number, CategoryTreeItem >,
|
||||
item: CategoryTreeItem
|
||||
) {
|
||||
if ( treeList[ item.parentID ] ) {
|
||||
treeList[ item.parentID ].isOpen = true;
|
||||
if ( treeList[ item.parentID ].parentID !== 0 ) {
|
||||
openParents( treeList, treeList[ item.parentID ] );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort function for category tree items, sorts by popularity and then alphabetically.
|
||||
*/
|
||||
export const sortCategoryTreeItems = (
|
||||
menuItems: CategoryTreeItem[]
|
||||
): CategoryTreeItem[] => {
|
||||
return menuItems.sort( ( a, b ) => {
|
||||
if ( a.data.count === b.data.count ) {
|
||||
return a.data.name.localeCompare( b.data.name );
|
||||
}
|
||||
return b.data.count - a.data.count;
|
||||
} );
|
||||
};
|
||||
|
||||
/**
|
||||
* Flattens the category tree into a single list, also sorts the children of any parent tree item.
|
||||
*/
|
||||
function flattenCategoryTreeAndSortChildren(
|
||||
items: ProductCategory[] = [],
|
||||
treeItems: CategoryTreeItem[]
|
||||
) {
|
||||
for ( const treeItem of treeItems ) {
|
||||
items.push( treeItem.data );
|
||||
if ( treeItem.children.length > 0 ) {
|
||||
treeItem.children = sortCategoryTreeItems( treeItem.children );
|
||||
flattenCategoryTreeAndSortChildren( items, treeItem.children );
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursive function to turn a category list into a tree and retrieve any missing parents.
|
||||
* It checks if any parents are missing, and then does a single request to retrieve those, running this function again after.
|
||||
*/
|
||||
export async function getCategoriesTreeWithMissingParents(
|
||||
newCategories: ProductCategory[],
|
||||
search: string
|
||||
): Promise<
|
||||
[
|
||||
ProductCategory[],
|
||||
CategoryTreeItem[],
|
||||
Record< number, CategoryTreeItem >
|
||||
]
|
||||
> {
|
||||
const items: Record< number, CategoryTreeItem > = {};
|
||||
const missingParents: number[] = [];
|
||||
|
||||
for ( const cat of newCategories ) {
|
||||
items[ cat.id ] = {
|
||||
data: cat,
|
||||
children: [],
|
||||
parentID: cat.parent,
|
||||
isOpen: false,
|
||||
};
|
||||
}
|
||||
// Loops through each item and adds children to their parents by the use of parentID.
|
||||
Object.keys( items ).forEach( ( key ) => {
|
||||
const item = items[ parseInt( key, 10 ) ];
|
||||
if ( item.parentID !== 0 ) {
|
||||
// Check the parent cache incase the parent was missing and use that instead.
|
||||
if (
|
||||
! items[ item.parentID ] &&
|
||||
parentCategoryCache[ item.parentID ]
|
||||
) {
|
||||
items[ item.parentID ] = {
|
||||
data: parentCategoryCache[ item.parentID ],
|
||||
children: [],
|
||||
parentID: parentCategoryCache[ item.parentID ].parent,
|
||||
isOpen: false,
|
||||
};
|
||||
}
|
||||
if ( items[ item.parentID ] ) {
|
||||
items[ item.parentID ].children.push( item );
|
||||
parentCategoryCache[ item.parentID ] =
|
||||
items[ item.parentID ].data;
|
||||
// Open the parents if the child matches the search string.
|
||||
const searchRegex = new RegExp( escapeRegExp( search ), 'i' );
|
||||
if ( search.length > 0 && searchRegex.test( item.data.name ) ) {
|
||||
openParents( items, item );
|
||||
}
|
||||
} else {
|
||||
missingParents.push( item.parentID );
|
||||
}
|
||||
}
|
||||
} );
|
||||
|
||||
// Retrieve the missing parent objects incase not all of them were included.
|
||||
if ( missingParents.length > 0 ) {
|
||||
return resolveSelect( EXPERIMENTAL_PRODUCT_CATEGORIES_STORE_NAME )
|
||||
.getProductCategories( {
|
||||
include: missingParents,
|
||||
} )
|
||||
.then( ( parentCategories ) => {
|
||||
return getCategoriesTreeWithMissingParents(
|
||||
[
|
||||
...( parentCategories as ProductCategory[] ),
|
||||
...newCategories,
|
||||
],
|
||||
search
|
||||
);
|
||||
} );
|
||||
}
|
||||
const categoryTreeList = sortCategoryTreeItems(
|
||||
Object.values( items ).filter( ( item ) => item.parentID === 0 )
|
||||
);
|
||||
const categoryCheckboxList = flattenCategoryTreeAndSortChildren(
|
||||
[],
|
||||
categoryTreeList
|
||||
);
|
||||
|
||||
return Promise.resolve( [ categoryCheckboxList, categoryTreeList, items ] );
|
||||
}
|
||||
|
||||
const productCategoryQueryObject = {
|
||||
per_page: PAGE_SIZE,
|
||||
};
|
||||
|
||||
/**
|
||||
* A hook used to handle all the search logic for the category search component.
|
||||
* This hook also handles the data structure and provides a tree like structure see: CategoryTreeItema.
|
||||
*/
|
||||
export const useCategorySearch = () => {
|
||||
const lastSearchValue = useRef( '' );
|
||||
const { initialCategories, totalCount } = useSelect(
|
||||
( select: WCDataSelector ) => {
|
||||
const { getProductCategories, getProductCategoriesTotalCount } =
|
||||
select( EXPERIMENTAL_PRODUCT_CATEGORIES_STORE_NAME );
|
||||
return {
|
||||
initialCategories: getProductCategories(
|
||||
productCategoryQueryObject
|
||||
),
|
||||
totalCount: getProductCategoriesTotalCount(
|
||||
productCategoryQueryObject
|
||||
),
|
||||
};
|
||||
}
|
||||
);
|
||||
const [ isSearching, setIsSearching ] = useState( false );
|
||||
const [ categoriesAndNewItem, setCategoriesAndNewItem ] = useState<
|
||||
[
|
||||
ProductCategory[],
|
||||
CategoryTreeItem[],
|
||||
Record< number, CategoryTreeItem >
|
||||
]
|
||||
>( [ [], [], {} ] );
|
||||
const isAsync =
|
||||
! initialCategories ||
|
||||
( initialCategories.length > 0 && totalCount > PAGE_SIZE );
|
||||
|
||||
useEffect( () => {
|
||||
if (
|
||||
initialCategories &&
|
||||
initialCategories.length > 0 &&
|
||||
( categoriesAndNewItem[ 0 ].length === 0 ||
|
||||
lastSearchValue.current.length === 0 )
|
||||
) {
|
||||
setIsSearching( true );
|
||||
getCategoriesTreeWithMissingParents(
|
||||
[ ...initialCategories ],
|
||||
''
|
||||
).then(
|
||||
( categoryTree ) => {
|
||||
setCategoriesAndNewItem( categoryTree );
|
||||
setIsSearching( false );
|
||||
},
|
||||
() => {
|
||||
setIsSearching( false );
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [ initialCategories ] );
|
||||
|
||||
const searchCategories = useCallback(
|
||||
async ( search: string ): Promise< CategoryTreeItem[] > => {
|
||||
lastSearchValue.current = search;
|
||||
if ( ! isAsync && initialCategories.length > 0 ) {
|
||||
return getCategoriesTreeWithMissingParents(
|
||||
[ ...initialCategories ],
|
||||
search
|
||||
).then( ( categoryData ) => {
|
||||
setCategoriesAndNewItem( categoryData );
|
||||
return categoryData[ 1 ];
|
||||
} );
|
||||
}
|
||||
setIsSearching( true );
|
||||
try {
|
||||
const newCategories = await resolveSelect(
|
||||
EXPERIMENTAL_PRODUCT_CATEGORIES_STORE_NAME
|
||||
).getProductCategories( {
|
||||
search,
|
||||
per_page: PAGE_SIZE,
|
||||
} );
|
||||
|
||||
const categoryTreeData =
|
||||
await getCategoriesTreeWithMissingParents(
|
||||
newCategories as ProductCategory[],
|
||||
search || ''
|
||||
);
|
||||
setIsSearching( false );
|
||||
setCategoriesAndNewItem( categoryTreeData );
|
||||
return categoryTreeData[ 1 ];
|
||||
} catch ( e ) {
|
||||
setIsSearching( false );
|
||||
return [];
|
||||
}
|
||||
},
|
||||
[ initialCategories ]
|
||||
);
|
||||
|
||||
const categoryTreeKeyValues = categoriesAndNewItem[ 2 ];
|
||||
|
||||
/**
|
||||
* getFilteredItems callback for use in the SelectControl component.
|
||||
*/
|
||||
const getFilteredItems = useCallback(
|
||||
(
|
||||
allItems: Pick< ProductCategory, 'id' | 'name' >[],
|
||||
inputValue: string,
|
||||
selectedItems: Pick< ProductCategory, 'id' | 'name' >[]
|
||||
) => {
|
||||
const searchRegex = new RegExp( escapeRegExp( inputValue ), 'i' );
|
||||
return allItems.filter(
|
||||
( item ) =>
|
||||
selectedItems.indexOf( item ) < 0 &&
|
||||
( searchRegex.test( item.name ) ||
|
||||
( categoryTreeKeyValues[ item.id ] &&
|
||||
categoryTreeKeyValues[ item.id ].isOpen ) )
|
||||
);
|
||||
},
|
||||
[ categoriesAndNewItem ]
|
||||
);
|
||||
|
||||
return {
|
||||
searchCategories,
|
||||
getFilteredItems,
|
||||
categoriesSelectList: categoriesAndNewItem[ 0 ],
|
||||
categories: categoriesAndNewItem[ 1 ],
|
||||
isSearching,
|
||||
categoryTreeKeyValues,
|
||||
};
|
||||
};
|
|
@ -15,6 +15,7 @@ import { cleanForSlug } from '@wordpress/url';
|
|||
import { EnrichedLabel, useFormContext } from '@woocommerce/components';
|
||||
import {
|
||||
Product,
|
||||
ProductCategory,
|
||||
PRODUCTS_STORE_NAME,
|
||||
WCDataSelector,
|
||||
} from '@woocommerce/data';
|
||||
|
@ -27,6 +28,7 @@ import './product-details-section.scss';
|
|||
import { getCheckboxProps, getTextControlProps } from './utils';
|
||||
import { ProductSectionLayout } from '../layout/product-section-layout';
|
||||
import { EditProductLinkModal } from '../shared/edit-product-link-modal';
|
||||
import { CategoryField } from '../fields/category-field';
|
||||
|
||||
const PRODUCT_DETAILS_SLUG = 'product-details';
|
||||
|
||||
|
@ -111,6 +113,16 @@ export const ProductDetailsSection: React.FC = () => {
|
|||
</span>
|
||||
) }
|
||||
</div>
|
||||
<CategoryField
|
||||
label={ __( 'Categories', 'woocommerce' ) }
|
||||
placeholder={ __(
|
||||
'Search or create category…',
|
||||
'woocommerce'
|
||||
) }
|
||||
{ ...getInputProps<
|
||||
Pick< ProductCategory, 'id' | 'name' >[]
|
||||
>( 'categories' ) }
|
||||
/>
|
||||
<CheckboxControl
|
||||
label={
|
||||
<EnrichedLabel
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: add
|
||||
|
||||
Add new Category dropdown field to the new Product Management screen.
|
Loading…
Reference in New Issue