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:
louwie17 2022-10-14 09:05:39 -03:00 committed by GitHub
parent 9b9abd1eae
commit c55c91d7e0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 1512 additions and 70 deletions

View File

@ -0,0 +1,4 @@
Significance: minor
Type: update
Update experimental SelectControl compoment to expose a couple extra combobox functions from Downshift.

View File

@ -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% );
}

View File

@ -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;

View File

@ -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 )

View File

@ -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;
}
}
}

View File

@ -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() }

View File

@ -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

View File

@ -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';

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add getItemsTotalCount selector to the crud data store.

View File

@ -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

View File

@ -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;

View File

@ -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;
};

View File

@ -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>
);
};

View File

@ -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;
}
}

View File

@ -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>
);
};

View File

@ -0,0 +1 @@
export * from './category-field';

View File

@ -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'
);
} );
} );
} );

View File

@ -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 );
} );
} );
} );

View File

@ -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,
};
};

View File

@ -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

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add new Category dropdown field to the new Product Management screen.