From b42da82e5073f72d0cc9c7682e7d9a86d07ea35f Mon Sep 17 00:00:00 2001 From: louwie17 Date: Wed, 19 Apr 2023 04:28:18 -0300 Subject: [PATCH] Update/34885 category field in product editor (#36869) * Add initial custom meta box for product categories * Make use of TreeSelectControl * Update classnames * Display selected items and sync with most used tab * Always show placeholder and remove checklist container * Reactify category metabox tabs * Add create new category logic * Remove unused markup * Fix saving of empty category list * Add callback when input is cleared as well * Some small cleanup and refactoring. * Add changelog * Fix tree creation and style enqueue * Auto fix lint errors * Fix linting errors * Fix css lint errors * Add 100 limit, and address some PR feedback * Fix some styling and warnings * Remove unused code * Address PR feedback * Fix lint error * Fix lint errors * Address PR feedback * Fix lint error * Minor fixes and add tracking * Add debounce * Fix lint error * Allow custom min filter amount and fix menu not showing after escaping input * Allow single item to be cleared out of select control * Fix bug where typed values did not show up * Fix some styling issues * Allow parents to be individually selected * Address PR feedback and add error message * Add changelogs * Fix saving issue * Add client side sorting and stop clearing field upon selection * Update changelog * Create feature flag for async product categories dropdown * Fix lint errors * Fix linting --- ...ory_field_in_product_editor_select_control | 4 + ...egory_field_in_product_editor_tree_changes | 4 + .../experimental-select-control/combo-box.tsx | 3 +- .../select-control.tsx | 14 +- .../src/tree-select-control/index.js | 28 ++- .../client/typings/global.d.ts | 3 + .../all-category-list.tsx | 211 ++++++++++++++++++ .../category-add-new.tsx | 194 ++++++++++++++++ .../category-handlers.js | 25 +++ .../category-metabox.tsx | 154 +++++++++++++ .../product-category-metabox/index.js | 29 +++ .../popular-category-list.tsx | 97 ++++++++ .../product-category-metabox/style.scss | 64 ++++++ plugins/woocommerce-admin/webpack.config.js | 1 + ...ate-34885_category_field_in_product_editor | 4 + .../woocommerce/client/admin/config/core.json | 3 +- .../client/admin/config/development.json | 3 +- .../includes/admin/class-wc-admin-assets.php | 2 + .../class-wc-meta-box-product-categories.php | 73 ++++++ .../woocommerce/includes/class-wc-ajax.php | 78 ++++++- .../AsyncProductEditorCategoryField/Init.php | 85 +++++++ 21 files changed, 1063 insertions(+), 16 deletions(-) create mode 100644 packages/js/components/changelog/update-34885_category_field_in_product_editor_select_control create mode 100644 packages/js/components/changelog/update-34885_category_field_in_product_editor_tree_changes create mode 100644 plugins/woocommerce-admin/client/wp-admin-scripts/product-category-metabox/all-category-list.tsx create mode 100644 plugins/woocommerce-admin/client/wp-admin-scripts/product-category-metabox/category-add-new.tsx create mode 100644 plugins/woocommerce-admin/client/wp-admin-scripts/product-category-metabox/category-handlers.js create mode 100644 plugins/woocommerce-admin/client/wp-admin-scripts/product-category-metabox/category-metabox.tsx create mode 100644 plugins/woocommerce-admin/client/wp-admin-scripts/product-category-metabox/index.js create mode 100644 plugins/woocommerce-admin/client/wp-admin-scripts/product-category-metabox/popular-category-list.tsx create mode 100644 plugins/woocommerce-admin/client/wp-admin-scripts/product-category-metabox/style.scss create mode 100644 plugins/woocommerce/changelog/update-34885_category_field_in_product_editor create mode 100644 plugins/woocommerce/includes/admin/meta-boxes/class-wc-meta-box-product-categories.php create mode 100644 plugins/woocommerce/src/Admin/Features/AsyncProductEditorCategoryField/Init.php diff --git a/packages/js/components/changelog/update-34885_category_field_in_product_editor_select_control b/packages/js/components/changelog/update-34885_category_field_in_product_editor_select_control new file mode 100644 index 00000000000..f4635d9f8c6 --- /dev/null +++ b/packages/js/components/changelog/update-34885_category_field_in_product_editor_select_control @@ -0,0 +1,4 @@ +Significance: minor +Type: fix + +Fix issue where single item can not be cleared and text can not be selected upon click. diff --git a/packages/js/components/changelog/update-34885_category_field_in_product_editor_tree_changes b/packages/js/components/changelog/update-34885_category_field_in_product_editor_tree_changes new file mode 100644 index 00000000000..95ce9ecb8af --- /dev/null +++ b/packages/js/components/changelog/update-34885_category_field_in_product_editor_tree_changes @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add minFilterQueryLength, individuallySelectParent, and clearOnSelect props. diff --git a/packages/js/components/src/experimental-select-control/combo-box.tsx b/packages/js/components/src/experimental-select-control/combo-box.tsx index d4a53d32c94..a71a323ff94 100644 --- a/packages/js/components/src/experimental-select-control/combo-box.tsx +++ b/packages/js/components/src/experimental-select-control/combo-box.tsx @@ -45,9 +45,8 @@ export const ComboBox = ( { return; } - event.preventDefault(); - if ( document.activeElement !== inputRef.current ) { + event.preventDefault(); inputRef.current.focus(); event.stopPropagation(); } 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 5caa65e4717..7957e980d55 100644 --- a/packages/js/components/src/experimental-select-control/select-control.tsx +++ b/packages/js/components/src/experimental-select-control/select-control.tsx @@ -177,8 +177,13 @@ function SelectControl< ItemType = DefaultItemType >( { items: filteredItems, selectedItem: multiple ? null : singleSelectedItem, itemToString: getItemLabel, - onSelectedItemChange: ( { selectedItem } ) => - selectedItem && onSelect( selectedItem ), + onSelectedItemChange: ( { selectedItem } ) => { + if ( selectedItem ) { + onSelect( selectedItem ); + } else if ( singleSelectedItem ) { + onRemove( singleSelectedItem ); + } + }, onInputValueChange: ( { inputValue: value, ...changes } ) => { if ( value !== undefined ) { setInputValue( value ); @@ -193,8 +198,13 @@ function SelectControl< ItemType = DefaultItemType >( { // Set input back to selected item if there is a selected item, blank otherwise. newChanges = { ...changes, + selectedItem: + ! changes.inputValue?.length && ! multiple + ? null + : changes.selectedItem, inputValue: changes.selectedItem === state.selectedItem && + changes.inputValue?.length && ! multiple ? getItemLabel( comboboxSingleSelectedItem ) : '', diff --git a/packages/js/components/src/tree-select-control/index.js b/packages/js/components/src/tree-select-control/index.js index fffb28d9f23..86b4026e492 100644 --- a/packages/js/components/src/tree-select-control/index.js +++ b/packages/js/components/src/tree-select-control/index.js @@ -64,6 +64,7 @@ import { ARROW_DOWN, ARROW_UP, ENTER, ESCAPE, ROOT_VALUE } from './constants'; * @param {string} [props.className] The class name for this component * @param {boolean} [props.disabled] Disables the component * @param {boolean} [props.includeParent] Includes parent with selection. + * @param {boolean} [props.individuallySelectParent] Considers parent as a single item (default: false). * @param {boolean} [props.alwaysShowPlaceholder] Will always show placeholder (default: false) * @param {Option[]} [props.options] Options to show in the component * @param {string[]} [props.value] Selected values @@ -71,6 +72,8 @@ import { ARROW_DOWN, ARROW_UP, ENTER, ESCAPE, ROOT_VALUE } from './constants'; * @param {Function} [props.onChange] Callback when the selector changes * @param {(visible: boolean) => void} [props.onDropdownVisibilityChange] Callback when the visibility of the dropdown options is changed. * @param {Function} [props.onInputChange] Callback when the selector changes + * @param {number} [props.minFilterQueryLength] Minimum input length to filter results by. + * @param {boolean} [props.clearOnSelect] Clear input on select (default: true). * @return {JSX.Element} The component */ const TreeSelectControl = ( { @@ -88,7 +91,10 @@ const TreeSelectControl = ( { onDropdownVisibilityChange = noop, onInputChange = noop, includeParent = false, + individuallySelectParent = false, alwaysShowPlaceholder = false, + minFilterQueryLength = 3, + clearOnSelect = true, } ) => { let instanceId = useInstanceId( TreeSelectControl ); instanceId = id ?? instanceId; @@ -126,7 +132,8 @@ const TreeSelectControl = ( { const filterQuery = inputControlValue.trim().toLowerCase(); // we only trigger the filter when there are more than 3 characters in the input. - const filter = filterQuery.length >= 3 ? filterQuery : ''; + const filter = + filterQuery.length >= minFilterQueryLength ? filterQuery : ''; /** * Optimizes the performance for getting the tags info @@ -419,9 +426,11 @@ const TreeSelectControl = ( { */ const handleParentChange = ( checked, option ) => { let newValue; - const changedValues = option.leaves - .filter( ( opt ) => opt.checked !== checked ) - .map( ( opt ) => opt.value ); + const changedValues = individuallySelectParent + ? [] + : option.leaves + .filter( ( opt ) => opt.checked !== checked ) + .map( ( opt ) => opt.value ); if ( includeParent && option.value !== ROOT_VALUE ) { changedValues.push( option.value ); } @@ -452,10 +461,12 @@ const TreeSelectControl = ( { handleSingleChange( checked, option, parent ); } - onInputChange( '' ); - setInputControlValue( '' ); - if ( ! nodesExpanded.includes( option.parent ) ) { - controlRef.current.focus(); + if ( clearOnSelect ) { + onInputChange( '' ); + setInputControlValue( '' ); + if ( ! nodesExpanded.includes( option.parent ) ) { + controlRef.current.focus(); + } } }; @@ -475,6 +486,7 @@ const TreeSelectControl = ( { * @param {Event} e Event returned by the On Change function in the Input control */ const handleOnInputChange = ( e ) => { + setTreeVisible( true ); onInputChange( e.target.value ); setInputControlValue( e.target.value ); }; diff --git a/plugins/woocommerce-admin/client/typings/global.d.ts b/plugins/woocommerce-admin/client/typings/global.d.ts index 88b1a9564e8..ef6a9c65c56 100644 --- a/plugins/woocommerce-admin/client/typings/global.d.ts +++ b/plugins/woocommerce-admin/client/typings/global.d.ts @@ -48,6 +48,9 @@ declare global { isDirty: () => boolean; }; }; + getUserSetting?: ( name: string ) => string | undefined; + setUserSetting?: ( name: string, value: string ) => void; + deleteUserSetting?: ( name: string ) => void; } } diff --git a/plugins/woocommerce-admin/client/wp-admin-scripts/product-category-metabox/all-category-list.tsx b/plugins/woocommerce-admin/client/wp-admin-scripts/product-category-metabox/all-category-list.tsx new file mode 100644 index 00000000000..0524ccc06b0 --- /dev/null +++ b/plugins/woocommerce-admin/client/wp-admin-scripts/product-category-metabox/all-category-list.tsx @@ -0,0 +1,211 @@ +/** + * External dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useState, +} from '@wordpress/element'; +import { addQueryArgs } from '@wordpress/url'; +import { useDebounce } from '@wordpress/compose'; +import { TreeSelectControl } from '@woocommerce/components'; +import { getSetting } from '@woocommerce/settings'; +import { recordEvent } from '@woocommerce/tracks'; +import apiFetch from '@wordpress/api-fetch'; + +/** + * Internal dependencies + */ +import { CATEGORY_TERM_NAME } from './category-handlers'; +import { CategoryTerm } from './popular-category-list'; + +declare const wc_product_category_metabox_params: { + search_categories_nonce: string; +}; + +type CategoryTreeItem = CategoryTerm & { + children?: CategoryTreeItem[]; +}; + +type CategoryTreeItemLabelValue = { + children: CategoryTreeItemLabelValue[]; + label: string; + value: string; +}; + +export const DEFAULT_DEBOUNCE_TIME = 250; + +const categoryLibrary: Record< number, CategoryTreeItem > = {}; +function convertTreeToLabelValue( + tree: CategoryTreeItem[], + newTree: CategoryTreeItemLabelValue[] = [] +) { + for ( const child of tree ) { + const newItem = { + label: child.name, + value: child.term_id.toString(), + children: [], + }; + categoryLibrary[ child.term_id ] = child; + newTree.push( newItem ); + if ( child.children?.length ) { + convertTreeToLabelValue( child.children, newItem.children ); + } + } + newTree.sort( + ( a: CategoryTreeItemLabelValue, b: CategoryTreeItemLabelValue ) => { + const nameA = a.label.toUpperCase(); + const nameB = b.label.toUpperCase(); + if ( nameA < nameB ) { + return -1; + } + if ( nameA > nameB ) { + return 1; + } + return 0; + } + ); + return newTree; +} + +async function getTreeItems( filter: string ) { + const resp = await apiFetch< CategoryTreeItem[] >( { + url: addQueryArgs( + new URL( 'admin-ajax.php', getSetting( 'adminUrl' ) ).toString(), + { + term: filter, + action: 'woocommerce_json_search_categories_tree', + // eslint-disable-next-line no-undef, camelcase + security: + wc_product_category_metabox_params.search_categories_nonce, + } + ), + method: 'GET', + } ); + if ( resp ) { + return convertTreeToLabelValue( Object.values( resp ) ); + } + return []; +} + +export const AllCategoryList = forwardRef< + { resetInitialValues: () => void }, + { + selectedCategoryTerms: CategoryTerm[]; + onChange: ( selected: CategoryTerm[] ) => void; + } +>( ( { selectedCategoryTerms, onChange }, ref ) => { + const [ filter, setFilter ] = useState( '' ); + const [ treeItems, setTreeItems ] = useState< + CategoryTreeItemLabelValue[] + >( [] ); + + const searchCategories = useCallback( + ( value: string ) => { + if ( value && value.length > 0 ) { + recordEvent( 'product_category_search', { + page: 'product', + async: true, + search_string_length: value.length, + } ); + } + getTreeItems( value ).then( ( res ) => { + setTreeItems( Object.values( res ) ); + } ); + }, + [ setTreeItems ] + ); + const searchCategoriesDebounced = useDebounce( + searchCategories, + DEFAULT_DEBOUNCE_TIME + ); + + useEffect( () => { + searchCategoriesDebounced( filter ); + }, [ filter ] ); + + useImperativeHandle( + ref, + () => { + return { + resetInitialValues() { + getTreeItems( '' ).then( ( res ) => { + setTreeItems( Object.values( res ) ); + } ); + }, + }; + }, + [] + ); + + return ( + <> +
+ + category.term_id.toString() + ) } + onChange={ ( selectedCategoryIds: number[] ) => { + onChange( + selectedCategoryIds.map( + ( id ) => categoryLibrary[ id ] + ) + ); + recordEvent( 'product_category_update', { + page: 'product', + async: true, + selected: selectedCategoryIds.length, + } ); + } } + selectAllLabel={ false } + onInputChange={ setFilter } + placeholder={ __( 'Add category', 'woocommerce' ) } + includeParent={ true } + minFilterQueryLength={ 2 } + clearOnSelect={ false } + individuallySelectParent={ true } + /> +
+ + + ); +} ); diff --git a/plugins/woocommerce-admin/client/wp-admin-scripts/product-category-metabox/category-add-new.tsx b/plugins/woocommerce-admin/client/wp-admin-scripts/product-category-metabox/category-add-new.tsx new file mode 100644 index 00000000000..1e64ac10c59 --- /dev/null +++ b/plugins/woocommerce-admin/client/wp-admin-scripts/product-category-metabox/category-add-new.tsx @@ -0,0 +1,194 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { useCallback, useState } from '@wordpress/element'; +import { addQueryArgs } from '@wordpress/url'; +import { getSetting } from '@woocommerce/settings'; +import { + useAsyncFilter, + __experimentalSelectControl as SelectControl, +} from '@woocommerce/components'; +import { useUser } from '@woocommerce/data'; +import { recordEvent } from '@woocommerce/tracks'; +import apiFetch from '@wordpress/api-fetch'; + +/** + * Internal dependencies + */ +import { CATEGORY_TERM_NAME } from './category-handlers'; +import { CategoryTerm } from './popular-category-list'; + +declare const wc_product_category_metabox_params: { + search_categories_nonce: string; +}; + +function getCategoryTermLabel( item: CategoryTerm | null ): string { + return item?.name || ''; +} + +function getCategoryTermKey( item: CategoryTerm | null ): string { + return String( item?.term_id ); +} + +export const CategoryAddNew: React.FC< { + selectedCategoryTerms: CategoryTerm[]; + onChange: ( selected: CategoryTerm[] ) => void; +} > = ( { selectedCategoryTerms, onChange } ) => { + const [ showAddNew, setShowAddNew ] = useState( false ); + const [ newCategoryName, setNewCategoryName ] = useState( '' ); + const [ categoryCreateError, setCategoryCreateError ] = useState( '' ); + const [ categoryParent, setCategoryParent ] = useState< CategoryTerm >(); + const [ fetchedItems, setFetchedItems ] = useState< CategoryTerm[] >( [] ); + const { currentUserCan } = useUser(); + + const canEditTerms = currentUserCan( 'edit_product_terms' ); + + const onCreate = ( event: React.MouseEvent< HTMLInputElement > ) => { + event.preventDefault(); + if ( ! newCategoryName ) { + return; + } + + const data = { + name: newCategoryName, + parent: categoryParent?.term_id ?? -1, + }; + setCategoryCreateError( '' ); + apiFetch< { + id: number; + name: string; + count: number; + parent: number; + } >( { + path: '/wc/v3/products/categories', + data, + method: 'POST', + } ) + .then( ( res ) => { + if ( res ) { + recordEvent( 'product_category_add', { + category_id: res.id, + parent_id: res.parent, + parent_category: res.parent > 0 ? 'Other' : 'None', + page: 'product', + async: true, + } ); + onChange( [ + ...selectedCategoryTerms, + { term_id: res.id, name: res.name, count: res.count }, + ] ); + setNewCategoryName( '' ); + setCategoryParent( undefined ); + setShowAddNew( false ); + } + } ) + .catch( ( error ) => { + if ( error && error.message ) { + setCategoryCreateError( error.message ); + } + } ); + }; + + const filter: ( value: string ) => Promise< CategoryTerm[] > = useCallback( + async ( value = '' ) => { + setFetchedItems( [] ); + return apiFetch< CategoryTerm[] >( { + url: addQueryArgs( + new URL( + 'admin-ajax.php', + getSetting( 'adminUrl' ) + ).toString(), + { + term: value, + action: 'woocommerce_json_search_categories', + // eslint-disable-next-line no-undef, camelcase + security: + wc_product_category_metabox_params.search_categories_nonce, + } + ), + method: 'GET', + } ).then( ( response ) => { + if ( response ) { + setFetchedItems( Object.values( response ) ); + } + return []; + } ); + }, + [] + ); + + const { isFetching, ...selectProps } = useAsyncFilter< CategoryTerm >( { + filter, + } ); + + if ( ! canEditTerms ) { + return null; + } + + return ( +
+ setShowAddNew( ! showAddNew ) } + aria-label={ __( 'Add new category', 'woocommerce' ) } + > + { __( '+ Add new category', 'woocommerce' ) } + + { showAddNew && ( +
+ + + setNewCategoryName( event.target.value ) + } + aria-required="true" + /> + + + { ...selectProps } + label={ __( 'Parent category:', 'woocommerce' ) } + items={ fetchedItems } + selected={ categoryParent || null } + placeholder={ __( 'Find category', 'woocommerce' ) } + onSelect={ setCategoryParent } + getItemLabel={ getCategoryTermLabel } + getItemValue={ getCategoryTermKey } + onRemove={ () => setCategoryParent( undefined ) } + /> + { categoryCreateError && ( +

+ { categoryCreateError } +

+ ) } + +
+ ) } +
+ ); +}; diff --git a/plugins/woocommerce-admin/client/wp-admin-scripts/product-category-metabox/category-handlers.js b/plugins/woocommerce-admin/client/wp-admin-scripts/product-category-metabox/category-handlers.js new file mode 100644 index 00000000000..831b455e64e --- /dev/null +++ b/plugins/woocommerce-admin/client/wp-admin-scripts/product-category-metabox/category-handlers.js @@ -0,0 +1,25 @@ +export const CATEGORY_TERM_NAME = 'product_cat'; + +export function getCategoryDataFromElement( element ) { + if ( element && element.dataset && element.dataset.name ) { + return { + term_id: parseInt( element.value, 10 ), + name: element.dataset.name, + }; + } + return null; +} + +export function getSelectedCategoryData( container ) { + if ( container ) { + const selectedCategories = Array.from( + container.querySelectorAll( ':scope > input[type=hidden]' ) + ).map( ( categoryElement ) => { + const id = getCategoryDataFromElement( categoryElement ); + categoryElement.remove(); + return id; + } ); + return selectedCategories; + } + return []; +} diff --git a/plugins/woocommerce-admin/client/wp-admin-scripts/product-category-metabox/category-metabox.tsx b/plugins/woocommerce-admin/client/wp-admin-scripts/product-category-metabox/category-metabox.tsx new file mode 100644 index 00000000000..94fd76d9f9c --- /dev/null +++ b/plugins/woocommerce-admin/client/wp-admin-scripts/product-category-metabox/category-metabox.tsx @@ -0,0 +1,154 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { useRef, useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { CATEGORY_TERM_NAME } from './category-handlers'; +import { AllCategoryList } from './all-category-list'; +import { CategoryTerm, PopularCategoryList } from './popular-category-list'; +import { CategoryAddNew } from './category-add-new'; + +let initialTab = ''; +if ( window.getUserSetting ) { + initialTab = window.getUserSetting( CATEGORY_TERM_NAME + '_tab' ) || ''; +} + +const CATEGORY_POPULAR_TAB_ID = 'pop'; +const CATEGORY_ALL_TAB_ID = 'all'; + +const CategoryMetabox: React.FC< { + initialSelected: CategoryTerm[]; +} > = ( { initialSelected } ) => { + const [ selected, setSelected ] = useState( initialSelected ); + const allCategoryListRef = useRef< { resetInitialValues: () => void } >( + null + ); + const [ activeTab, setActiveTab ] = useState( + initialTab === CATEGORY_POPULAR_TAB_ID + ? initialTab + : CATEGORY_ALL_TAB_ID + ); + return ( +
+ +
+
    + +
+
+
+ +
+ { ( selected || [] ).map( ( sel ) => ( + + ) ) } + { selected.length === 0 && ( + + ) } + { + setSelected( sel ); + if ( allCategoryListRef.current ) { + allCategoryListRef.current.resetInitialValues(); + } + } } + /> +
+ ); +}; + +export default CategoryMetabox; diff --git a/plugins/woocommerce-admin/client/wp-admin-scripts/product-category-metabox/index.js b/plugins/woocommerce-admin/client/wp-admin-scripts/product-category-metabox/index.js new file mode 100644 index 00000000000..05e64ded3ad --- /dev/null +++ b/plugins/woocommerce-admin/client/wp-admin-scripts/product-category-metabox/index.js @@ -0,0 +1,29 @@ +/** + * External dependencies + */ +import { render, Suspense, lazy } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { getSelectedCategoryData } from './category-handlers'; +import './style.scss'; + +const CategoryMetabox = lazy( () => + import( /* webpackChunkName: "category-metabox" */ './category-metabox' ) +); + +const metaboxContainer = document.querySelector( + '#taxonomy-product_cat-metabox' +); +if ( metaboxContainer ) { + const initialSelected = getSelectedCategoryData( + metaboxContainer.parentElement + ); + render( + + + , + metaboxContainer + ); +} diff --git a/plugins/woocommerce-admin/client/wp-admin-scripts/product-category-metabox/popular-category-list.tsx b/plugins/woocommerce-admin/client/wp-admin-scripts/product-category-metabox/popular-category-list.tsx new file mode 100644 index 00000000000..0cddd552ab0 --- /dev/null +++ b/plugins/woocommerce-admin/client/wp-admin-scripts/product-category-metabox/popular-category-list.tsx @@ -0,0 +1,97 @@ +/** + * External dependencies + */ +import { useEffect, useState } from '@wordpress/element'; +import { addQueryArgs } from '@wordpress/url'; +import { getSetting } from '@woocommerce/settings'; +import apiFetch from '@wordpress/api-fetch'; + +/** + * Internal dependencies + */ +import { CATEGORY_TERM_NAME } from './category-handlers'; + +declare const wc_product_category_metabox_params: { + search_taxonomy_terms_nonce: string; +}; + +export type CategoryTerm = { + name: string; + term_id: number; + count: number; +}; + +export const PopularCategoryList: React.FC< { + selected: CategoryTerm[]; + onChange: ( selected: CategoryTerm[] ) => void; +} > = ( { selected, onChange } ) => { + const [ popularCategories, setPopularCategories ] = useState< + CategoryTerm[] + >( [] ); + + useEffect( () => { + apiFetch< CategoryTerm[] >( { + url: addQueryArgs( + new URL( + 'admin-ajax.php', + getSetting( 'adminUrl' ) + ).toString(), + { + action: 'woocommerce_json_search_taxonomy_terms', + taxonomy: CATEGORY_TERM_NAME, + limit: 10, + orderby: 'count', + order: 'DESC', + // eslint-disable-next-line no-undef, camelcase + security: + wc_product_category_metabox_params.search_taxonomy_terms_nonce, + } + ), + method: 'GET', + } ).then( ( res ) => { + if ( res ) { + setPopularCategories( res.filter( ( cat ) => cat.count > 0 ) ); + } + } ); + }, [] ); + + const selectedIds = selected.map( ( sel ) => sel.term_id ); + + return ( + + ); +}; diff --git a/plugins/woocommerce-admin/client/wp-admin-scripts/product-category-metabox/style.scss b/plugins/woocommerce-admin/client/wp-admin-scripts/product-category-metabox/style.scss new file mode 100644 index 00000000000..bfc1564a2f4 --- /dev/null +++ b/plugins/woocommerce-admin/client/wp-admin-scripts/product-category-metabox/style.scss @@ -0,0 +1,64 @@ +.product-add-category { + &__tree-control { + margin-top: $gap-smaller; + + .woocommerce-tree-select-control { + .components-base-control, + .woocommerce-tree-select-control__tree { + padding: 0 5px; + } + + .components-checkbox-control__label { + min-height: $gap-larger; + } + + &__tags { + display: none; + } + .components-base-control .woocommerce-tree-select-control__control-input, + .woocommerce-tree-select-control__option { + font-size: 12px; + } + .components-checkbox-control__input { + height: $gap; + width: $gap; + } + .components-checkbox-control__checked { + height: $gap + $gap-smallest; + width: $gap + $gap-smallest; + } + } + } +} + +.categorydiv.category-async-metabox { + #product_cat-all { + overflow: visible; + + .categorychecklist { + max-height: 140px; + margin-left: 0; + > li { + display: flex; + align-items: center; + margin-right: $gap-small; + } + .ntdelbutton { + position: relative; + margin: 0; + } + } + } + .woocommerce-experimental-select-control__combo-box-wrapper { + min-height: 30px; + border-radius: $gap-smallest; + } + .woocommerce-experimental-select-control__menu-item { + padding: 5px $gap-small; + } + .category-add { + &__error { + color: $error-red; + } + } +} diff --git a/plugins/woocommerce-admin/webpack.config.js b/plugins/woocommerce-admin/webpack.config.js index 5b535c7e8c8..25964390459 100644 --- a/plugins/woocommerce-admin/webpack.config.js +++ b/plugins/woocommerce-admin/webpack.config.js @@ -67,6 +67,7 @@ const wpAdminScripts = [ 'order-tracking', 'product-import-tracking', 'variable-product-tour', + 'product-category-metabox', ]; const getEntryPoints = () => { const entryPoints = { diff --git a/plugins/woocommerce/changelog/update-34885_category_field_in_product_editor b/plugins/woocommerce/changelog/update-34885_category_field_in_product_editor new file mode 100644 index 00000000000..acdb7217e9e --- /dev/null +++ b/plugins/woocommerce/changelog/update-34885_category_field_in_product_editor @@ -0,0 +1,4 @@ +Significance: major +Type: update + +Update Category product metabox with an async dropdown search control rendered with React. diff --git a/plugins/woocommerce/client/admin/config/core.json b/plugins/woocommerce/client/admin/config/core.json index ddc2eb8f623..ca2589613c7 100644 --- a/plugins/woocommerce/client/admin/config/core.json +++ b/plugins/woocommerce/client/admin/config/core.json @@ -28,6 +28,7 @@ "transient-notices": true, "woo-mobile-welcome": true, "wc-pay-promotion": true, - "wc-pay-welcome-page": true + "wc-pay-welcome-page": true, + "async-product-editor-category-field": false } } diff --git a/plugins/woocommerce/client/admin/config/development.json b/plugins/woocommerce/client/admin/config/development.json index c6a95550b95..59d4145ed5d 100644 --- a/plugins/woocommerce/client/admin/config/development.json +++ b/plugins/woocommerce/client/admin/config/development.json @@ -28,6 +28,7 @@ "transient-notices": true, "woo-mobile-welcome": true, "wc-pay-promotion": true, - "wc-pay-welcome-page": true + "wc-pay-welcome-page": true, + "async-product-editor-category-field": true } } diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-assets.php b/plugins/woocommerce/includes/admin/class-wc-admin-assets.php index 54b6c0f1624..deba885199a 100644 --- a/plugins/woocommerce/includes/admin/class-wc-admin-assets.php +++ b/plugins/woocommerce/includes/admin/class-wc-admin-assets.php @@ -8,6 +8,7 @@ use Automattic\Jetpack\Constants; use Automattic\WooCommerce\Admin\Features\Features; +use Automattic\WooCommerce\Internal\Admin\WCAdminAssets; if ( ! defined( 'ABSPATH' ) ) { exit; @@ -197,6 +198,7 @@ if ( ! class_exists( 'WC_Admin_Assets', false ) ) : wp_enqueue_script( 'iris' ); wp_enqueue_script( 'woocommerce_admin' ); wp_enqueue_script( 'wc-enhanced-select' ); + wp_enqueue_script( 'jquery-ui-sortable' ); wp_enqueue_script( 'jquery-ui-autocomplete' ); diff --git a/plugins/woocommerce/includes/admin/meta-boxes/class-wc-meta-box-product-categories.php b/plugins/woocommerce/includes/admin/meta-boxes/class-wc-meta-box-product-categories.php new file mode 100644 index 00000000000..96fcffac209 --- /dev/null +++ b/plugins/woocommerce/includes/admin/meta-boxes/class-wc-meta-box-product-categories.php @@ -0,0 +1,73 @@ + 'category' ); + if ( ! isset( $box['args'] ) || ! is_array( $box['args'] ) ) { + $args = array(); + } else { + $args = $box['args']; + } + $parsed_args = wp_parse_args( $args, $defaults ); + $tax_name = $parsed_args['taxonomy']; + $selected_categories = wp_get_object_terms( $post->ID, 'product_cat' ); + ?> +
+ + + array( 'product_cat' ), 'orderby' => 'id', 'order' => 'ASC', - 'hide_empty' => true, + 'hide_empty' => ! $show_empty, 'fields' => 'all', 'name__like' => $search_text, ); @@ -1785,6 +1787,7 @@ class WC_AJAX { foreach ( $terms as $term ) { $term->formatted_name = ''; + $ancestors = array(); if ( $term->parent ) { $ancestors = array_reverse( get_ancestors( $term->term_id, 'product_cat' ) ); foreach ( $ancestors as $ancestor ) { @@ -1795,6 +1798,7 @@ class WC_AJAX { } } + $term->parents = $ancestors; $term->formatted_name .= $term->name . ' (' . $term->count . ')'; $found_categories[ $term->term_id ] = $term; } @@ -1803,6 +1807,75 @@ class WC_AJAX { wp_send_json( apply_filters( 'woocommerce_json_search_found_categories', $found_categories ) ); } + /** + * Search for categories and return json. + */ + public static function json_search_categories_tree() { + ob_start(); + + check_ajax_referer( 'search-categories', 'security' ); + + if ( ! current_user_can( 'edit_products' ) ) { + wp_die( -1 ); + } + + $search_text = isset( $_GET['term'] ) ? wc_clean( wp_unslash( $_GET['term'] ) ) : ''; + $number = isset( $_GET['number'] ) ? absint( $_GET['number'] ) : 50; + + $args = array( + 'taxonomy' => array( 'product_cat' ), + 'orderby' => 'name', + 'order' => 'ASC', + 'hide_empty' => false, + 'fields' => 'all', + 'number' => $number, + 'name__like' => $search_text, + ); + + $terms = get_terms( $args ); + + $terms_map = array(); + + if ( $terms ) { + foreach ( $terms as $term ) { + $terms_map[ $term->term_id ] = $term; + + if ( $term->parent ) { + $ancestors = get_ancestors( $term->term_id, 'product_cat' ); + $current_child = $term; + foreach ( $ancestors as $ancestor ) { + if ( ! isset( $terms_map[ $ancestor ] ) ) { + $ancestor_term = get_term( $ancestor, 'product_cat' ); + $terms_map[ $ancestor ] = $ancestor_term; + } + if ( ! $terms_map[ $ancestor ]->children ) { + $terms_map[ $ancestor ]->children = array(); + } + $item_exists = count( + array_filter( + $terms_map[ $ancestor ]->children, + function( $term ) use ( $current_child ) { + return $term->term_id === $current_child->term_id; + } + ) + ) === 1; + if ( ! $item_exists ) { + $terms_map[ $ancestor ]->children[] = $current_child; + } + $current_child = $terms_map[ $ancestor ]; + } + } + } + } + $parent_terms = array_filter( + array_values( $terms_map ), + function( $term ) { + return 0 === $term->parent; + } + ); + wp_send_json( apply_filters( 'woocommerce_json_search_found_categories', $parent_terms ) ); + } + /** * Search for taxonomy terms and return json. */ @@ -1819,11 +1892,12 @@ class WC_AJAX { $limit = isset( $_GET['limit'] ) ? absint( wp_unslash( $_GET['limit'] ) ) : null; $taxonomy = isset( $_GET['taxonomy'] ) ? wc_clean( wp_unslash( $_GET['taxonomy'] ) ) : ''; $orderby = isset( $_GET['orderby'] ) ? wc_clean( wp_unslash( $_GET['orderby'] ) ) : 'name'; + $order = isset( $_GET['order'] ) ? wc_clean( wp_unslash( $_GET['order'] ) ) : 'ASC'; $args = array( 'taxonomy' => $taxonomy, 'orderby' => $orderby, - 'order' => 'ASC', + 'order' => $order, 'hide_empty' => false, 'fields' => 'all', 'number' => $limit, diff --git a/plugins/woocommerce/src/Admin/Features/AsyncProductEditorCategoryField/Init.php b/plugins/woocommerce/src/Admin/Features/AsyncProductEditorCategoryField/Init.php new file mode 100644 index 00000000000..a33411d3dee --- /dev/null +++ b/plugins/woocommerce/src/Admin/Features/AsyncProductEditorCategoryField/Init.php @@ -0,0 +1,85 @@ + wp_create_nonce( 'search-categories' ), + 'search_taxonomy_terms_nonce' => wp_create_nonce( 'search-taxonomy-terms' ), + ) + ); + wp_enqueue_script( 'product-category-metabox' ); + + } + + /** + * Enqueue styles needed for the rich text editor. + */ + public function enqueue_styles() { + if ( ! PageController::is_admin_or_embed_page() ) { + return; + } + $version = Constants::get_constant( 'WC_VERSION' ); + + wp_register_style( + 'woocommerce_admin_product_category_metabox_styles', + WCAdminAssets::get_url( 'product-category-metabox/style', 'css' ), + array(), + $version + ); + wp_style_add_data( 'woocommerce_admin_product_category_metabox_styles', 'rtl', 'replace' ); + + wp_enqueue_style( 'woocommerce_admin_product_category_metabox_styles' ); + } + +}