diff --git a/packages/js/components/changelog/add-34_create_new_category_field_modal b/packages/js/components/changelog/add-34_create_new_category_field_modal
new file mode 100644
index 00000000000..4bfc4ae8b9b
--- /dev/null
+++ b/packages/js/components/changelog/add-34_create_new_category_field_modal
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+Move classname down in SelectControl Menu so it is on the actual Menu element.
diff --git a/packages/js/components/src/experimental-select-control/menu.tsx b/packages/js/components/src/experimental-select-control/menu.tsx
index 60a8cbb6e4e..13680d9349d 100644
--- a/packages/js/components/src/experimental-select-control/menu.tsx
+++ b/packages/js/components/src/experimental-select-control/menu.tsx
@@ -46,39 +46,41 @@ export const Menu = ( {
return (
-
0,
- }
- ) }
- position="bottom center"
- animate={ false }
- >
-
- // Fix to prevent select control dropdown from closing when selecting within the Popover.
- e.stopPropagation()
- }
+
+
0,
+ }
+ ) }
+ position="bottom right"
+ animate={ false }
>
- { isOpen && children }
-
-
+
+ // Fix to prevent select control dropdown from closing when selecting within the Popover.
+ e.stopPropagation()
+ }
+ >
+ { isOpen && children }
+
+
+
);
/* eslint-enable jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/click-events-have-key-events */
diff --git a/packages/js/components/src/experimental-select-control/select-control.tsx b/packages/js/components/src/experimental-select-control/select-control.tsx
index 8545e652112..83a25ca716b 100644
--- a/packages/js/components/src/experimental-select-control/select-control.tsx
+++ b/packages/js/components/src/experimental-select-control/select-control.tsx
@@ -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?: (
diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-field/attribute-field.tsx b/plugins/woocommerce-admin/client/products/fields/attribute-field/attribute-field.tsx
index 9fea1840def..b502135d1cd 100644
--- a/plugins/woocommerce-admin/client/products/fields/attribute-field/attribute-field.tsx
+++ b/plugins/woocommerce-admin/client/products/fields/attribute-field/attribute-field.tsx
@@ -194,6 +194,7 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
) }
/>
) }
+
diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-field/test/attribute-field.spec.tsx b/plugins/woocommerce-admin/client/products/fields/attribute-field/test/attribute-field.spec.tsx
index 542a1fe7e07..5eb5801b01b 100644
--- a/plugins/woocommerce-admin/client/products/fields/attribute-field/test/attribute-field.spec.tsx
+++ b/plugins/woocommerce-admin/client/products/fields/attribute-field/test/attribute-field.spec.tsx
@@ -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: () =>
,
ListItem: ( { children }: { children: JSX.Element } ) => children,
- __experimentalSelectControlMenuSlot: () => null,
Sortable: ( {
onOrderChange,
children,
diff --git a/plugins/woocommerce-admin/client/products/fields/category-field/category-field-add-new-item.tsx b/plugins/woocommerce-admin/client/products/fields/category-field/category-field-add-new-item.tsx
new file mode 100644
index 00000000000..e181f3d9a5d
--- /dev/null
+++ b/plugins/woocommerce-admin/client/products/fields/category-field/category-field-add-new-item.tsx
@@ -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 (
+
+
+
+ { sprintf( __( 'Create "%s"', 'woocommerce' ), item.name ) }
+
+
+ );
+};
diff --git a/plugins/woocommerce-admin/client/products/fields/category-field/category-field-item.tsx b/plugins/woocommerce-admin/client/products/fields/category-field/category-field-item.tsx
index 1236d61591a..a2c54aaad21 100644
--- a/plugins/woocommerce-admin/client/products/fields/category-field/category-field-item.tsx
+++ b/plugins/woocommerce-admin/client/products/fields/category-field/category-field-item.tsx
@@ -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 > = ( {
item.data /*&& onSelect( item.data )*/ }
+ onChange={ () => item.data }
/>
{ 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 ) }
diff --git a/plugins/woocommerce-admin/client/products/fields/category-field/category-field.scss b/plugins/woocommerce-admin/client/products/fields/category-field/category-field.scss
index a80d03fe3eb..f94d941255f 100644
--- a/plugins/woocommerce-admin/client/products/fields/category-field/category-field.scss
+++ b/plugins/woocommerce-admin/client/products/fields/category-field/category-field.scss
@@ -24,6 +24,11 @@
margin-top: 0;
}
}
+ &.is-new {
+ .category-field-dropdown__toggle {
+ margin-right: $gap-smaller;
+ }
+ }
}
&__item-content {
height: 48px;
diff --git a/plugins/woocommerce-admin/client/products/fields/category-field/category-field.tsx b/plugins/woocommerce-admin/client/products/fields/category-field/category-field.tsx
index 9bab0b74b9a..8b366a6a77f 100644
--- a/plugins/woocommerce-admin/client/products/fields/category-field/category-field.tsx
+++ b/plugins/woocommerce-admin/client/products/fields/category-field/category-field.tsx
@@ -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 (
- >
- 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 (
- <>
+ <>
+ >
+ 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 (
= ( {
) }
{ isOpen &&
- rootItems.map( ( item ) => (
-
- ) ) }
+ rootItems.map( ( item ) => {
+ return item.id === -99 ? (
+
+ ) : (
+
+ );
+ } ) }
>
- >
- );
- } }
-
+ );
+ } }
+
+
+ { showCreateNewModal && (
+ setShowCreateNewModal( false ) }
+ onCreate={ ( newCategory ) => {
+ onChange(
+ getSelectedWithParents(
+ [ ...value ],
+ newCategory,
+ categoryTreeKeyValues
+ )
+ );
+ setShowCreateNewModal( false );
+ onInputChange( '' );
+ } }
+ />
+ ) }
+ >
);
};
diff --git a/plugins/woocommerce-admin/client/products/fields/category-field/create-category-modal.scss b/plugins/woocommerce-admin/client/products/fields/category-field/create-category-modal.scss
new file mode 100644
index 00000000000..4c8ead0e0da
--- /dev/null
+++ b/plugins/woocommerce-admin/client/products/fields/category-field/create-category-modal.scss
@@ -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;
+ }
+ }
+}
diff --git a/plugins/woocommerce-admin/client/products/fields/category-field/create-category-modal.tsx b/plugins/woocommerce-admin/client/products/fields/category-field/create-category-modal.tsx
new file mode 100644
index 00000000000..d9540cd3265
--- /dev/null
+++ b/plugins/woocommerce-admin/client/products/fields/category-field/create-category-modal.tsx
@@ -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 (
+ onCancel() }
+ className="woocommerce-create-new-category-modal"
+ >
+
+
+
>
+ items={ categoriesSelectList }
+ label={ interpolateComponents( {
+ mixedString: __(
+ 'Parent category {{optional/}}',
+ 'woocommerce'
+ ),
+ components: {
+ optional: (
+
+ { __( '(optional)', 'woocommerce' ) }
+
+ ),
+ },
+ } ) }
+ 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 (
+
+ { [
+ isSearching ? (
+
+ ) : null,
+ ...items
+ .filter(
+ ( item ) =>
+ categoryTreeKeyValues[ item.id ]
+ ?.parentID === 0
+ )
+ .map( ( item ) => {
+ return (
+
+ );
+ } ),
+ ].filter(
+ ( item ): item is JSX.Element =>
+ item !== null
+ ) }
+
+ );
+ } }
+
+
+ onCancel() }
+ disabled={ isCreating }
+ >
+ { __( 'Cancel', 'woocommerce' ) }
+
+ {
+ onSave();
+ } }
+ >
+ { __( 'Save', 'woocommerce' ) }
+
+
+
+
+ );
+};
diff --git a/plugins/woocommerce-admin/client/products/fields/category-field/use-category-search.ts b/plugins/woocommerce-admin/client/products/fields/category-field/use-category-search.ts
index 5c42cdf6008..99e9396c063 100644
--- a/plugins/woocommerce-admin/client/products/fields/category-field/use-category-search.ts
+++ b/plugins/woocommerce-admin/client/products/fields/category-field/use-category-search.ts
@@ -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 ];
diff --git a/plugins/woocommerce/changelog/add-34_create_new_category_field_modal b/plugins/woocommerce/changelog/add-34_create_new_category_field_modal
new file mode 100644
index 00000000000..8051976db5a
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-34_create_new_category_field_modal
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add add new option for the category dropdown within the product MVP