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;
}