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'] );
+	}
 }