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:
parent
5a8ed71edc
commit
e9aad24125
|
@ -0,0 +1,4 @@
|
|||
Significance: patch
|
||||
Type: update
|
||||
|
||||
Rename woocommerce/taxonomy-field to woocommerce/product-taxonomy-field
|
|
@ -0,0 +1,4 @@
|
|||
Significance: patch
|
||||
Type: tweak
|
||||
|
||||
Remove __experimentalDetailsCategoriesField and woocommerce/product-category-field block
|
|
@ -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"
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 } );
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 ) {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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( '' );
|
||||
} }
|
||||
/>
|
||||
) }
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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' ) }
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -1,2 +0,0 @@
|
|||
export * from './details-categories-field';
|
||||
export * from './category-field';
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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 );
|
||||
} );
|
||||
} );
|
|
@ -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 );
|
||||
} );
|
||||
} );
|
||||
} );
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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';
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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 } ] }
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
Significance: patch
|
||||
Type: tweak
|
||||
|
||||
Migrate category field to woocommerce/product-taxonomy-field block
|
|
@ -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',
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
@ -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' ),
|
||||
],
|
||||
]
|
||||
);
|
||||
|
|
Loading…
Reference in New Issue