Commit 3947699a2a for woocommerce
commit 3947699a2a0ce67c0162c4f81f84e6ee8d68a8e8
Author: Karol Manijak <20098064+kmanijak@users.noreply.github.com>
Date: Mon Feb 2 11:30:21 2026 +0200
Taxonomy Filters: add option to order terms by `menu_order` (#62940)
* Add menu order potion
* Add menu order sorting on the frontend
* Build and cache the data
* Only add the menu order sorting to taxonomies that support it
* Add changelog
* Add tests
* Update comments
* Fix linter
* Add docs to filter
* Clear cache on updated_term_meta
* Expose menu_order via REST API for editor sorting
Add REST field to expose term menu_order meta for sortable taxonomies
(product_cat), enabling the block editor to display terms in the correct
custom order rather than falling back to alphabetical sorting.
Changes:
- Register menu_order REST field for sortable taxonomies
- Add menuOrder property to FilterOptionItem type
- Update sortFilterOptions to sort by menuOrder with secondary name sort
- Pass menu_order from term objects to editor components
* Add filter docblock
* fix: warm cache before use, clear meta cache on added
* Clear cache on deleted term meta
---------
Co-authored-by: Tung Du <dinhtungdu@gmail.com>
diff --git a/plugins/woocommerce/changelog/add-product-filter-menu-order b/plugins/woocommerce/changelog/add-product-filter-menu-order
new file mode 100644
index 0000000000..6c1516891c
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-product-filter-menu-order
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Taxonomy Filters: Add option to sort terms by menu_order
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/taxonomy-filter/edit.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/taxonomy-filter/edit.tsx
index fe6819d9b8..78cbf99d02 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/taxonomy-filter/edit.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/taxonomy-filter/edit.tsx
@@ -183,6 +183,7 @@ const Edit = ( props: EditProps ) => {
count,
id: term.id,
parent: term.parent || 0,
+ menuOrder: term.menu_order ?? 0,
} );
return acc;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/taxonomy-filter/inspector.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/taxonomy-filter/inspector.tsx
index 200fcd6d1c..af89355286 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/taxonomy-filter/inspector.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/taxonomy-filter/inspector.tsx
@@ -2,7 +2,9 @@
* External dependencies
*/
import { InspectorControls } from '@wordpress/block-editor';
+import { useMemo } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
+import { getSetting } from '@woocommerce/settings';
import {
SelectControl,
ToggleControl,
@@ -22,12 +24,53 @@ import {
} from '../../components/display-style-switcher';
import metadata from './block.json';
+// Get the list of taxonomies that support custom ordering (drag & drop in admin).
+const sortableTaxonomies = getSetting< string[] >( 'sortableTaxonomies', [
+ 'product_cat',
+] );
+
export const TaxonomyFilterInspectorControls = ( {
attributes,
setAttributes,
clientId,
}: EditProps ) => {
- const { showCounts, sortOrder, hideEmpty, displayStyle } = attributes;
+ const { showCounts, sortOrder, hideEmpty, displayStyle, taxonomy } =
+ attributes;
+
+ // Only show "Menu order" option for taxonomies that support custom ordering.
+ const sortOrderOptions = useMemo( () => {
+ const baseOptions = [
+ {
+ label: __( 'Count (High to Low)', 'woocommerce' ),
+ value: 'count-desc',
+ },
+ {
+ label: __( 'Count (Low to High)', 'woocommerce' ),
+ value: 'count-asc',
+ },
+ {
+ label: __( 'Name (A to Z)', 'woocommerce' ),
+ value: 'name-asc',
+ },
+ {
+ label: __( 'Name (Z to A)', 'woocommerce' ),
+ value: 'name-desc',
+ },
+ ];
+
+ // Add "Menu order" option only for sortable taxonomies.
+ if ( sortableTaxonomies.includes( taxonomy ) ) {
+ return [
+ {
+ label: __( 'Menu order', 'woocommerce' ),
+ value: 'menu_order-asc',
+ },
+ ...baseOptions,
+ ];
+ }
+
+ return baseOptions;
+ }, [ taxonomy ] );
return (
<InspectorControls>
@@ -58,30 +101,7 @@ export const TaxonomyFilterInspectorControls = ( {
<SelectControl
label={ __( 'Sort Order', 'woocommerce' ) }
value={ sortOrder }
- options={ [
- {
- label: __(
- 'Count (High to Low)',
- 'woocommerce'
- ),
- value: 'count-desc',
- },
- {
- label: __(
- 'Count (Low to High)',
- 'woocommerce'
- ),
- value: 'count-asc',
- },
- {
- label: __( 'Name (A to Z)', 'woocommerce' ),
- value: 'name-asc',
- },
- {
- label: __( 'Name (Z to A)', 'woocommerce' ),
- value: 'name-desc',
- },
- ] }
+ options={ sortOrderOptions }
onChange={ ( value: string ) =>
setAttributes( { sortOrder: value } )
}
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/taxonomy-filter/test/block.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/taxonomy-filter/test/block.ts
index 45398dc36b..2174c28457 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/taxonomy-filter/test/block.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/taxonomy-filter/test/block.ts
@@ -33,6 +33,10 @@ jest.mock( '@woocommerce/settings', () => {
},
];
}
+ if ( key === 'sortableTaxonomies' ) {
+ // Only product_cat supports custom ordering by default
+ return [ 'product_cat' ];
+ }
// Use the original getSetting for other keys
return originalModule.getSetting( key, defaultValue );
} ),
@@ -263,4 +267,57 @@ describe( 'Taxonomy Filter block', () => {
);
} );
} );
+
+ describe( 'Menu order option visibility', () => {
+ test( 'should show Menu order option for sortable taxonomies (product_cat)', async () => {
+ await setup( { taxonomy: 'product_cat' } );
+ await selectBlock( /Block: Category Filter/i );
+
+ enableControl( 'Sort Order' );
+
+ const sortOrderSelect = screen.getByRole( 'combobox', {
+ name: /Sort Order/i,
+ } );
+
+ // Menu order option should be available for product_cat
+ const options = within( sortOrderSelect ).getAllByRole( 'option' );
+ const optionValues = options.map( ( opt ) => opt.textContent );
+
+ expect( optionValues ).toContain( 'Menu order' );
+ } );
+
+ test( 'should not show Menu order option for non-sortable taxonomies (product_tag)', async () => {
+ await setup( { taxonomy: 'product_tag' } );
+ await selectBlock( /Block: Tag Filter/i );
+
+ enableControl( 'Sort Order' );
+
+ const sortOrderSelect = screen.getByRole( 'combobox', {
+ name: /Sort Order/i,
+ } );
+
+ // Menu order option should NOT be available for product_tag
+ const options = within( sortOrderSelect ).getAllByRole( 'option' );
+ const optionValues = options.map( ( opt ) => opt.textContent );
+
+ expect( optionValues ).not.toContain( 'Menu order' );
+ } );
+
+ test( 'should allow selecting Menu order for sortable taxonomies', async () => {
+ await setup( { taxonomy: 'product_cat' } );
+ await selectBlock( /Block: Category Filter/i );
+
+ enableControl( 'Sort Order' );
+
+ const sortOrderSelect = screen.getByRole( 'combobox', {
+ name: /Sort Order/i,
+ } );
+
+ fireEvent.change( sortOrderSelect, {
+ target: { value: 'menu_order-asc' },
+ } );
+
+ expect( sortOrderSelect ).toHaveValue( 'menu_order-asc' );
+ } );
+ } );
} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/types.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/types.ts
index ca30706a2d..c5f52d363e 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/types.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/types.ts
@@ -27,6 +27,7 @@ export type FilterOptionItem = (
id?: number;
parent?: number;
depth?: number;
+ menuOrder?: number;
};
export type FilterBlockContext = {
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/utils/sort-filter-options.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/utils/sort-filter-options.ts
index 42e0d8f1d9..927677eb30 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/utils/sort-filter-options.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/utils/sort-filter-options.ts
@@ -23,7 +23,7 @@ function getSortableText( item: FilterOptionItem ): string {
* Sorts filter options based on the specified sort order
*
* @param options - Array of filter option items to sort
- * @param sortOrder - Sort order (name-asc, name-desc, count-asc, count-desc)
+ * @param sortOrder - Sort order (name-asc, name-desc, count-asc, count-desc, menu_order-asc)
* @return Sorted array of filter option items
*/
export function sortFilterOptions(
@@ -32,6 +32,17 @@ export function sortFilterOptions(
): FilterOptionItem[] {
return options.sort( ( a, b ) => {
switch ( sortOrder ) {
+ case 'menu_order-asc': {
+ // Sort by menu order, with secondary sort by name when equal.
+ const orderA = a.menuOrder ?? 0;
+ const orderB = b.menuOrder ?? 0;
+ if ( orderA !== orderB ) {
+ return orderA - orderB;
+ }
+ return getSortableText( a ).localeCompare(
+ getSortableText( b )
+ );
+ }
case 'name-asc':
return getSortableText( a ).localeCompare(
getSortableText( b )
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterTaxonomy.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterTaxonomy.php
index 706e58a8da..c7d40bcc14 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterTaxonomy.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterTaxonomy.php
@@ -88,6 +88,47 @@ final class ProductFilterTaxonomy extends AbstractBlock {
parent::initialize();
add_filter( 'woocommerce_blocks_product_filters_selected_items', array( $this, 'prepare_selected_filters' ), 10, 2 );
+
+ // Register REST field for menu_order on sortable taxonomies.
+ $this->register_taxonomy_menu_order_rest_field();
+ }
+
+ /**
+ * Register a REST field to expose the menu_order meta for sortable taxonomies.
+ * This allows the editor to display terms in menu order.
+ */
+ private function register_taxonomy_menu_order_rest_field(): void {
+ /**
+ * Filters the list of taxonomies that support custom ordering. Filter was introduced long
+ * ago is only documented in 10.6.0.
+ *
+ * First instance in plugins/woocommerce/includes/admin/class-wc-admin-assets.php.
+ *
+ * @since 1.0
+ *
+ * @param array $sortable_taxonomies List of taxonomy slugs that support custom ordering.
+ * @return array List of taxonomy slugs that support custom ordering.
+ */
+ $sortable_taxonomies = apply_filters( 'woocommerce_sortable_taxonomies', array( 'product_cat' ) );
+
+ foreach ( $sortable_taxonomies as $taxonomy ) {
+ register_rest_field(
+ $taxonomy,
+ 'menu_order',
+ array(
+ 'get_callback' => function ( $term ) {
+ $menu_order = get_term_meta( $term['id'], 'order', true );
+ return is_numeric( $menu_order ) ? (int) $menu_order : 0;
+ },
+ 'schema' => array(
+ 'description' => __( 'Menu order, used to custom sort the term.', 'woocommerce' ),
+ 'type' => 'integer',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ )
+ );
+ }
}
/**
@@ -102,6 +143,22 @@ final class ProductFilterTaxonomy extends AbstractBlock {
if ( is_admin() ) {
$this->asset_data_registry->add( 'filterableProductTaxonomies', $this->get_taxonomies() );
+ // Expose sortable taxonomies so the editor can show/hide "Menu order" option.
+ $this->asset_data_registry->add(
+ 'sortableTaxonomies',
+ /**
+ * Filters the list of taxonomies that support custom ordering. Filter was introduced long
+ * ago is only documented in 10.6.0.
+ *
+ * First instance in plugins/woocommerce/includes/admin/class-wc-admin-assets.php.
+ *
+ * @since 1.0
+ *
+ * @param array $sortable_taxonomies List of taxonomy slugs that support custom ordering.
+ * @return array List of taxonomy slugs that support custom ordering.
+ */
+ apply_filters( 'woocommerce_sortable_taxonomies', array( 'product_cat' ) )
+ );
}
}
@@ -269,6 +326,21 @@ final class ProductFilterTaxonomy extends AbstractBlock {
return array();
}
+ // Add menu_order to flat terms for sorting.
+ if ( 'menu_order' === $orderby ) {
+ // Prime term meta cache in single query to avoid N+1.
+ update_termmeta_cache( wp_list_pluck( $terms, 'term_id' ) );
+ $terms = array_map(
+ function ( $term ) {
+ $term = (array) $term;
+ $menu_order = get_term_meta( $term['term_id'], 'order', true );
+ $term['menu_order'] = is_numeric( $menu_order ) ? (int) $menu_order : 0;
+ return (object) $term;
+ },
+ $terms
+ );
+ }
+
return $this->sort_terms_by_criteria( $terms, $orderby, $order, $taxonomy_counts );
}
@@ -449,7 +521,7 @@ final class ProductFilterTaxonomy extends AbstractBlock {
}
/**
- * Sort terms by the specified criteria (name or count).
+ * Sort terms by the specified criteria (name, count, or menu_order).
*
* @param array $terms Array of term objects to sort.
* @param string $orderby Sort field (name, count, menu_order).
@@ -472,6 +544,16 @@ final class ProductFilterTaxonomy extends AbstractBlock {
$comparison = $count_a <=> $count_b;
break;
+ case 'menu_order':
+ $order_a = $a->menu_order ?? 0;
+ $order_b = $b->menu_order ?? 0;
+ $comparison = $order_a <=> $order_b;
+ // Secondary sort by name when menu_order is equal.
+ if ( 0 === $comparison ) {
+ $comparison = strcasecmp( $a->name, $b->name );
+ }
+ break;
+
case 'name':
default:
$comparison = strcasecmp( $a->name, $b->name );
diff --git a/plugins/woocommerce/src/Internal/ProductFilters/CacheController.php b/plugins/woocommerce/src/Internal/ProductFilters/CacheController.php
index 2321822fff..48b39e17d5 100644
--- a/plugins/woocommerce/src/Internal/ProductFilters/CacheController.php
+++ b/plugins/woocommerce/src/Internal/ProductFilters/CacheController.php
@@ -50,6 +50,11 @@ class CacheController implements RegisterHooksInterface {
add_action( 'created_term', array( $this, 'clear_taxonomy_hierarchy_cache' ), 10, 3 );
add_action( 'edited_term', array( $this, 'clear_taxonomy_hierarchy_cache' ), 10, 3 );
add_action( 'delete_term', array( $this, 'clear_taxonomy_hierarchy_cache' ), 10, 3 );
+
+ // Clear taxonomy hierarchy cache when term meta (like 'order') is added or updated.
+ add_action( 'added_term_meta', array( $this, 'clear_taxonomy_hierarchy_cache_on_meta_update' ), 10, 4 );
+ add_action( 'updated_term_meta', array( $this, 'clear_taxonomy_hierarchy_cache_on_meta_update' ), 10, 4 );
+ add_action( 'deleted_term_meta', array( $this, 'clear_taxonomy_hierarchy_cache_on_meta_update' ), 10, 4 );
}
/**
@@ -74,6 +79,29 @@ class CacheController implements RegisterHooksInterface {
}
}
+ /**
+ * Clear taxonomy hierarchy cache when term meta is updated.
+ * This handles the case when categories are reordered (updates 'order' meta).
+ *
+ * @param int $meta_id Meta ID.
+ * @param int $term_id Term ID.
+ * @param string $meta_key Meta key.
+ * @param mixed $meta_value Meta value.
+ */
+ public function clear_taxonomy_hierarchy_cache_on_meta_update( $meta_id, $term_id, $meta_key, $meta_value ): void {
+ // Only clear cache when the 'order' meta key is updated (used for menu ordering).
+ if ( 'order' !== $meta_key ) {
+ return;
+ }
+
+ $term = get_term( $term_id );
+ if ( ! $term instanceof \WP_Term ) {
+ return;
+ }
+
+ $this->taxonomy_hierarchy_data->clear_cache( $term->taxonomy );
+ }
+
/**
* Delete all filter data transients.
*/
diff --git a/plugins/woocommerce/src/Internal/ProductFilters/TaxonomyHierarchyData.php b/plugins/woocommerce/src/Internal/ProductFilters/TaxonomyHierarchyData.php
index 7ccf0c6b19..91cc37296d 100644
--- a/plugins/woocommerce/src/Internal/ProductFilters/TaxonomyHierarchyData.php
+++ b/plugins/woocommerce/src/Internal/ProductFilters/TaxonomyHierarchyData.php
@@ -153,6 +153,9 @@ class TaxonomyHierarchyData {
$temp_parents = array();
$temp_terms = array();
+ // Prime term meta cache in single query to avoid N+1.
+ update_termmeta_cache( wp_list_pluck( $terms, 'term_id' ) );
+
foreach ( $terms as $term ) {
$term_id = $term->term_id;
$parent_id = $term->parent;
@@ -165,11 +168,15 @@ class TaxonomyHierarchyData {
$temp_children[ $parent_id ][] = $term_id;
+ // Get the menu_order from term meta (WooCommerce stores category order in 'order' meta).
+ $menu_order = get_term_meta( $term_id, 'order', true );
+
$temp_terms[ $term_id ] = array(
- 'slug' => $term->slug,
- 'name' => $term->name,
- 'parent' => $parent_id,
- 'term_id' => $term->term_id,
+ 'slug' => $term->slug,
+ 'name' => $term->name,
+ 'parent' => $parent_id,
+ 'term_id' => $term->term_id,
+ 'menu_order' => is_numeric( $menu_order ) ? (int) $menu_order : 0,
);
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductFilters/TaxonomyHierarchyDataTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductFilters/TaxonomyHierarchyDataTest.php
index c52381e3dd..9109ba699c 100644
--- a/plugins/woocommerce/tests/php/src/Internal/ProductFilters/TaxonomyHierarchyDataTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductFilters/TaxonomyHierarchyDataTest.php
@@ -241,4 +241,47 @@ class TaxonomyHierarchyDataTest extends WP_UnitTestCase {
$this->assertEquals( 2, $gaming_tree['depth'] );
$this->assertEquals( $laptops_id, $gaming_tree['parent'] );
}
+
+ /**
+ * Should include menu_order field in tree structure with default value of 0.
+ */
+ public function test_tree_structure_includes_menu_order_default(): void {
+ $electronics_id = $this->create_test_term( 'Electronics' );
+
+ $map = $this->sut->get_hierarchy_map( $this->taxonomy );
+
+ $this->assertArrayHasKey( 'menu_order', $map['tree'][ $electronics_id ] );
+ $this->assertEquals( 0, $map['tree'][ $electronics_id ]['menu_order'] );
+ }
+
+ /**
+ * Should include menu_order field from term meta when set.
+ */
+ public function test_tree_structure_includes_menu_order_from_meta(): void {
+ $electronics_id = $this->create_test_term( 'Electronics' );
+ $laptops_id = $this->create_test_term( 'Laptops', $electronics_id );
+
+ update_term_meta( $electronics_id, 'order', 5 );
+ update_term_meta( $laptops_id, 'order', 10 );
+
+ $this->sut->clear_cache( $this->taxonomy );
+ $map = $this->sut->get_hierarchy_map( $this->taxonomy );
+
+ $this->assertEquals( 5, $map['tree'][ $electronics_id ]['menu_order'] );
+ $this->assertEquals( 10, $map['tree'][ $electronics_id ]['children'][ $laptops_id ]['menu_order'] );
+ }
+
+ /**
+ * Should handle non-numeric menu_order meta gracefully.
+ */
+ public function test_tree_structure_handles_invalid_menu_order_meta(): void {
+ $electronics_id = $this->create_test_term( 'Electronics' );
+
+ update_term_meta( $electronics_id, 'order', 'invalid' );
+
+ $this->sut->clear_cache( $this->taxonomy );
+ $map = $this->sut->get_hierarchy_map( $this->taxonomy );
+
+ $this->assertEquals( 0, $map['tree'][ $electronics_id ]['menu_order'] );
+ }
}