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
This commit is contained in:
louwie17 2023-04-19 04:28:18 -03:00 committed by GitHub
parent caf20d7989
commit b42da82e50
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 1063 additions and 16 deletions

View File

@ -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.

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add minFilterQueryLength, individuallySelectParent, and clearOnSelect props.

View File

@ -45,9 +45,8 @@ export const ComboBox = ( {
return;
}
event.preventDefault();
if ( document.activeElement !== inputRef.current ) {
event.preventDefault();
inputRef.current.focus();
event.stopPropagation();
}

View File

@ -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 )
: '',

View File

@ -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 );
};

View File

@ -48,6 +48,9 @@ declare global {
isDirty: () => boolean;
};
};
getUserSetting?: ( name: string ) => string | undefined;
setUserSetting?: ( name: string, value: string ) => void;
deleteUserSetting?: ( name: string ) => void;
}
}

View File

@ -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 (
<>
<div className="product-add-category__tree-control">
<TreeSelectControl
alwaysShowPlaceholder={ true }
options={ treeItems }
value={ selectedCategoryTerms.map( ( category ) =>
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 }
/>
</div>
<ul
// Adding tagchecklist class to make use of already existing styling for the selected categories.
className="categorychecklist form-no-clear tagchecklist"
id={ CATEGORY_TERM_NAME + 'checklist' }
>
{ selectedCategoryTerms.map( ( selectedCategory ) => (
<li key={ selectedCategory.term_id }>
<button
type="button"
className="ntdelbutton"
onClick={ () => {
const newSelectedItems =
selectedCategoryTerms.filter(
( category ) =>
category.term_id !==
selectedCategory.term_id
);
onChange( newSelectedItems );
} }
>
<span
className="remove-tag-icon"
aria-hidden="true"
></span>
<span className="screen-reader-text">
{ sprintf(
__( 'Remove term: %s', 'woocommerce' ),
selectedCategory.name
) }
</span>
</button>
{ selectedCategory.name }
</li>
) ) }
</ul>
</>
);
} );

View File

@ -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 (
<div id={ CATEGORY_TERM_NAME + '-adder' }>
<a
id="product_cat-add-toggle"
href={ '#taxonomy-' + CATEGORY_TERM_NAME }
className="taxonomy-add-new"
onClick={ () => setShowAddNew( ! showAddNew ) }
aria-label={ __( 'Add new category', 'woocommerce' ) }
>
{ __( '+ Add new category', 'woocommerce' ) }
</a>
{ showAddNew && (
<div id="product_cat-add" className="category-add">
<label
className="screen-reader-text"
htmlFor="newproduct_cat"
>
{ __( 'Add new category', 'woocommerce' ) }
</label>
<input
type="text"
name="newproduct_cat"
id="newproduct_cat"
className="form-required"
placeholder={ __( 'New category name', 'woocommerce' ) }
value={ newCategoryName }
onChange={ ( event ) =>
setNewCategoryName( event.target.value )
}
aria-required="true"
/>
<label
className="screen-reader-text"
htmlFor="newproduct_cat_parent"
>
{ __( 'Parent category:', 'woocommerce' ) }
</label>
<SelectControl< CategoryTerm >
{ ...selectProps }
label={ __( 'Parent category:', 'woocommerce' ) }
items={ fetchedItems }
selected={ categoryParent || null }
placeholder={ __( 'Find category', 'woocommerce' ) }
onSelect={ setCategoryParent }
getItemLabel={ getCategoryTermLabel }
getItemValue={ getCategoryTermKey }
onRemove={ () => setCategoryParent( undefined ) }
/>
{ categoryCreateError && (
<p className="category-add__error">
{ categoryCreateError }
</p>
) }
<input
type="button"
id="product_cat-add-submit"
className="button category-add-submit"
value={ __( 'Add new category', 'woocommerce' ) }
disabled={ ! newCategoryName.length }
onClick={ onCreate }
/>
</div>
) }
</div>
);
};

View File

@ -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 [];
}

View File

@ -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 (
<div
id={ 'taxonomy-' + CATEGORY_TERM_NAME }
className="categorydiv category-async-metabox"
>
<ul className="category-tabs">
<li
className={
activeTab === CATEGORY_ALL_TAB_ID ? 'tabs' : ''
}
>
<a
href={
'#' + CATEGORY_TERM_NAME + '-' + CATEGORY_ALL_TAB_ID
}
onClick={ ( event ) => {
event.preventDefault();
setActiveTab( CATEGORY_ALL_TAB_ID );
if ( window.deleteUserSetting ) {
window.deleteUserSetting(
CATEGORY_TERM_NAME + '_tab'
);
}
} }
>
{ __( 'All items', 'woocommerce' ) }
</a>
</li>
<li
className={
activeTab === CATEGORY_POPULAR_TAB_ID ? 'tabs' : ''
}
>
<a
href={
'#' +
CATEGORY_TERM_NAME +
'-' +
CATEGORY_POPULAR_TAB_ID
}
onClick={ ( event ) => {
event.preventDefault();
setActiveTab( CATEGORY_POPULAR_TAB_ID );
if ( window.setUserSetting ) {
window.setUserSetting(
CATEGORY_TERM_NAME + '_tab',
CATEGORY_POPULAR_TAB_ID
);
}
} }
>
{ __( 'Most used', 'woocommerce' ) }
</a>
</li>
</ul>
<div
className="tabs-panel"
id={ CATEGORY_TERM_NAME + '-' + CATEGORY_POPULAR_TAB_ID }
style={
activeTab !== CATEGORY_POPULAR_TAB_ID
? { display: 'none' }
: {}
}
>
<ul
id={
CATEGORY_TERM_NAME +
'checklist-' +
CATEGORY_POPULAR_TAB_ID
}
className="categorychecklist form-no-clear"
>
<PopularCategoryList
selected={ selected }
onChange={ setSelected }
/>
</ul>
</div>
<div
className="tabs-panel"
id={ CATEGORY_TERM_NAME + '-' + CATEGORY_ALL_TAB_ID }
style={
activeTab !== CATEGORY_ALL_TAB_ID ? { display: 'none' } : {}
}
>
<AllCategoryList
selectedCategoryTerms={ selected }
onChange={ setSelected }
ref={ allCategoryListRef }
/>
</div>
{ ( selected || [] ).map( ( sel ) => (
<input
key={ sel.term_id }
type="hidden"
value={ sel.term_id }
name={ 'tax_input[' + CATEGORY_TERM_NAME + '][]' }
/>
) ) }
{ selected.length === 0 && (
<input
type="hidden"
value=""
name={ 'tax_input[' + CATEGORY_TERM_NAME + '][]' }
/>
) }
<CategoryAddNew
selectedCategoryTerms={ selected }
onChange={ ( sel ) => {
setSelected( sel );
if ( allCategoryListRef.current ) {
allCategoryListRef.current.resetInitialValues();
}
} }
/>
</div>
);
};
export default CategoryMetabox;

View File

@ -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(
<Suspense fallback={ null }>
<CategoryMetabox initialSelected={ initialSelected } />
</Suspense>,
metaboxContainer
);
}

View File

@ -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 (
<ul
className="categorychecklist form-no-clear"
id={ CATEGORY_TERM_NAME + 'checklist-pop' }
>
{ popularCategories.map( ( cat ) => {
const categoryCheckboxId = `in-popular-${ CATEGORY_TERM_NAME }-${ cat.term_id }`;
return (
<li key={ cat.term_id } className="popular-category">
<label
className="selectit"
htmlFor={ categoryCheckboxId }
>
<input
type="checkbox"
id={ categoryCheckboxId }
checked={ selectedIds.includes( cat.term_id ) }
onChange={ () => {
if ( selectedIds.includes( cat.term_id ) ) {
onChange(
selected.filter(
( sel ) =>
sel.term_id !== cat.term_id
)
);
} else {
onChange( [ ...selected, cat ] );
}
} }
/>
{ cat.name }
</label>
</li>
);
} ) }
</ul>
);
};

View File

@ -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;
}
}
}

View File

@ -67,6 +67,7 @@ const wpAdminScripts = [
'order-tracking',
'product-import-tracking',
'variable-product-tour',
'product-category-metabox',
];
const getEntryPoints = () => {
const entryPoints = {

View File

@ -0,0 +1,4 @@
Significance: major
Type: update
Update Category product metabox with an async dropdown search control rendered with React.

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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' );

View File

@ -0,0 +1,73 @@
<?php
/**
* Product Categories meta box
*
* Display the product categories meta box.
*
* @package WooCommerce\Admin\Meta Boxes
* @version 7.5.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* WC_Meta_Box_Product_Categories Class.
*/
class WC_Meta_Box_Product_Categories {
/**
* Output the metabox.
*
* @param WP_Post $post Current post object.
* @param array $box {
* Categories meta box arguments.
*
* @type string $id Meta box 'id' attribute.
* @type string $title Meta box title.
* @type callable $callback Meta box display callback.
* @type array $args {
* Extra meta box arguments.
*
* @type string $taxonomy Taxonomy. Default 'category'.
* }
* }
*/
public static function output( $post, $box ) {
$categories_count = (int) wp_count_terms( 'product_cat' );
/**
* Filters the category metabox search threshold, for when to render the typeahead field.
*
* @since 7.6.0
*
* @param number $threshold The default threshold.
* @returns number The threshold that will be used.
*/
if ( $categories_count <= apply_filters( 'woocommerce_product_category_metabox_search_threshold', 100 ) && function_exists( 'post_categories_meta_box' ) ) {
return post_categories_meta_box( $post, $box );
}
$defaults = array( 'taxonomy' => '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' );
?>
<div id="taxonomy-<?php echo esc_attr( $tax_name ); ?>-metabox"></div>
<?php foreach ( (array) $selected_categories as $term ) { ?>
<input
type="hidden"
value="<?php echo esc_attr( $term->term_id ); ?>"
name="tax_input[<?php esc_attr( $tax_name ); ?>][]"
data-name="<?php echo esc_attr( $term->name ); ?>"
/>
<?php
}
}
}

View File

@ -157,6 +157,7 @@ class WC_AJAX {
'json_search_downloadable_products_and_variations',
'json_search_customers',
'json_search_categories',
'json_search_categories_tree',
'json_search_taxonomy_terms',
'json_search_product_attributes',
'json_search_pages',
@ -1769,12 +1770,13 @@ class WC_AJAX {
wp_die();
}
$show_empty = isset( $_GET['show_empty'] ) ? wp_validate_boolean( wc_clean( wp_unslash( $_GET['show_empty'] ) ) ) : false;
$found_categories = array();
$args = array(
'taxonomy' => 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,

View File

@ -0,0 +1,85 @@
<?php
/**
* WooCommerce Async Product Editor Category Field.
*/
namespace Automattic\WooCommerce\Admin\Features\AsyncProductEditorCategoryField;
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Internal\Admin\WCAdminAssets;
use Automattic\WooCommerce\Admin\PageController;
/**
* Loads assets related to the async category field for the product editor.
*/
class Init {
const FEATURE_ID = 'async-product-editor-category-field';
/**
* Constructor
*/
public function __construct() {
if ( Features::is_enabled( self::FEATURE_ID ) ) {
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_styles' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_scripts' ) );
add_filter( 'woocommerce_taxonomy_args_product_cat', array( $this, 'add_metabox_args' ) );
}
}
/**
* Adds meta_box_cb callback arguments for custom metabox.
*
* @param array $args Category taxonomy args.
* @return array $args category taxonomy args.
*/
public function add_metabox_args( $args ) {
if ( ! isset( $args['meta_box_cb'] ) ) {
$args['meta_box_cb'] = 'WC_Meta_Box_Product_Categories::output';
$args['meta_box_sanitize_cb'] = 'taxonomy_meta_box_sanitize_cb_checkboxes';
}
return $args;
}
/**
* Enqueue scripts needed for the product form block editor.
*/
public function enqueue_scripts() {
if ( ! PageController::is_admin_or_embed_page() ) {
return;
}
WCAdminAssets::register_script( 'wp-admin-scripts', 'product-category-metabox', true );
wp_localize_script(
'wc-admin-product-category-metabox',
'wc_product_category_metabox_params',
array(
'search_categories_nonce' => 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' );
}
}