Migrate Category field to woocommerce/taxonomy-field block (#40021)

* Migrate category field to woocommerce/taxonomy-field block

* Remove details-categories-field

* Add changelogs

* Remove more references

* Rename block and add it to blockregistry

* Add missing setIsCreating calls

* Undo changelog change

* Add changelog
This commit is contained in:
Nathan Silveira 2023-09-06 10:21:37 -03:00 committed by GitHub
parent 5a8ed71edc
commit e9aad24125
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 22 additions and 1562 deletions

View File

@ -0,0 +1,4 @@
Significance: patch
Type: update
Rename woocommerce/taxonomy-field to woocommerce/product-taxonomy-field

View File

@ -0,0 +1,4 @@
Significance: patch
Type: tweak
Remove __experimentalDetailsCategoriesField and woocommerce/product-category-field block

View File

@ -1,33 +0,0 @@
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 2,
"name": "woocommerce/product-category-field",
"title": "Product Category",
"category": "widgets",
"description": "A field to select product categories.",
"keywords": [ "products", "category" ],
"textdomain": "default",
"attributes": {
"name": {
"type": "string",
"__experimentalRole": "content"
},
"label": {
"type": "string"
},
"placeholder": {
"type": "string"
}
},
"usesContext": [ "postType" ],
"supports": {
"align": false,
"html": false,
"multiple": false,
"reusable": false,
"inserter": false,
"lock": false,
"__experimentalToolbar": false
},
"editorStyle": "file:./editor.css"
}

View File

@ -1,45 +0,0 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { createElement } from '@wordpress/element';
import { BlockAttributes } from '@wordpress/blocks';
import { useBlockProps } from '@wordpress/block-editor';
import { ProductCategory } from '@woocommerce/data';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore No types for this exist yet.
// eslint-disable-next-line @woocommerce/dependency-group
import { useEntityProp } from '@wordpress/core-data';
/**
* Internal dependencies
*/
import { CategoryField } from '../../components/details-categories-field';
export function Edit( {
attributes,
context,
}: {
attributes: BlockAttributes;
context?: { postType?: string };
} ) {
const blockProps = useBlockProps();
const { name, label, placeholder } = attributes;
const [ categories, setCategories ] = useEntityProp<
Pick< ProductCategory, 'name' | 'id' | 'parent' >[]
>( 'postType', context?.postType || 'product', name || 'categories' );
return (
<div { ...blockProps }>
<CategoryField
label={ label || __( 'Categories', 'woocommerce' ) }
placeholder={
placeholder ||
__( 'Search or create category…', 'woocommerce' )
}
onChange={ setCategories }
value={ categories || [] }
/>
</div>
);
}

View File

@ -1,11 +0,0 @@
.wp-block-woocommerce-product-category-field {
.woocommerce-experimental-select-tree-control__menu {
.experimental-woocommerce-tree-item {
font-size: 13px;
.components-checkbox-control__input-container, input[type='checkbox'], .components-checkbox-control__checked {
width: 20px;
height: 20px;
}
}
}
}

View File

@ -1,18 +0,0 @@
/**
* Internal dependencies
*/
import { initBlock } from '../../utils';
import metadata from './block.json';
import { Edit } from './edit';
const { name } = metadata;
export { metadata, name };
export const settings = {
example: {},
edit: Edit,
};
export const init = () =>
initBlock( { name, metadata: metadata as never, settings } );

View File

@ -1,5 +1,4 @@
export { init as initCatalogVisibility } from './catalog-visibility';
export { init as initCategory } from './category';
export { init as initCheckbox } from './checkbox';
export { init as initCollapsible } from './collapsible';
export { init as initConditional } from './conditional';

View File

@ -1,5 +1,4 @@
@import 'attributes/editor.scss';
@import 'category/editor.scss';
@import 'checkbox/editor.scss';
@import 'images/editor.scss';
@import 'inventory-email/editor.scss';

View File

@ -1,4 +1,4 @@
# woocommerce/taxonomy-field block
# woocommerce/product-taxonomy-field block
This is a block that displays a taxonomy field, allowing searching, selection, and creation of new items, to be used in a product context.

View File

@ -1,7 +1,7 @@
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 2,
"name": "woocommerce/taxonomy-field",
"name": "woocommerce/product-taxonomy-field",
"title": "Taxonomy",
"category": "widgets",
"description": "A block that displays a taxonomy field, allowing searching, selection, and creation of new items",

View File

@ -67,6 +67,7 @@ export const CreateTaxonomyModal: React.FC< CreateTaxonomyModalProps > = ( {
const onSave = async () => {
setErrorMessage( null );
setIsCreating( true );
try {
const newTaxonomy: Taxonomy = await saveEntityRecord(
'taxonomy',
@ -79,6 +80,7 @@ export const CreateTaxonomyModal: React.FC< CreateTaxonomyModalProps > = ( {
throwOnError: true,
}
);
setIsCreating( false );
onCreate( newTaxonomy );
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch ( e: any ) {

View File

@ -1,48 +0,0 @@
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import { Icon } from '@wordpress/components';
import { plus } from '@wordpress/icons';
import classNames from 'classnames';
import { ProductCategory } from '@woocommerce/data';
import { __experimentalSelectControlMenuItemProps as MenuItemProps } from '@woocommerce/components';
import { createElement } from '@wordpress/element';
type CategoryFieldAddNewItemProps = {
item: Pick< ProductCategory, 'id' | 'name' >;
highlightedIndex: number;
items: Pick< ProductCategory, 'id' | 'name' >[];
} & Pick<
MenuItemProps< Pick< ProductCategory, 'id' | 'name' > >,
'getItemProps'
>;
export const CategoryFieldAddNewItem: React.FC<
CategoryFieldAddNewItemProps
> = ( { item, highlightedIndex, getItemProps, items } ) => {
const index = items.findIndex( ( i ) => i.id === item.id );
return (
<li
{ ...getItemProps( {
item,
index,
} ) }
className={ classNames(
'woocommerce-category-field-dropdown__item is-new',
{
item_highlighted: highlightedIndex === index,
}
) }
>
<div className="woocommerce-category-field-dropdown__item-content">
<Icon
className="category-field-dropdown__toggle"
icon={ plus }
size={ 20 }
/>
{ sprintf( __( 'Create "%s"', 'woocommerce' ), item.name ) }
</div>
</li>
);
};

View File

@ -1,121 +0,0 @@
/**
* External dependencies
*/
import { CheckboxControl, Icon } from '@wordpress/components';
import { useEffect, useState, createElement } from '@wordpress/element';
import { decodeEntities } from '@wordpress/html-entities';
import { chevronDown, chevronUp } from '@wordpress/icons';
import { ProductCategory } from '@woocommerce/data';
import { __experimentalSelectControlMenuItemProps as MenuItemProps } from '@woocommerce/components';
import classNames from 'classnames';
/**
* Internal dependencies
*/
import { ProductCategoryNode } from './use-category-search';
export type CategoryTreeItem = {
data: ProductCategory;
children: CategoryTreeItem[];
parentID: number;
isOpen: boolean;
};
type CategoryFieldItemProps = {
item: CategoryTreeItem;
selectedIds: number[];
items: ProductCategoryNode[];
highlightedIndex: number;
openParent?: () => void;
} & Pick< MenuItemProps< ProductCategoryNode >, 'getItemProps' >;
export const CategoryFieldItem: React.FC< CategoryFieldItemProps > = ( {
item,
selectedIds = [],
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={ decodeEntities( item.data.name ) }
checked={ selectedIds.includes( item.data.id ) }
onChange={ () => 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 }
items={ items }
highlightedIndex={ highlightedIndex }
openParent={ () => ! isOpen && setIsOpen( true ) }
getItemProps={ getItemProps }
/>
) ) }
</ul>
) : null }
</li>
);
};

View File

@ -1,196 +0,0 @@
/**
* External dependencies
*/
import { useMemo, useState, createElement, Fragment } from '@wordpress/element';
import {
TreeItemType,
__experimentalSelectTreeControl as SelectTree,
} from '@woocommerce/components';
import { ProductCategory } from '@woocommerce/data';
import { debounce } from 'lodash';
/**
* Internal dependencies
*/
import { CategoryTreeItem } from './category-field-item';
import { useCategorySearch, ProductCategoryNode } from './use-category-search';
import { CreateCategoryModal } from './create-category-modal';
type CategoryFieldProps = {
label: string;
placeholder: string;
value?: ProductCategoryNode[];
onChange: ( value: ProductCategoryNode[] ) => void;
};
/**
* Recursive function that adds the current item to the selected list and all it's parents
* if not included already.
*/
function getSelectedWithParents(
selected: ProductCategoryNode[] = [],
item: ProductCategory,
treeKeyValues: Record< number, CategoryTreeItem >
): ProductCategoryNode[] {
selected.push( { id: item.id, name: item.name, parent: item.parent } );
const parentId =
item.parent !== undefined
? 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 function mapFromCategoryToTreeItem(
val: ProductCategoryNode
): TreeItemType {
return val.parent
? {
value: String( val.id ),
label: val.name,
parent: String( val.parent ),
}
: {
value: String( val.id ),
label: val.name,
};
}
export function mapFromTreeItemToCategory(
val: TreeItemType
): ProductCategoryNode {
return {
id: +val.value,
name: val.label,
parent: val.parent ? +val.parent : 0,
};
}
export function mapFromCategoriesToTreeItems(
categories: ProductCategoryNode[]
): TreeItemType[] {
return categories.map( mapFromCategoryToTreeItem );
}
export function mapFromTreeItemsToCategories(
categories: TreeItemType[]
): ProductCategoryNode[] {
return categories.map( mapFromTreeItemToCategory );
}
export const CategoryField: React.FC< CategoryFieldProps > = ( {
label,
placeholder,
value = [],
onChange,
} ) => {
const {
isSearching,
categoriesSelectList,
categoryTreeKeyValues,
searchCategories,
getFilteredItemsForSelectTree,
} = useCategorySearch();
const [ showCreateNewModal, setShowCreateNewModal ] = useState( false );
const [ searchValue, setSearchValue ] = useState( '' );
const onInputChange = ( searchString?: string ) => {
setSearchValue( searchString || '' );
searchCategories( searchString || '' );
};
const searchDelayed = useMemo(
() => debounce( onInputChange, 150 ),
[ onInputChange ]
);
return (
<>
<SelectTree
id="category-field"
multiple
shouldNotRecursivelySelect
createValue={ searchValue }
label={ label }
isLoading={ isSearching }
onInputChange={ searchDelayed }
placeholder={ value.length === 0 ? placeholder : '' }
onCreateNew={ () => {
setShowCreateNewModal( true );
} }
shouldShowCreateButton={ ( typedValue ) =>
! typedValue ||
categoriesSelectList.findIndex(
( item ) => item.name === typedValue
) === -1
}
items={ getFilteredItemsForSelectTree(
mapFromCategoriesToTreeItems( categoriesSelectList ),
searchValue,
mapFromCategoriesToTreeItems( value )
) }
selected={ mapFromCategoriesToTreeItems( value ) }
onSelect={ ( selectedItems ) => {
if ( Array.isArray( selectedItems ) ) {
const newItems: ProductCategoryNode[] =
mapFromTreeItemsToCategories(
selectedItems.filter(
( { value: selectedItemValue } ) =>
! value.some(
( item ) =>
item.id === +selectedItemValue
)
)
);
onChange( [ ...value, ...newItems ] );
}
} }
onRemove={ ( removedItems ) => {
const newValues = Array.isArray( removedItems )
? value.filter(
( item ) =>
! removedItems.some(
( { value: removedValue } ) =>
item.id === +removedValue
)
)
: value.filter(
( item ) => item.id !== +removedItems.value
);
onChange( newValues );
} }
></SelectTree>
{ showCreateNewModal && (
<CreateCategoryModal
initialCategoryName={ searchValue }
onCancel={ () => setShowCreateNewModal( false ) }
onCreate={ ( newCategory ) => {
onChange(
getSelectedWithParents(
[ ...value ],
newCategory,
categoryTreeKeyValues
)
);
setShowCreateNewModal( false );
onInputChange( '' );
} }
/>
) }
</>
);
};

View File

@ -1,34 +0,0 @@
.woocommerce-create-new-category-modal {
min-width: 650px;
overflow: visible;
&__buttons {
margin-top: $gap-larger;
display: flex;
flex-direction: row;
gap: $gap-smaller;
justify-content: flex-end;
}
.category-field-dropdown {
&__menu {
padding: 0;
border: none;
}
}
}
.woocommerce-select-control__popover-menu {
margin-top: -$gap-small;
}
.woocommerce-select-control__popover-menu-container {
max-height: 300px;
overflow-y: scroll;
padding: 0 $gap-smaller 0 $gap-small;
> .category-field-dropdown__item:not(:first-child) {
.category-field-dropdown__item-content {
border-top: 1px solid $gray-200;
}
}
}

View File

@ -1,162 +0,0 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Button, Modal, TextControl } from '@wordpress/components';
import { useDebounce } from '@wordpress/compose';
import {
useState,
createElement,
createInterpolateElement,
} from '@wordpress/element';
import { useDispatch } from '@wordpress/data';
import {
__experimentalSelectTreeControl as SelectTree,
TreeItemType as Item,
} from '@woocommerce/components';
import { recordEvent } from '@woocommerce/tracks';
import {
EXPERIMENTAL_PRODUCT_CATEGORIES_STORE_NAME,
ProductCategory,
} from '@woocommerce/data';
/**
* Internal dependencies
*/
import { ProductCategoryNode, useCategorySearch } from './use-category-search';
import {
mapFromCategoriesToTreeItems,
mapFromCategoryToTreeItem,
} from './category-field';
import { TRACKS_SOURCE } from '../../constants';
type CreateCategoryModalProps = {
initialCategoryName?: string;
onCancel: () => void;
onCreate: ( newCategory: ProductCategory ) => void;
};
export const CreateCategoryModal: React.FC< CreateCategoryModalProps > = ( {
initialCategoryName,
onCancel,
onCreate,
} ) => {
const {
categoriesSelectList,
isSearching,
searchCategories,
getFilteredItemsForSelectTree,
} = useCategorySearch();
const { createNotice } = useDispatch( 'core/notices' );
const [ isCreating, setIsCreating ] = useState( false );
const { createProductCategory, invalidateResolutionForStoreSelector } =
useDispatch( EXPERIMENTAL_PRODUCT_CATEGORIES_STORE_NAME );
const [ categoryName, setCategoryName ] = useState(
initialCategoryName || ''
);
const [ categoryParent, setCategoryParent ] =
useState< ProductCategoryNode | null >( null );
const [ categoryParentTypedValue, setCategoryParentTypedValue ] =
useState( '' );
const onSave = async () => {
recordEvent( 'product_category_add', {
source: TRACKS_SOURCE,
} );
setIsCreating( true );
try {
const newCategory: ProductCategory = await createProductCategory( {
name: categoryName,
parent: categoryParent ? categoryParent.id : undefined,
} );
invalidateResolutionForStoreSelector( 'getProductCategories' );
setIsCreating( false );
onCreate( newCategory );
} catch ( e ) {
createNotice(
'error',
__( 'Failed to create category.', 'woocommerce' )
);
setIsCreating( false );
onCancel();
}
};
const debouncedSearch = useDebounce( searchCategories, 250 );
return (
<Modal
title={ __( 'Create category', 'woocommerce' ) }
onRequestClose={ () => onCancel() }
className="woocommerce-create-new-category-modal"
>
<div className="woocommerce-create-new-category-modal__wrapper">
<TextControl
label={ __( 'Name', 'woocommerce' ) }
name="Tops"
value={ categoryName }
onChange={ setCategoryName }
/>
<SelectTree
label={ createInterpolateElement(
__( 'Parent category <optional/>', 'woocommerce' ),
{
optional: (
<span className="woocommerce-product-form__optional-input">
{ __( '(optional)', 'woocommerce' ) }
</span>
),
}
) }
id="parent-category-field"
isLoading={ isSearching }
items={ getFilteredItemsForSelectTree(
mapFromCategoriesToTreeItems( categoriesSelectList ),
categoryParentTypedValue,
[]
) }
shouldNotRecursivelySelect
selected={
categoryParent
? mapFromCategoryToTreeItem( categoryParent )
: undefined
}
onSelect={ ( item: Item ) =>
item &&
setCategoryParent( {
id: +item.value,
name: item.label,
parent: item.parent ? +item.parent : 0,
} )
}
onRemove={ () => setCategoryParent( null ) }
onInputChange={ ( value ) => {
debouncedSearch( value );
setCategoryParentTypedValue( value || '' );
} }
createValue={ categoryParentTypedValue }
/>
<div className="woocommerce-create-new-category-modal__buttons">
<Button
isSecondary
onClick={ () => onCancel() }
disabled={ isCreating }
>
{ __( 'Cancel', 'woocommerce' ) }
</Button>
<Button
isPrimary
disabled={ categoryName.length === 0 || isCreating }
isBusy={ isCreating }
onClick={ () => {
onSave();
} }
>
{ __( 'Save', 'woocommerce' ) }
</Button>
</div>
</div>
</Modal>
);
};

View File

@ -1,25 +0,0 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useFormContext } from '@woocommerce/components';
import { Product } from '@woocommerce/data';
import { createElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import { CategoryField } from './category-field';
import { ProductCategoryNode } from './use-category-search';
export const DetailsCategoriesField = () => {
const { getInputProps } = useFormContext< Product >();
return (
<CategoryField
label={ __( 'Categories', 'woocommerce' ) }
placeholder={ __( 'Search or create category…', 'woocommerce' ) }
{ ...getInputProps< ProductCategoryNode[] >( 'categories' ) }
/>
);
};

View File

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

View File

@ -1,69 +0,0 @@
.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;
}
}
&.is-new {
.category-field-dropdown__toggle {
margin-right: $gap-smaller;
}
}
}
&__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 {
&__combox-box-icon {
box-sizing: unset;
}
}

View File

@ -1,79 +0,0 @@
/**
* External dependencies
*/
import { render } from '@testing-library/react';
import { Form, FormContextType } from '@woocommerce/components';
import { Product } from '@woocommerce/data';
import { createElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import { CategoryField } from '../category-field';
import { ProductCategoryNode } from '../use-category-search';
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(),
getFilteredItemsForSelectTree: jest.fn().mockReturnValue( [] ),
isSearching: false,
categoriesSelectList: [],
categoryTreeKeyValues: {},
} ),
};
} );
describe( 'CategoryField', () => {
beforeEach( () => {
jest.clearAllMocks();
} );
it( 'should render a dropdown select control', () => {
const { queryByText, queryByPlaceholderText } = render(
<Form initialValues={ { categories: [] } }>
{ ( { getInputProps }: FormContextType< Product > ) => (
<CategoryField
label="Categories"
placeholder="Search or create category…"
{ ...getInputProps< ProductCategoryNode[] >(
'categories'
) }
/>
) }
</Form>
);
queryByPlaceholderText( 'Search or create category…' )?.focus();
expect( queryByText( 'Create new' ) ).toBeInTheDocument();
} );
it( 'should pass in the selected categories as select control items', () => {
const { queryAllByText, queryByPlaceholderText } = render(
<Form
initialValues={ {
categories: [
{ id: 2, name: 'Test' },
{ id: 5, name: 'Clothing' },
],
} }
>
{ ( { getInputProps }: FormContextType< Product > ) => (
<CategoryField
label="Categories"
placeholder="Search or create category…"
{ ...getInputProps< ProductCategoryNode[] >(
'categories'
) }
/>
) }
</Form>
);
queryByPlaceholderText( 'Search or create category…' )?.focus();
expect( queryAllByText( 'Test, Clothing' ) ).toHaveLength( 1 );
} );
} );

View File

@ -1,413 +0,0 @@
/**
* External dependencies
*/
import { act, renderHook } from '@testing-library/react-hooks';
import { useSelect, resolveSelect } from '@wordpress/data';
/**
* Internal dependencies
*/
import { useCategorySearch } from '../use-category-search';
import { mapFromCategoriesToTreeItems } from '../../details-categories-field/category-field';
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( 'getFilteredItemsForSelectTree', () => {
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.getFilteredItemsForSelectTree(
mapFromCategoriesToTreeItems(
result.current.categoriesSelectList
),
'Rain',
[]
);
expect( filteredItems.length ).toEqual( 1 );
expect( filteredItems[ 0 ].label ).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.getFilteredItemsForSelectTree(
mapFromCategoriesToTreeItems(
result.current.categoriesSelectList
),
'Bel',
[]
);
expect( filteredItems.length ).toEqual( 3 );
expect( filteredItems[ 0 ].label ).toEqual( 'Clothing' );
expect( filteredItems[ 1 ].label ).toEqual( 'Accessories' );
expect( filteredItems[ 2 ].label ).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

@ -1,286 +0,0 @@
/**
* 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';
import { TreeItemType } from '@woocommerce/components';
/**
* 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 ] );
}
}
}
export type ProductCategoryNode = Pick<
ProductCategory,
'id' | 'name' | 'parent'
>;
/**
* 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,
} )
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
.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(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
( select: WCDataSelector ) => {
const { getProductCategories, getProductCategoriesTotalCount } =
select( EXPERIMENTAL_PRODUCT_CATEGORIES_STORE_NAME );
return {
initialCategories: getProductCategories(
productCategoryQueryObject
),
totalCount: getProductCategoriesTotalCount(
productCategoryQueryObject
),
};
}
);
const [ isSearching, setIsSearching ] = useState( true );
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 ];
const getFilteredItemsForSelectTree = useCallback(
(
allItems: TreeItemType[],
inputValue: string,
selectedItems: TreeItemType[]
) => {
const searchRegex = new RegExp( escapeRegExp( inputValue ), 'i' );
return allItems.filter(
( item ) =>
selectedItems.indexOf( item ) < 0 &&
( searchRegex.test( item.label ) ||
( categoryTreeKeyValues[ +item.value ] &&
categoryTreeKeyValues[ +item.value ].isOpen ) )
);
},
[ categoriesAndNewItem ]
);
return {
searchCategories,
getFilteredItemsForSelectTree,
categoriesSelectList: categoriesAndNewItem[ 0 ],
categories: categoriesAndNewItem[ 1 ],
isSearching,
categoryTreeKeyValues,
};
};

View File

@ -7,7 +7,6 @@ export { WooProductSectionItem as __experimentalWooProductSectionItem } from './
export { WooProductTabItem as __experimentalWooProductTabItem } from './woo-product-tab-item';
export { DetailsNameField as __experimentalDetailsNameField } from './details-name-field';
export { DetailsFeatureField as __experimentalDetailsFeatureField } from './details-feature-field';
export { DetailsCategoriesField as __experimentalDetailsCategoriesField } from './details-categories-field';
export { DetailsSummaryField as __experimentalDetailsSummaryField } from './details-summary-field';
export { DetailsDescriptionField as __experimentalDetailsDescriptionField } from './details-description-field';
export { WooProductMoreMenuItem as __experimentalWooProductMoreMenuItem } from './header';

View File

@ -17,15 +17,11 @@
@import "components/radio-field/style.scss";
@import "components/notice/style.scss";
@import "components/iframe-editor/style.scss";
@import "components/details-categories-field/style.scss";
@import "components/details-categories-field/create-category-modal.scss";
@import "components/modal-editor/style.scss";
@import "components/feedback-bar/style.scss";
@import "components/product-mvp-feedback-modal/style.scss";
@import "components/edit-product-link-modal/style.scss";
@import "components/edit-product-link-modal/style.scss";
@import "components/details-categories-field/style.scss";
@import "components/details-categories-field/create-category-modal.scss";
@import "components/attribute-control/attribute-field.scss";
@import "components/attribute-control/edit-attribute-modal.scss";
@import "components/attribute-control/new-attribute-modal.scss";

View File

@ -8,7 +8,6 @@ import {
__experimentalWooProductFieldItem as WooProductFieldItem,
__experimentalDetailsNameField as DetailsNameField,
__experimentalDetailsFeatureField as DetailsFeatureField,
__experimentalDetailsCategoriesField as DetailsCategoriesField,
__experimentalDetailsSummaryField as DetailsSummaryField,
__experimentalDetailsDescriptionField as DetailsDescriptionField,
DETAILS_SECTION_ID,
@ -44,13 +43,6 @@ export const DetailsSectionFills = () => (
>
<DetailsNameField />
</WooProductFieldItem>
<WooProductFieldItem
id="categories"
sections={ [ { name: DETAILS_SECTION_ID, order: 3 } ] }
pluginId={ PLUGIN_ID }
>
<DetailsCategoriesField />
</WooProductFieldItem>
<WooProductFieldItem
id="feature"
sections={ [ { name: DETAILS_SECTION_ID, order: 5 } ] }

View File

@ -0,0 +1,4 @@
Significance: patch
Type: tweak
Migrate category field to woocommerce/product-taxonomy-field block

View File

@ -22,7 +22,6 @@ class BlockRegistry {
const PRODUCT_BLOCKS = [
'woocommerce/conditional',
'woocommerce/product-catalog-visibility-field',
'woocommerce/product-category-field',
'woocommerce/product-checkbox-field',
'woocommerce/product-collapsible',
'woocommerce/product-description-field',
@ -46,6 +45,7 @@ class BlockRegistry {
'woocommerce/product-variations-fields',
'woocommerce/product-password-field',
'woocommerce/product-has-variations-notice',
'woocommerce/product-taxonomy-field',
];
/**

View File

@ -268,10 +268,13 @@ class SimpleProductTemplate extends AbstractProductFormTemplate implements Produ
$product_catalog_section->add_block(
[
'id' => 'product-categories',
'blockName' => 'woocommerce/product-category-field',
'blockName' => 'woocommerce/product-taxonomy-field',
'order' => 10,
'attributes' => [
'name' => 'categories',
'slug' => 'product_cat',
'property' => 'categories',
'label' => __( 'Categories', 'woocommerce' ),
'createTitle' => __( 'Create new category', 'woocommerce' ),
],
]
);