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 } - > - - + + +
); /* 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 + ) } +
    + ); + } } + +
    + + +
    +
    +
    + ); +}; 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