Skip to content

Commit

Permalink
Update/34885 category field in product editor (#36869)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
louwie17 committed Apr 19, 2023
1 parent caf20d7 commit b42da82
Show file tree
Hide file tree
Showing 21 changed files with 1,063 additions and 16 deletions.
@@ -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.
Expand Up @@ -45,9 +45,8 @@ export const ComboBox = ( {
return;
}

event.preventDefault();

if ( document.activeElement !== inputRef.current ) {
event.preventDefault();
inputRef.current.focus();
event.stopPropagation();
}
Expand Down
Expand Up @@ -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 );
Expand All @@ -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 )
: '',
Expand Down
28 changes: 20 additions & 8 deletions packages/js/components/src/tree-select-control/index.js
Expand Up @@ -64,13 +64,16 @@ 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
* @param {number} [props.maxVisibleTags] The maximum number of tags to show. Undefined, 0 or less than 0 evaluates to "Show All".
* @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 = ( {
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 );
}
Expand Down Expand Up @@ -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();
}
}
};

Expand All @@ -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 );
};
Expand Down
3 changes: 3 additions & 0 deletions plugins/woocommerce-admin/client/typings/global.d.ts
Expand Up @@ -48,6 +48,9 @@ declare global {
isDirty: () => boolean;
};
};
getUserSetting?: ( name: string ) => string | undefined;
setUserSetting?: ( name: string, value: string ) => void;
deleteUserSetting?: ( name: string ) => void;
}
}

Expand Down
@@ -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 comments on commit b42da82

Please sign in to comment.