Add/34 create new category field modal (#35132)

* Add initial category field component with new typeahead

Move search logic to useCategorySearch hook

Add initial add new category logic

Add parent category field to add new category modal

Adding some debug changes

Update category control to make use of internal selectItem function of select control

Add changelogs

Update pagesize back to 100

Add placeholder

Empty placeholder

Fix input and icon sizes

Fix input underline

Add max height and scroll to category dropdown

Add sorting of category items

Auto open parents when traversing up the tree using arrow keys

Add several comments

Add some initial unit tests for the category field component

Add tests for useCategorySearch hook and fixed minor bug

Update styling and autoselect parent if child is selected

Fix styling issues for the select control dropdown inside a modal

Fix issue with creating new category with parent

Add function comment and fixed border styling

* Fix up some issues after the rebase

* Some small fixes for the Category creation

* Fix up some styling issues around the add-new-item

* Add changelogs

* Remove unneeded export of toggle button props

* Fix create category error and tests in attribute field

* Fix some minor bugs and styling changes that came up during PR feedback

* Fix tests

* Make use of $gap variable for css
This commit is contained in:
louwie17 2022-11-16 12:30:42 -04:00 committed by GitHub
parent 7bff5cbb6b
commit 1550806efc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 492 additions and 121 deletions

View File

@ -0,0 +1,4 @@
Significance: minor
Type: update
Move classname down in SelectControl Menu so it is on the actual Menu element.

View File

@ -46,39 +46,41 @@ export const Menu = ( {
return (
<div
ref={ selectControlMenuRef }
className={ classnames(
'woocommerce-experimental-select-control__menu',
className
) }
className="woocommerce-experimental-select-control__menu"
>
<Popover
// @ts-expect-error this prop does exist, see: https://github.com/WordPress/gutenberg/blob/trunk/packages/components/src/popover/index.tsx#L180.
__unstableSlotName="woocommerce-select-control-menu"
focusOnMount={ false }
className={ classnames(
'woocommerce-experimental-select-control__popover-menu',
{
'is-open': isOpen,
'has-results': Children.count( children ) > 0,
}
) }
position="bottom center"
animate={ false }
>
<ul
{ ...getMenuProps() }
className="woocommerce-experimental-select-control__popover-menu-container"
style={ {
width: boundingRect?.width,
} }
onMouseUp={ ( e ) =>
// Fix to prevent select control dropdown from closing when selecting within the Popover.
e.stopPropagation()
}
<div>
<Popover
// @ts-expect-error this prop does exist, see: https://github.com/WordPress/gutenberg/blob/trunk/packages/components/src/popover/index.tsx#L180.
__unstableSlotName="woocommerce-select-control-menu"
focusOnMount={ false }
className={ classnames(
'woocommerce-experimental-select-control__popover-menu',
{
'is-open': isOpen,
'has-results': Children.count( children ) > 0,
}
) }
position="bottom right"
animate={ false }
>
{ isOpen && children }
</ul>
</Popover>
<ul
{ ...getMenuProps() }
className={ classnames(
'woocommerce-experimental-select-control__popover-menu-container',
className
) }
style={ {
width: boundingRect?.width,
} }
onMouseUp={ ( e ) =>
// Fix to prevent select control dropdown from closing when selecting within the Popover.
e.stopPropagation()
}
>
{ isOpen && children }
</ul>
</Popover>
</div>
</div>
);
/* eslint-enable jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/click-events-have-key-events */

View File

@ -37,7 +37,7 @@ import {
type SelectControlProps< ItemType > = {
children?: ChildrenType< ItemType >;
items: ItemType[];
label: string;
label: string | JSX.Element;
getItemLabel?: getItemLabelType< ItemType >;
getItemValue?: getItemValueType< ItemType >;
getFilteredItems?: (

View File

@ -194,6 +194,7 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
) }
/>
) }
<SelectControlMenuSlot />
</div>
</CardBody>
</Card>

View File

@ -1,10 +1,9 @@
/**
* External dependencies
*/
import { render, act, screen, waitFor } from '@testing-library/react';
import { render, act, screen } from '@testing-library/react';
import { useState, useEffect } from '@wordpress/element';
import { ProductAttribute } from '@woocommerce/data';
import { resolveSelect } from '@wordpress/data';
/**
* Internal dependencies
@ -73,8 +72,8 @@ jest.mock( '@wordpress/data', () => ( {
jest.mock( '@woocommerce/components', () => ( {
__esModule: true,
__experimentalSelectControlMenuSlot: () => <div></div>,
ListItem: ( { children }: { children: JSX.Element } ) => children,
__experimentalSelectControlMenuSlot: () => null,
Sortable: ( {
onOrderChange,
children,

View File

@ -0,0 +1,47 @@
/**
* 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';
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

@ -18,7 +18,6 @@ export type CategoryTreeItem = {
type CategoryFieldItemProps = {
item: CategoryTreeItem;
selectedIds: number[];
onSelect: ( item: ProductCategory ) => void;
items: Pick< ProductCategory, 'id' | 'name' >[];
highlightedIndex: number;
openParent?: () => void;
@ -30,7 +29,6 @@ type CategoryFieldItemProps = {
export const CategoryFieldItem: React.FC< CategoryFieldItemProps > = ( {
item,
selectedIds = [],
onSelect,
items,
highlightedIndex,
openParent,
@ -89,7 +87,7 @@ export const CategoryFieldItem: React.FC< CategoryFieldItemProps > = ( {
<CheckboxControl
label={ item.data.name }
checked={ selectedIds.includes( item.data.id ) }
onChange={ () => item.data /*&& onSelect( item.data )*/ }
onChange={ () => item.data }
/>
</div>
{ children.length > 0 ? (
@ -107,7 +105,6 @@ export const CategoryFieldItem: React.FC< CategoryFieldItemProps > = ( {
key={ child.data.id }
item={ child }
selectedIds={ selectedIds }
onSelect={ onSelect }
items={ items }
highlightedIndex={ highlightedIndex }
openParent={ () => ! isOpen && setIsOpen( true ) }

View File

@ -24,6 +24,11 @@
margin-top: 0;
}
}
&.is-new {
.category-field-dropdown__toggle {
margin-right: $gap-smaller;
}
}
}
&__item-content {
height: 48px;

View File

@ -1,11 +1,13 @@
/**
* External dependencies
*/
import { useMemo } from '@wordpress/element';
import { useMemo, useState } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import {
selectControlStateChangeTypes,
Spinner,
__experimentalSelectControl as SelectControl,
__experimentalSelectControlMenuSlot as MenuSlot,
__experimentalSelectControlMenu as Menu,
} from '@woocommerce/components';
import { ProductCategory } from '@woocommerce/data';
@ -17,6 +19,8 @@ import { debounce } from 'lodash';
import './category-field.scss';
import { CategoryFieldItem, CategoryTreeItem } from './category-field-item';
import { useCategorySearch } from './use-category-search';
import { CreateCategoryModal } from './create-category-modal';
import { CategoryFieldAddNewItem } from './category-field-add-new-item';
type CategoryFieldProps = {
label: string;
@ -36,9 +40,10 @@ function getSelectedWithParents(
): Pick< ProductCategory, 'id' | 'name' >[] {
selected.push( { id: item.id, name: item.name } );
const parentId = item.parent
? item.parent
: treeKeyValues[ item.id ].parentID;
const parentId =
item.parent !== undefined
? item.parent
: treeKeyValues[ item.id ].parentID;
if (
parentId > 0 &&
treeKeyValues[ parentId ] &&
@ -69,8 +74,11 @@ export const CategoryField: React.FC< CategoryFieldProps > = ( {
searchCategories,
getFilteredItems,
} = useCategorySearch();
const [ showCreateNewModal, setShowCreateNewModal ] = useState( false );
const [ searchValue, setSearchValue ] = useState( '' );
const onInputChange = ( searchString?: string ) => {
setSearchValue( searchString || '' );
searchCategories( searchString || '' );
};
@ -80,6 +88,10 @@ export const CategoryField: React.FC< CategoryFieldProps > = ( {
);
const onSelect = ( itemId: number, selected: boolean ) => {
if ( itemId === -99 ) {
setShowCreateNewModal( true );
return;
}
if ( selected ) {
const item = categoryTreeKeyValues[ itemId ].data;
if ( item ) {
@ -96,65 +108,97 @@ export const CategoryField: React.FC< CategoryFieldProps > = ( {
}
};
const categoryFieldGetFilteredItems = (
allItems: Pick< ProductCategory, 'id' | 'name' >[],
inputValue: string,
selectedItems: Pick< ProductCategory, 'id' | 'name' >[]
) => {
const filteredItems = getFilteredItems(
allItems,
inputValue,
selectedItems
);
if (
inputValue.length > 0 &&
! isSearching &&
! filteredItems.find(
( item ) => item.name.toLowerCase() === inputValue.toLowerCase()
)
) {
return [
...filteredItems,
{
id: -99,
name: inputValue,
},
];
}
return filteredItems;
};
const selectedIds = value.map( ( item ) => item.id );
const selectControlItems = categoriesSelectList;
return (
<SelectControl< Pick< ProductCategory, 'id' | 'name' > >
className="woocommerce-category-field-dropdown components-base-control"
multiple
items={ selectControlItems }
label={ label }
selected={ value }
getItemLabel={ ( item ) => item?.name || '' }
getItemValue={ ( item ) => item?.id || '' }
onSelect={ ( item ) => {
if ( item ) {
onSelect( item.id, ! selectedIds.includes( item.id ) );
}
} }
onRemove={ ( item ) => item && onSelect( item.id, false ) }
onInputChange={ searchDelayed }
getFilteredItems={ getFilteredItems }
placeholder={ value.length === 0 ? placeholder : '' }
stateReducer={ ( state, actionAndChanges ) => {
const { changes, type } = actionAndChanges;
switch ( type ) {
case selectControlStateChangeTypes.ControlledPropUpdatedSelectedItem:
return {
...changes,
inputValue: state.inputValue,
};
case selectControlStateChangeTypes.ItemClick:
return {
...changes,
isOpen: true,
inputValue: state.inputValue,
highlightedIndex: state.highlightedIndex,
};
default:
return changes;
}
} }
>
{ ( {
items,
isOpen,
getMenuProps,
getItemProps,
selectItem,
highlightedIndex,
} ) => {
const rootItems =
items.length > 0
? items.filter(
( item ) =>
categoryTreeKeyValues[ item.id ]
?.parentID === 0
)
: [];
return (
<>
<>
<SelectControl< Pick< ProductCategory, 'id' | 'name' > >
className="woocommerce-category-field-dropdown components-base-control"
multiple
items={ categoriesSelectList }
label={ label }
selected={ value }
getItemLabel={ ( item ) => item?.name || '' }
getItemValue={ ( item ) => item?.id || '' }
onSelect={ ( item ) => {
if ( item ) {
onSelect( item.id, ! selectedIds.includes( item.id ) );
}
} }
onRemove={ ( item ) => item && onSelect( item.id, false ) }
onInputChange={ searchDelayed }
getFilteredItems={ categoryFieldGetFilteredItems }
placeholder={ value.length === 0 ? placeholder : '' }
stateReducer={ ( state, actionAndChanges ) => {
const { changes, type } = actionAndChanges;
switch ( type ) {
case selectControlStateChangeTypes.ControlledPropUpdatedSelectedItem:
return {
...changes,
inputValue: state.inputValue,
};
case selectControlStateChangeTypes.ItemClick:
if (
changes.selectedItem &&
changes.selectedItem.id === -99
) {
return changes;
}
return {
...changes,
isOpen: true,
inputValue: state.inputValue,
highlightedIndex: state.highlightedIndex,
};
default:
return changes;
}
} }
>
{ ( {
items,
isOpen,
getMenuProps,
getItemProps,
highlightedIndex,
} ) => {
const rootItems =
items.length > 0
? items.filter(
( item ) =>
categoryTreeKeyValues[ item.id ]
?.parentID === 0 || item.id === -99
)
: [];
return (
<Menu
isOpen={ isOpen }
getMenuProps={ getMenuProps }
@ -169,26 +213,57 @@ export const CategoryField: React.FC< CategoryFieldProps > = ( {
</li>
) }
{ isOpen &&
rootItems.map( ( item ) => (
<CategoryFieldItem
key={ `${ item.id }` }
item={
categoryTreeKeyValues[ item.id ]
}
highlightedIndex={
highlightedIndex
}
selectedIds={ selectedIds }
onSelect={ selectItem }
items={ items }
getItemProps={ getItemProps }
/>
) ) }
rootItems.map( ( item ) => {
return item.id === -99 ? (
<CategoryFieldAddNewItem
key={ `${ item.id }` }
item={ item }
highlightedIndex={
highlightedIndex
}
items={ items }
getItemProps={ getItemProps }
/>
) : (
<CategoryFieldItem
key={ `${ item.id }` }
item={
categoryTreeKeyValues[
item.id
]
}
highlightedIndex={
highlightedIndex
}
selectedIds={ selectedIds }
items={ items }
getItemProps={ getItemProps }
/>
);
} ) }
</>
</Menu>
</>
);
} }
</SelectControl>
);
} }
</SelectControl>
<MenuSlot />
{ showCreateNewModal && (
<CreateCategoryModal
initialCategoryName={ searchValue }
onCancel={ () => setShowCreateNewModal( false ) }
onCreate={ ( newCategory ) => {
onChange(
getSelectedWithParents(
[ ...value ],
newCategory,
categoryTreeKeyValues
)
);
setShowCreateNewModal( false );
onInputChange( '' );
} }
/>
) }
</>
);
};

View File

@ -0,0 +1,33 @@
.woocommerce-create-new-category-modal {
min-width: 650px;
&__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

@ -0,0 +1,204 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import interpolateComponents from '@automattic/interpolate-components';
import { Button, Modal, Spinner, TextControl } from '@wordpress/components';
import { useDebounce } from '@wordpress/compose';
import { useState } from '@wordpress/element';
import { useDispatch } from '@wordpress/data';
import {
__experimentalSelectControl as SelectControl,
__experimentalSelectControlMenu as Menu,
} from '@woocommerce/components';
import { recordEvent } from '@woocommerce/tracks';
import {
EXPERIMENTAL_PRODUCT_CATEGORIES_STORE_NAME,
ProductCategory,
} from '@woocommerce/data';
/**
* Internal dependencies
*/
import './create-category-modal.scss';
import { useCategorySearch } from './use-category-search';
import { CategoryFieldItem } from './category-field-item';
type CreateCategoryModalProps = {
initialCategoryName?: string;
onCancel: () => void;
onCreate: ( newCategory: ProductCategory ) => void;
};
export const CreateCategoryModal: React.FC< CreateCategoryModalProps > = ( {
initialCategoryName,
onCancel,
onCreate,
} ) => {
const {
categoriesSelectList,
isSearching,
categoryTreeKeyValues,
searchCategories,
getFilteredItems,
} = 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< Pick<
ProductCategory,
'id' | 'name'
> | null >( null );
const onSave = async () => {
recordEvent( 'product_category_add', {
new_product_page: true,
} );
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 }
/>
<SelectControl< Pick< ProductCategory, 'id' | 'name' > >
items={ categoriesSelectList }
label={ interpolateComponents( {
mixedString: __(
'Parent category {{optional/}}',
'woocommerce'
),
components: {
optional: (
<span className="woocommerce-product-form__optional-input">
{ __( '(optional)', 'woocommerce' ) }
</span>
),
},
} ) }
selected={ categoryParent }
onSelect={ ( item ) => item && setCategoryParent( item ) }
onRemove={ () => setCategoryParent( null ) }
onInputChange={ debouncedSearch }
getFilteredItems={ getFilteredItems }
getItemLabel={ ( item ) => item?.name || '' }
getItemValue={ ( item ) => item?.id || '' }
>
{ ( {
items,
isOpen,
getMenuProps,
highlightedIndex,
getItemProps,
} ) => {
return (
<Menu
isOpen={ isOpen }
getMenuProps={ getMenuProps }
className="woocommerce-category-field-dropdown__menu"
>
{ [
isSearching ? (
<div
key="loading-spinner"
className="woocommerce-category-field-dropdown__item"
>
<div className="woocommerce-category-field-dropdown__item-content">
<Spinner />
</div>
</div>
) : null,
...items
.filter(
( item ) =>
categoryTreeKeyValues[ item.id ]
?.parentID === 0
)
.map( ( item ) => {
return (
<CategoryFieldItem
key={ `${ item.id }` }
item={
categoryTreeKeyValues[
item.id
]
}
selectedIds={
categoryParent
? [
categoryParent.id,
]
: []
}
items={ items }
highlightedIndex={
highlightedIndex
}
getItemProps={
getItemProps
}
/>
);
} ),
].filter(
( item ): item is JSX.Element =>
item !== null
) }
</Menu>
);
} }
</SelectControl>
<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

@ -207,12 +207,12 @@ export const useCategorySearch = () => {
}, [ initialCategories ] );
const searchCategories = useCallback(
async ( search: string ): Promise< CategoryTreeItem[] > => {
lastSearchValue.current = search;
async ( search?: string ): Promise< CategoryTreeItem[] > => {
lastSearchValue.current = search || '';
if ( ! isAsync && initialCategories.length > 0 ) {
return getCategoriesTreeWithMissingParents(
[ ...initialCategories ],
search
search || ''
).then( ( categoryData ) => {
setCategoriesAndNewItem( categoryData );
return categoryData[ 1 ];

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add add new option for the category dropdown within the product MVP