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:
parent
caf20d7989
commit
b42da82e50
|
@ -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.
|
|
@ -0,0 +1,4 @@
|
||||||
|
Significance: minor
|
||||||
|
Type: add
|
||||||
|
|
||||||
|
Add minFilterQueryLength, individuallySelectParent, and clearOnSelect props.
|
|
@ -45,9 +45,8 @@ export const ComboBox = ( {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
if ( document.activeElement !== inputRef.current ) {
|
if ( document.activeElement !== inputRef.current ) {
|
||||||
|
event.preventDefault();
|
||||||
inputRef.current.focus();
|
inputRef.current.focus();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
}
|
}
|
||||||
|
|
|
@ -177,8 +177,13 @@ function SelectControl< ItemType = DefaultItemType >( {
|
||||||
items: filteredItems,
|
items: filteredItems,
|
||||||
selectedItem: multiple ? null : singleSelectedItem,
|
selectedItem: multiple ? null : singleSelectedItem,
|
||||||
itemToString: getItemLabel,
|
itemToString: getItemLabel,
|
||||||
onSelectedItemChange: ( { selectedItem } ) =>
|
onSelectedItemChange: ( { selectedItem } ) => {
|
||||||
selectedItem && onSelect( selectedItem ),
|
if ( selectedItem ) {
|
||||||
|
onSelect( selectedItem );
|
||||||
|
} else if ( singleSelectedItem ) {
|
||||||
|
onRemove( singleSelectedItem );
|
||||||
|
}
|
||||||
|
},
|
||||||
onInputValueChange: ( { inputValue: value, ...changes } ) => {
|
onInputValueChange: ( { inputValue: value, ...changes } ) => {
|
||||||
if ( value !== undefined ) {
|
if ( value !== undefined ) {
|
||||||
setInputValue( value );
|
setInputValue( value );
|
||||||
|
@ -193,8 +198,13 @@ function SelectControl< ItemType = DefaultItemType >( {
|
||||||
// Set input back to selected item if there is a selected item, blank otherwise.
|
// Set input back to selected item if there is a selected item, blank otherwise.
|
||||||
newChanges = {
|
newChanges = {
|
||||||
...changes,
|
...changes,
|
||||||
|
selectedItem:
|
||||||
|
! changes.inputValue?.length && ! multiple
|
||||||
|
? null
|
||||||
|
: changes.selectedItem,
|
||||||
inputValue:
|
inputValue:
|
||||||
changes.selectedItem === state.selectedItem &&
|
changes.selectedItem === state.selectedItem &&
|
||||||
|
changes.inputValue?.length &&
|
||||||
! multiple
|
! multiple
|
||||||
? getItemLabel( comboboxSingleSelectedItem )
|
? getItemLabel( comboboxSingleSelectedItem )
|
||||||
: '',
|
: '',
|
||||||
|
|
|
@ -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 {string} [props.className] The class name for this component
|
||||||
* @param {boolean} [props.disabled] Disables the component
|
* @param {boolean} [props.disabled] Disables the component
|
||||||
* @param {boolean} [props.includeParent] Includes parent with selection.
|
* @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 {boolean} [props.alwaysShowPlaceholder] Will always show placeholder (default: false)
|
||||||
* @param {Option[]} [props.options] Options to show in the component
|
* @param {Option[]} [props.options] Options to show in the component
|
||||||
* @param {string[]} [props.value] Selected values
|
* @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 {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 {(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 {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
|
* @return {JSX.Element} The component
|
||||||
*/
|
*/
|
||||||
const TreeSelectControl = ( {
|
const TreeSelectControl = ( {
|
||||||
|
@ -88,7 +91,10 @@ const TreeSelectControl = ( {
|
||||||
onDropdownVisibilityChange = noop,
|
onDropdownVisibilityChange = noop,
|
||||||
onInputChange = noop,
|
onInputChange = noop,
|
||||||
includeParent = false,
|
includeParent = false,
|
||||||
|
individuallySelectParent = false,
|
||||||
alwaysShowPlaceholder = false,
|
alwaysShowPlaceholder = false,
|
||||||
|
minFilterQueryLength = 3,
|
||||||
|
clearOnSelect = true,
|
||||||
} ) => {
|
} ) => {
|
||||||
let instanceId = useInstanceId( TreeSelectControl );
|
let instanceId = useInstanceId( TreeSelectControl );
|
||||||
instanceId = id ?? instanceId;
|
instanceId = id ?? instanceId;
|
||||||
|
@ -126,7 +132,8 @@ const TreeSelectControl = ( {
|
||||||
|
|
||||||
const filterQuery = inputControlValue.trim().toLowerCase();
|
const filterQuery = inputControlValue.trim().toLowerCase();
|
||||||
// we only trigger the filter when there are more than 3 characters in the input.
|
// 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
|
* Optimizes the performance for getting the tags info
|
||||||
|
@ -419,9 +426,11 @@ const TreeSelectControl = ( {
|
||||||
*/
|
*/
|
||||||
const handleParentChange = ( checked, option ) => {
|
const handleParentChange = ( checked, option ) => {
|
||||||
let newValue;
|
let newValue;
|
||||||
const changedValues = option.leaves
|
const changedValues = individuallySelectParent
|
||||||
.filter( ( opt ) => opt.checked !== checked )
|
? []
|
||||||
.map( ( opt ) => opt.value );
|
: option.leaves
|
||||||
|
.filter( ( opt ) => opt.checked !== checked )
|
||||||
|
.map( ( opt ) => opt.value );
|
||||||
if ( includeParent && option.value !== ROOT_VALUE ) {
|
if ( includeParent && option.value !== ROOT_VALUE ) {
|
||||||
changedValues.push( option.value );
|
changedValues.push( option.value );
|
||||||
}
|
}
|
||||||
|
@ -452,10 +461,12 @@ const TreeSelectControl = ( {
|
||||||
handleSingleChange( checked, option, parent );
|
handleSingleChange( checked, option, parent );
|
||||||
}
|
}
|
||||||
|
|
||||||
onInputChange( '' );
|
if ( clearOnSelect ) {
|
||||||
setInputControlValue( '' );
|
onInputChange( '' );
|
||||||
if ( ! nodesExpanded.includes( option.parent ) ) {
|
setInputControlValue( '' );
|
||||||
controlRef.current.focus();
|
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
|
* @param {Event} e Event returned by the On Change function in the Input control
|
||||||
*/
|
*/
|
||||||
const handleOnInputChange = ( e ) => {
|
const handleOnInputChange = ( e ) => {
|
||||||
|
setTreeVisible( true );
|
||||||
onInputChange( e.target.value );
|
onInputChange( e.target.value );
|
||||||
setInputControlValue( e.target.value );
|
setInputControlValue( e.target.value );
|
||||||
};
|
};
|
||||||
|
|
|
@ -48,6 +48,9 @@ declare global {
|
||||||
isDirty: () => boolean;
|
isDirty: () => boolean;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
getUserSetting?: ( name: string ) => string | undefined;
|
||||||
|
setUserSetting?: ( name: string, value: string ) => void;
|
||||||
|
deleteUserSetting?: ( name: string ) => void;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
} );
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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 [];
|
||||||
|
}
|
|
@ -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;
|
|
@ -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
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -67,6 +67,7 @@ const wpAdminScripts = [
|
||||||
'order-tracking',
|
'order-tracking',
|
||||||
'product-import-tracking',
|
'product-import-tracking',
|
||||||
'variable-product-tour',
|
'variable-product-tour',
|
||||||
|
'product-category-metabox',
|
||||||
];
|
];
|
||||||
const getEntryPoints = () => {
|
const getEntryPoints = () => {
|
||||||
const entryPoints = {
|
const entryPoints = {
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
Significance: major
|
||||||
|
Type: update
|
||||||
|
|
||||||
|
Update Category product metabox with an async dropdown search control rendered with React.
|
|
@ -28,6 +28,7 @@
|
||||||
"transient-notices": true,
|
"transient-notices": true,
|
||||||
"woo-mobile-welcome": true,
|
"woo-mobile-welcome": true,
|
||||||
"wc-pay-promotion": true,
|
"wc-pay-promotion": true,
|
||||||
"wc-pay-welcome-page": true
|
"wc-pay-welcome-page": true,
|
||||||
|
"async-product-editor-category-field": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,6 +28,7 @@
|
||||||
"transient-notices": true,
|
"transient-notices": true,
|
||||||
"woo-mobile-welcome": true,
|
"woo-mobile-welcome": true,
|
||||||
"wc-pay-promotion": true,
|
"wc-pay-promotion": true,
|
||||||
"wc-pay-welcome-page": true
|
"wc-pay-welcome-page": true,
|
||||||
|
"async-product-editor-category-field": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@
|
||||||
|
|
||||||
use Automattic\Jetpack\Constants;
|
use Automattic\Jetpack\Constants;
|
||||||
use Automattic\WooCommerce\Admin\Features\Features;
|
use Automattic\WooCommerce\Admin\Features\Features;
|
||||||
|
use Automattic\WooCommerce\Internal\Admin\WCAdminAssets;
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
|
@ -197,6 +198,7 @@ if ( ! class_exists( 'WC_Admin_Assets', false ) ) :
|
||||||
wp_enqueue_script( 'iris' );
|
wp_enqueue_script( 'iris' );
|
||||||
wp_enqueue_script( 'woocommerce_admin' );
|
wp_enqueue_script( 'woocommerce_admin' );
|
||||||
wp_enqueue_script( 'wc-enhanced-select' );
|
wp_enqueue_script( 'wc-enhanced-select' );
|
||||||
|
|
||||||
wp_enqueue_script( 'jquery-ui-sortable' );
|
wp_enqueue_script( 'jquery-ui-sortable' );
|
||||||
wp_enqueue_script( 'jquery-ui-autocomplete' );
|
wp_enqueue_script( 'jquery-ui-autocomplete' );
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -157,6 +157,7 @@ class WC_AJAX {
|
||||||
'json_search_downloadable_products_and_variations',
|
'json_search_downloadable_products_and_variations',
|
||||||
'json_search_customers',
|
'json_search_customers',
|
||||||
'json_search_categories',
|
'json_search_categories',
|
||||||
|
'json_search_categories_tree',
|
||||||
'json_search_taxonomy_terms',
|
'json_search_taxonomy_terms',
|
||||||
'json_search_product_attributes',
|
'json_search_product_attributes',
|
||||||
'json_search_pages',
|
'json_search_pages',
|
||||||
|
@ -1769,12 +1770,13 @@ class WC_AJAX {
|
||||||
wp_die();
|
wp_die();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$show_empty = isset( $_GET['show_empty'] ) ? wp_validate_boolean( wc_clean( wp_unslash( $_GET['show_empty'] ) ) ) : false;
|
||||||
$found_categories = array();
|
$found_categories = array();
|
||||||
$args = array(
|
$args = array(
|
||||||
'taxonomy' => array( 'product_cat' ),
|
'taxonomy' => array( 'product_cat' ),
|
||||||
'orderby' => 'id',
|
'orderby' => 'id',
|
||||||
'order' => 'ASC',
|
'order' => 'ASC',
|
||||||
'hide_empty' => true,
|
'hide_empty' => ! $show_empty,
|
||||||
'fields' => 'all',
|
'fields' => 'all',
|
||||||
'name__like' => $search_text,
|
'name__like' => $search_text,
|
||||||
);
|
);
|
||||||
|
@ -1785,6 +1787,7 @@ class WC_AJAX {
|
||||||
foreach ( $terms as $term ) {
|
foreach ( $terms as $term ) {
|
||||||
$term->formatted_name = '';
|
$term->formatted_name = '';
|
||||||
|
|
||||||
|
$ancestors = array();
|
||||||
if ( $term->parent ) {
|
if ( $term->parent ) {
|
||||||
$ancestors = array_reverse( get_ancestors( $term->term_id, 'product_cat' ) );
|
$ancestors = array_reverse( get_ancestors( $term->term_id, 'product_cat' ) );
|
||||||
foreach ( $ancestors as $ancestor ) {
|
foreach ( $ancestors as $ancestor ) {
|
||||||
|
@ -1795,6 +1798,7 @@ class WC_AJAX {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$term->parents = $ancestors;
|
||||||
$term->formatted_name .= $term->name . ' (' . $term->count . ')';
|
$term->formatted_name .= $term->name . ' (' . $term->count . ')';
|
||||||
$found_categories[ $term->term_id ] = $term;
|
$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 ) );
|
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.
|
* Search for taxonomy terms and return json.
|
||||||
*/
|
*/
|
||||||
|
@ -1819,11 +1892,12 @@ class WC_AJAX {
|
||||||
$limit = isset( $_GET['limit'] ) ? absint( wp_unslash( $_GET['limit'] ) ) : null;
|
$limit = isset( $_GET['limit'] ) ? absint( wp_unslash( $_GET['limit'] ) ) : null;
|
||||||
$taxonomy = isset( $_GET['taxonomy'] ) ? wc_clean( wp_unslash( $_GET['taxonomy'] ) ) : '';
|
$taxonomy = isset( $_GET['taxonomy'] ) ? wc_clean( wp_unslash( $_GET['taxonomy'] ) ) : '';
|
||||||
$orderby = isset( $_GET['orderby'] ) ? wc_clean( wp_unslash( $_GET['orderby'] ) ) : 'name';
|
$orderby = isset( $_GET['orderby'] ) ? wc_clean( wp_unslash( $_GET['orderby'] ) ) : 'name';
|
||||||
|
$order = isset( $_GET['order'] ) ? wc_clean( wp_unslash( $_GET['order'] ) ) : 'ASC';
|
||||||
|
|
||||||
$args = array(
|
$args = array(
|
||||||
'taxonomy' => $taxonomy,
|
'taxonomy' => $taxonomy,
|
||||||
'orderby' => $orderby,
|
'orderby' => $orderby,
|
||||||
'order' => 'ASC',
|
'order' => $order,
|
||||||
'hide_empty' => false,
|
'hide_empty' => false,
|
||||||
'fields' => 'all',
|
'fields' => 'all',
|
||||||
'number' => $limit,
|
'number' => $limit,
|
||||||
|
|
|
@ -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' );
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
Loading…
Reference in New Issue