Commit 8a6c4957a6a for woocommerce

commit 8a6c4957a6a4cdff0d2950b21bca8320c2b4f18f
Author: James Kemp <me@jckemp.com>
Date:   Tue May 12 14:37:15 2026 +0100

    Add variation quick edit sidebar with bulk editing support (#64725)

    * WIP

    * improve implementation

    * WIP

    * clean up code

    ---------

    Co-authored-by: Luigi Teschio <gigitux@gmail.com>

diff --git a/packages/js/experimental-products-app/src/style.scss b/packages/js/experimental-products-app/src/style.scss
index 7c4335f8988..bf87fef2685 100644
--- a/packages/js/experimental-products-app/src/style.scss
+++ b/packages/js/experimental-products-app/src/style.scss
@@ -6,6 +6,11 @@

 .product_page_woocommerce-products-dashboard {
 	background-color: unset;
+}
+
+.product_page_woocommerce-products-dashboard,
+#woocommerce-variations-classic-root,
+.woocommerce-product-edit__drawer-portal {
 	@import "./fields/components/list-item/style.scss";
 	@import "./fields/downloadable/style.scss";
 	@import "./fields/images/style.scss";
diff --git a/packages/js/experimental-products-app/src/variation-view-app.tsx b/packages/js/experimental-products-app/src/variation-view-app.tsx
index d822b8a8896..db07c71de0f 100644
--- a/packages/js/experimental-products-app/src/variation-view-app.tsx
+++ b/packages/js/experimental-products-app/src/variation-view-app.tsx
@@ -2,6 +2,14 @@
  * External dependencies
  */
 import { StrictMode, Suspense, createRoot, lazy } from '@wordpress/element';
+import { privateApis as routerPrivateApis } from '@wordpress/router';
+
+/**
+ * Internal dependencies
+ */
+import { unlock } from './lock-unlock';
+
+const { RouterProvider } = unlock( routerPrivateApis );

 const VariationView = lazy( () =>
 	import(
@@ -32,7 +40,9 @@ export function initializeVariationView(
 	root.render(
 		<StrictMode>
 			<Suspense fallback={ null }>
-				<VariationView productId={ productId } />
+				<RouterProvider>
+					<VariationView productId={ productId } />
+				</RouterProvider>
 			</Suspense>
 		</StrictMode>
 	);
diff --git a/packages/js/experimental-products-app/src/variation-view/index.tsx b/packages/js/experimental-products-app/src/variation-view/index.tsx
index d3483456187..193dd7a6fe6 100644
--- a/packages/js/experimental-products-app/src/variation-view/index.tsx
+++ b/packages/js/experimental-products-app/src/variation-view/index.tsx
@@ -2,12 +2,12 @@
  * External dependencies
  */
 import { DataViews, type Action, type View } from '@wordpress/dataviews';
-import { Notice } from '@wordpress/components';
 import { Button, Stack } from '@wordpress/ui';
 import { __ } from '@wordpress/i18n';
-import { useMemo, useState, useCallback } from '@wordpress/element';
+import { useMemo, useState, useCallback, useEffect } from '@wordpress/element';
 import { useSelect } from '@wordpress/data';
-import { experimentalProductVariationsStore } from '@woocommerce/data';
+import { store as coreStore } from '@wordpress/core-data';
+import { privateApis as routerPrivateApis } from '@wordpress/router';

 /**
  * Internal dependencies
@@ -16,59 +16,204 @@ import { DEFAULT_LAYOUTS, DEFAULT_VIEW, PAGE_SIZE } from './constants';
 import { buildVariationViewQuery } from './query';
 import { normalizeVariation } from './normalization';
 import { variationFields } from './fields';
-import { VariationEditModal } from './variation-edit-modal';
 import type { VariationEntityRecord } from './types';
+import ProductEdit from '../product-edit';
+import { getProductWithUpdatedVariation } from '../product-edit/utils';
+import type { ProductEntityRecord } from '../fields/types';
+import { unlock } from '../lock-unlock';
+import {
+	getProductListNavigationPath,
+	getSelectionFromPostId,
+} from '../product-list/utils';

 const EMPTY_ARRAY: VariationEntityRecord[] = [];
+const EMPTY_PRODUCT_RECORDS: ProductEntityRecord[] = [];
+const { useHistory, useLocation } = unlock( routerPrivateApis );

 type VariationViewProps = {
 	productId: number;
 };

+function variationMatchesSearch(
+	variation: VariationEntityRecord,
+	search: string
+) {
+	const value = search.trim().toLowerCase();
+
+	if ( ! value ) {
+		return true;
+	}
+
+	const searchableValues = [
+		variation.name,
+		variation.sku,
+		...( variation.attributes?.map( ( attribute ) => attribute.option ) ??
+			[] ),
+	];
+
+	return searchableValues.some( ( searchableValue ) =>
+		searchableValue?.toLowerCase().includes( value )
+	);
+}
+
+function sortVariations( variations: VariationEntityRecord[], view: View ) {
+	const { field, direction = 'asc' } = view.sort ?? {};
+
+	if ( ! field ) {
+		return variations;
+	}
+
+	const directionModifier = direction === 'desc' ? -1 : 1;
+
+	return [ ...variations ].sort( ( first, second ) => {
+		if ( field === 'name' ) {
+			return first.name.localeCompare( second.name ) * directionModifier;
+		}
+
+		return (
+			( ( first.menu_order ?? 0 ) - ( second.menu_order ?? 0 ) ) *
+			directionModifier
+		);
+	} );
+}
+
 export function VariationView( { productId }: VariationViewProps ) {
+	const { navigate } = useHistory();
+	const location = useLocation();
+	const currentQuery = useMemo(
+		() =>
+			( location.query || {} ) as {
+				postId?: string;
+				quickEdit?: string;
+			},
+		[ location.query ]
+	);
+	const { postId } = currentQuery;
 	const [ view, setView ] = useState< View >( DEFAULT_VIEW );
-	const [ selection, setSelection ] = useState< string[] >( [] );
-	const [ editingVariation, setEditingVariation ] =
-		useState< VariationEntityRecord | null >( null );
+	const [ selection, setSelection ] = useState( () =>
+		getSelectionFromPostId( postId )
+	);
+	const showQuickEdit = currentQuery.quickEdit === 'true';

 	const query = useMemo(
-		() => buildVariationViewQuery( view, productId ),
-		[ productId, view ]
+		() => buildVariationViewQuery( productId ),
+		[ productId ]
 	);

-	const { records, totalItems, error } = useSelect(
+	const { records, parentProduct, hasResolved } = useSelect(
 		( select ) => {
-			const store = select( experimentalProductVariationsStore );
+			const coreSelect = select( coreStore );
+			const resolutionArgs = [ 'root', 'product', query ];
+			const products = coreSelect.getEntityRecords< ProductEntityRecord >(
+				'root',
+				'product',
+				query
+			);
+
 			return {
-				// @ts-expect-error missing types.
-				records: store.getProductVariations( query ),
-				// @ts-expect-error missing types.
-				totalItems: store.getProductVariationsTotalCount( query ),
-				// @ts-expect-error missing types.
-				error: store.getProductVariationsError( query ),
+				hasResolved: coreSelect.hasFinishedResolution(
+					'getEntityRecords',
+					resolutionArgs
+				),
+				parentProduct: products?.[ 0 ],
+				records: products
+					? products[ 0 ]?._embedded?.variations ??
+					  EMPTY_PRODUCT_RECORDS
+					: undefined,
 			};
 		},
-		[ query ]
+		[ productId, query ]
 	);

-	const variations = useMemo(
+	const allVariations = useMemo< VariationEntityRecord[] >(
 		() => records?.map( normalizeVariation ) || EMPTY_ARRAY,
 		[ records ]
 	);
+	const filteredVariations = useMemo(
+		() =>
+			sortVariations(
+				allVariations.filter( ( variation ) =>
+					variationMatchesSearch( variation, view.search ?? '' )
+				),
+				view
+			),
+		[ allVariations, view ]
+	);
+	const productWithVariations = useMemo( () => {
+		if ( ! parentProduct ) {
+			return undefined;
+		}
+
+		return allVariations.reduce< ProductEntityRecord >(
+			( product, variation ) =>
+				getProductWithUpdatedVariation(
+					product,
+					variation as unknown as ProductEntityRecord
+				),
+			parentProduct
+		);
+	}, [ allVariations, parentProduct ] );
 	const perPage = view.perPage || PAGE_SIZE;
+	const variations = useMemo< VariationEntityRecord[] >( () => {
+		const page = view.page ?? 1;
+		const offset = ( page - 1 ) * perPage;
+
+		return filteredVariations.slice( offset, offset + perPage );
+	}, [ filteredVariations, perPage, view.page ] );
+
 	const paginationInfo = useMemo(
 		() => ( {
-			totalItems: totalItems ?? 0,
-			totalPages: Math.ceil( ( totalItems ?? 0 ) / perPage ),
+			totalItems: filteredVariations.length,
+			totalPages: Math.ceil( filteredVariations.length / perPage ),
 		} ),
-		[ perPage, totalItems ]
+		[ filteredVariations.length, perPage ]
+	);
+
+	useEffect( () => {
+		setSelection( getSelectionFromPostId( postId ) );
+	}, [ postId ] );
+
+	const onChangeSelection = useCallback(
+		( items: string[] ) => {
+			setSelection( items );
+
+			const nextQuery = { ...currentQuery };
+
+			if ( items.length > 0 ) {
+				nextQuery.postId = items.join( ',' );
+			} else {
+				delete nextQuery.postId;
+			}
+
+			navigate(
+				getProductListNavigationPath( location.path, nextQuery )
+			);
+		},
+		[ currentQuery, location.path, navigate ]
+	);
+
+	const handleEditSelectedVariations = useCallback(
+		( selectedIds: string[] ) => {
+			if ( selectedIds.length === 0 ) {
+				return;
+			}
+
+			navigate(
+				getProductListNavigationPath( location.path, {
+					...currentQuery,
+					postId: selectedIds.join( ',' ),
+					quickEdit: 'true',
+				} )
+			);
+		},
+		[ currentQuery, location.path, navigate ]
 	);

 	const handleEditVariation = useCallback(
 		( variation: VariationEntityRecord ) => {
-			setEditingVariation( variation );
+			handleEditSelectedVariations( [ String( variation.id ) ] );
 		},
-		[]
+		[ handleEditSelectedVariations ]
 	);

 	const actions: Action< VariationEntityRecord >[] = useMemo(
@@ -77,7 +222,11 @@ export function VariationView( { productId }: VariationViewProps ) {
 				id: 'edit',
 				label: __( 'Edit', 'woocommerce' ),
 				isPrimary: true,
-				callback: ( items ) => handleEditVariation( items[ 0 ] ),
+				supportsBulk: true,
+				callback: ( items ) =>
+					handleEditSelectedVariations(
+						items.map( ( item ) => String( item.id ) )
+					),
 			},
 			{
 				id: 'delete-variation',
@@ -86,17 +235,9 @@ export function VariationView( { productId }: VariationViewProps ) {
 				callback: () => {},
 			},
 		],
-		[ handleEditVariation ]
+		[ handleEditSelectedVariations ]
 	);

-	if ( error ) {
-		return (
-			<Notice status="error" isDismissible={ false }>
-				{ __( 'Failed to load variations.', 'woocommerce' ) }
-			</Notice>
-		);
-	}
-
 	return (
 		<div className="woocommerce-variation-view">
 			<DataViews
@@ -105,7 +246,7 @@ export function VariationView( { productId }: VariationViewProps ) {
 				view={ view }
 				onClickItem={ handleEditVariation }
 				onChangeView={ setView }
-				isLoading={ ! records }
+				isLoading={ ! hasResolved }
 				paginationInfo={ paginationInfo }
 				getItemId={ ( item: VariationEntityRecord ) =>
 					String( item.id )
@@ -113,7 +254,7 @@ export function VariationView( { productId }: VariationViewProps ) {
 				defaultLayouts={ DEFAULT_LAYOUTS }
 				actions={ actions }
 				selection={ selection }
-				onChangeSelection={ setSelection }
+				onChangeSelection={ onChangeSelection }
 			>
 				<Stack
 					direction="row"
@@ -126,7 +267,12 @@ export function VariationView( { productId }: VariationViewProps ) {
 					/>
 					<Stack direction="row" gap="xs">
 						<DataViews.ViewConfig />
-						<Button disabled>
+						<Button
+							disabled={ selection.length === 0 }
+							onClick={ () =>
+								handleEditSelectedVariations( selection )
+							}
+						>
 							{ __( 'Edit options', 'woocommerce' ) }
 						</Button>
 					</Stack>
@@ -134,11 +280,8 @@ export function VariationView( { productId }: VariationViewProps ) {
 				<DataViews.Layout />
 				<DataViews.Footer />
 			</DataViews>
-			{ editingVariation && (
-				<VariationEditModal
-					variation={ editingVariation }
-					onClose={ () => setEditingVariation( null ) }
-				/>
+			{ showQuickEdit && productWithVariations && (
+				<ProductEdit products={ [ productWithVariations ] } />
 			) }
 		</div>
 	);
diff --git a/packages/js/experimental-products-app/src/variation-view/normalization.test.ts b/packages/js/experimental-products-app/src/variation-view/normalization.test.ts
index 35fb9f069ee..c352e43d9d5 100644
--- a/packages/js/experimental-products-app/src/variation-view/normalization.test.ts
+++ b/packages/js/experimental-products-app/src/variation-view/normalization.test.ts
@@ -59,4 +59,31 @@ describe( 'normalizeVariation', () => {

 		expect( normalized.name ).toBe( 'Variation #12' );
 	} );
+
+	it( 'keeps product-style images from embedded variations', () => {
+		const images = [
+			{
+				id: 6,
+				src: 'https://example.com/red.jpg',
+				alt: 'Red',
+				name: 'Red image',
+				thumbnail: 'https://example.com/red-thumbnail.jpg',
+				date_created: '',
+				date_created_gmt: '',
+				date_modified: '',
+				date_modified_gmt: '',
+			},
+		];
+
+		const normalized = normalizeVariation( {
+			id: 13,
+			parent_id: 99,
+			name: 'Red',
+			attributes: [],
+			images,
+			manage_stock: true,
+		} as unknown as ProductVariation );
+
+		expect( normalized.images ).toBe( images );
+	} );
 } );
diff --git a/packages/js/experimental-products-app/src/variation-view/normalization.ts b/packages/js/experimental-products-app/src/variation-view/normalization.ts
index 7293c5b42d1..534490b67c2 100644
--- a/packages/js/experimental-products-app/src/variation-view/normalization.ts
+++ b/packages/js/experimental-products-app/src/variation-view/normalization.ts
@@ -10,13 +10,32 @@ import { __, sprintf } from '@wordpress/i18n';
 import type { ProductEntityRecord } from '../fields/types';
 import type { VariationEntityRecord } from './types';

-function getVariationName( variation: ProductVariation ) {
+type VariationSource = Pick< ProductVariation, 'id' > & {
+	attributes?: Array< {
+		option?: string;
+		options?: string[];
+	} >;
+	categories?: ProductEntityRecord[ 'categories' ];
+	image?: ProductVariation[ 'image' ] | null;
+	images?: ProductEntityRecord[ 'images' ];
+	manage_stock?: ProductVariation[ 'manage_stock' ];
+	name?: string;
+	parent_id?: number;
+	slug?: string;
+	tags?: ProductEntityRecord[ 'tags' ];
+};
+
+function getVariationName( variation: VariationSource ) {
 	if ( variation.name ) {
 		return variation.name;
 	}

-	if ( variation.attributes?.length > 0 ) {
-		return variation.attributes.map( ( attr ) => attr.option ).join( ', ' );
+	const attributes = variation.attributes ?? [];
+
+	if ( attributes.length > 0 ) {
+		return attributes
+			.flatMap( ( attr ) => attr.option ?? attr.options ?? [] )
+			.join( ', ' );
 	}

 	return sprintf(
@@ -27,8 +46,12 @@ function getVariationName( variation: ProductVariation ) {
 }

 function getVariationImages(
-	variation: ProductVariation
+	variation: VariationSource
 ): ProductEntityRecord[ 'images' ] {
+	if ( variation.images ) {
+		return variation.images;
+	}
+
 	if ( ! variation.image ) {
 		return [];
 	}
@@ -49,15 +72,15 @@ function getVariationImages(
 }

 export function normalizeVariation(
-	variation: ProductVariation
+	variation: VariationSource
 ): VariationEntityRecord {
 	return {
 		...variation,
-		categories: [],
-		tags: [],
+		categories: variation.categories ?? [],
+		tags: variation.tags ?? [],
 		images: getVariationImages( variation ),
 		name: getVariationName( variation ),
-		slug: String( variation.id ),
+		slug: variation.slug ?? String( variation.id ),
 		type: 'variation',
 		manage_stock: variation.manage_stock === true,
 	} as unknown as VariationEntityRecord;
diff --git a/packages/js/experimental-products-app/src/variation-view/query.test.ts b/packages/js/experimental-products-app/src/variation-view/query.test.ts
index c2ea782c2f6..ec3596dc934 100644
--- a/packages/js/experimental-products-app/src/variation-view/query.test.ts
+++ b/packages/js/experimental-products-app/src/variation-view/query.test.ts
@@ -1,55 +1,18 @@
-/**
- * External dependencies
- */
-import type { View } from '@wordpress/dataviews';
-
 /**
  * Internal dependencies
  */
 import { buildVariationViewQuery } from './query';

 describe( 'buildVariationViewQuery', () => {
-	it( 'maps the parent product and pagination params', () => {
-		const query = buildVariationViewQuery(
-			{
-				type: 'table',
-				page: 2,
-				perPage: 15,
-				filters: [],
-			} as View,
-			99
-		);
+	it( 'uses the product list query to fetch the parent product with embedded variations', () => {
+		const query = buildVariationViewQuery( 99 );

 		expect( query ).toEqual(
 			expect.objectContaining( {
-				product_id: 99,
-				page: 2,
-				per_page: 15,
-			} )
-		);
-	} );
-
-	it( 'maps search and supported sorting params', () => {
-		const query = buildVariationViewQuery(
-			{
-				type: 'table',
+				_embed: 1,
+				include: [ 99 ],
 				page: 1,
-				perPage: 20,
-				search: 'blue',
-				sort: {
-					field: 'name',
-					direction: 'asc',
-				},
-				filters: [],
-			} as View,
-			42
-		);
-
-		expect( query ).toEqual(
-			expect.objectContaining( {
-				search: 'blue',
-				order: 'asc',
-				orderby: 'title',
+				per_page: 1,
 			} )
 		);
 	} );
diff --git a/packages/js/experimental-products-app/src/variation-view/query.ts b/packages/js/experimental-products-app/src/variation-view/query.ts
index faa2a8e6ee7..f4bc126be1d 100644
--- a/packages/js/experimental-products-app/src/variation-view/query.ts
+++ b/packages/js/experimental-products-app/src/variation-view/query.ts
@@ -1,38 +1,23 @@
 /**
- * External dependencies
+ * Internal dependencies
  */
-import type { View } from '@wordpress/dataviews';
+import { DEFAULT_VIEW as DEFAULT_PRODUCT_LIST_VIEW } from '../product-list/constants';
+import {
+	buildProductListQuery,
+	type ProductListQuery,
+} from '../product-list/query';

-export type VariationViewQuery = {
-	product_id: number;
-	page: number;
-	per_page: number;
-	search?: string;
-	order?: 'asc' | 'desc';
-	orderby?: 'date' | 'id' | 'include' | 'title' | 'slug' | 'menu_order';
+export type VariationViewQuery = ProductListQuery & {
+	include: number[];
 };

 export function buildVariationViewQuery(
-	view: View,
 	productId: number
 ): VariationViewQuery {
-	const query: VariationViewQuery = {
-		product_id: productId,
-		page: view.page ?? 1,
-		per_page: view.perPage ?? 20,
+	return {
+		...buildProductListQuery( DEFAULT_PRODUCT_LIST_VIEW ),
+		include: [ productId ],
+		page: 1,
+		per_page: 1,
 	};
-
-	if ( view.search ) {
-		query.search = view.search;
-	}
-
-	if ( view.sort?.direction ) {
-		query.order = view.sort.direction;
-	}
-
-	if ( view.sort?.field ) {
-		query.orderby = view.sort.field === 'name' ? 'title' : 'menu_order';
-	}
-
-	return query;
 }