Commit 81c9e2d7915 for woocommerce
commit 81c9e2d791505022dc7f3c8a9f985e7af26da30c
Author: Luigi Teschio <gigitux@gmail.com>
Date: Thu May 7 10:10:55 2026 +0200
Implement variation screen (#64595)
* Register product_variations_classic_redesign feature flag
* Add Init class and register wc-variations-classic assets
* Inject React mount point in variations PHP template
* Add React DataViews variations table
* Add PHP unit tests for Init class
* Add changelog entry for variations classic redesign Stage 1
* Refactor variations table with DataViews composition and Values column
* Fix variations table compliance with admin UI patterns
* Remove CSS workaround for DataViews checkbox margin
* Add @wordpress/ui to wp-min catalog and align admin package.json
* Update pnpm-lock.yaml for @wordpress/ui and @wordpress/dataviews changes
* Fix CI lint failures for variations-classic stage 1
* Fix ESLint webpack resolver crash with pnpm override
* Remove edit_variation fallback to classic UI
* Fix Round 2 CI failures: typescript resolver and PHPStan baseline
* Update pnpm-lock.yaml to reflect eslint-import-resolver-typescript override
* Skip default attribute saves when fields aren't posted
* Add horizontal padding to variations classic toolbar
* Add placeholder image and border to variation thumbnail
* Add empty edit modal triggered by variant title and Edit action
* Render variation options as badges with single-line +N overflow
* Move variation refresh in experimental product app
* fix feature flag
* clean up code
* fix enqueue script
* clean up code
* Add variation view app entry point to webpack config
* Add placeholder image fallback for products without images
* Fix DataViews sticky footer in classic product editor variation view
* Fix constant alignment in ProductVariationsClassicRedesign Init class
* Remove separate variation view webpack entry in favour of main bundle
* Remove placeholder image fallback from variation list
* Move variation-view styles into main stylesheet
* Restore images field render to original
* Fix constant alignment in ProductVariationsClassicRedesign Init class
* fix code
---------
Co-authored-by: James Kemp <me@jckemp.com>
diff --git a/packages/js/experimental-products-app/changelog/add-variation-view-bundle b/packages/js/experimental-products-app/changelog/add-variation-view-bundle
new file mode 100644
index 00000000000..a62b51eda04
--- /dev/null
+++ b/packages/js/experimental-products-app/changelog/add-variation-view-bundle
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add a dedicated variation view bundle for the classic product editor redesign.
diff --git a/packages/js/experimental-products-app/src/fields/registry.tsx b/packages/js/experimental-products-app/src/fields/registry.tsx
new file mode 100644
index 00000000000..a63ace4f81a
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/registry.tsx
@@ -0,0 +1,156 @@
+/**
+ * External dependencies
+ */
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import { fieldExtensions as buttonTextFieldExtensions } from './button_text/field';
+import { fieldExtensions as catalogVisibilityFieldExtensions } from './catalog_visibility/field';
+import { fieldExtensions as categoriesFieldExtensions } from './categories/field';
+import { fieldExtensions as crossSellIdsFieldExtensions } from './cross_sell_ids/field';
+import { fieldExtensions as dateOnSaleFromFieldExtensions } from './date_on_sale_from/field';
+import { fieldExtensions as dateOnSaleToFieldExtensions } from './date_on_sale_to/field';
+import { fieldExtensions as descriptionFieldExtensions } from './description/field';
+import { fieldExtensions as downloadableFieldExtensions } from './downloadable/field';
+import { fieldExtensions as downloadableCountFieldExtensions } from './downloadable_count/field';
+import { fieldExtensions as externalUrlFieldExtensions } from './external_url/field';
+import { fieldExtensions as featuredFieldExtensions } from './featured/field';
+import { fieldExtensions as heightFieldExtensions } from './height/field';
+import { fieldExtensions as imagesFieldExtensions } from './images/field';
+import { fieldExtensions as imagesCountFieldExtensions } from './images_count/field';
+import { fieldExtensions as inventorySummaryFieldExtensions } from './inventory_summary/field';
+import { fieldExtensions as lengthFieldExtensions } from './length/field';
+import { fieldExtensions as linkedProductsCountFieldExtensions } from './linked_products_count/field';
+import { fieldExtensions as manageStockFieldExtensions } from './manage_stock/field';
+import { fieldExtensions as nameFieldExtensions } from './name/field';
+import { fieldExtensions as onSaleFieldExtensions } from './on_sale/field';
+import { fieldExtensions as organizationSummaryFieldExtensions } from './organization_summary/field';
+import { fieldExtensions as priceFieldExtensions } from './price/field';
+import { fieldExtensions as priceSummaryFieldExtensions } from './price_summary/field';
+import { fieldExtensions as productStatusFieldExtensions } from './product_status/field';
+import { fieldExtensions as regularPriceFieldExtensions } from './regular_price/field';
+import { fieldExtensions as salePriceFieldExtensions } from './sale_price/field';
+import { fieldExtensions as scheduleSaleFieldExtensions } from './schedule_sale/field';
+import { fieldExtensions as shippingClassFieldExtensions } from './shipping_class/field';
+import { fieldExtensions as shippingSummaryFieldExtensions } from './shipping_summary/field';
+import { fieldExtensions as shortDescriptionFieldExtensions } from './short_description/field';
+import { fieldExtensions as skuFieldExtensions } from './sku/field';
+import { fieldExtensions as stockFieldExtensions } from './stock/field';
+import { fieldExtensions as stockQuantityFieldExtensions } from './stock_quantity/field';
+import { fieldExtensions as tagsFieldExtensions } from './tags/field';
+import { fieldExtensions as taxStatusFieldExtensions } from './tax_status/field';
+import type { ProductEntityRecord } from './types';
+import { fieldExtensions as typeFieldExtensions } from './type/field';
+import { fieldExtensions as upsellIdsFieldExtensions } from './upsell_ids/field';
+import { fieldExtensions as visibilitySummaryFieldExtensions } from './visibility_summary/field';
+import { fieldExtensions as weightFieldExtensions } from './weight/field';
+import { fieldExtensions as widthFieldExtensions } from './width/field';
+
+type ProductField = Field< ProductEntityRecord >;
+type ProductFieldExtensions = Partial< ProductField >;
+
+export const PRODUCT_FIELD_IDS = [
+ 'name',
+ 'short_description',
+ 'description',
+ 'images',
+ 'images_count',
+ 'product_status',
+ 'sku',
+ 'price',
+ 'regular_price',
+ 'sale_price',
+ 'schedule_sale',
+ 'date_on_sale_from',
+ 'date_on_sale_to',
+ 'on_sale',
+ 'price_summary',
+ 'stock',
+ 'stock_quantity',
+ 'manage_stock',
+ 'inventory_summary',
+ 'categories',
+ 'tags',
+ 'organization_summary',
+ 'type',
+ 'featured',
+ 'catalog_visibility',
+ 'visibility_summary',
+ 'downloadable',
+ 'downloadable_count',
+ 'external_url',
+ 'button_text',
+ 'weight',
+ 'length',
+ 'width',
+ 'height',
+ 'shipping_class',
+ 'shipping_summary',
+ 'tax_status',
+ 'upsell_ids',
+ 'cross_sell_ids',
+ 'linked_products_count',
+] as const;
+
+export type ProductFieldId = ( typeof PRODUCT_FIELD_IDS )[ number ];
+
+const PRODUCT_FIELD_EXTENSIONS: Record<
+ ProductFieldId,
+ ProductFieldExtensions
+> = {
+ name: nameFieldExtensions,
+ short_description: shortDescriptionFieldExtensions,
+ description: descriptionFieldExtensions,
+ images: imagesFieldExtensions,
+ images_count: imagesCountFieldExtensions,
+ product_status: productStatusFieldExtensions,
+ sku: skuFieldExtensions,
+ price: priceFieldExtensions as ProductFieldExtensions,
+ regular_price: regularPriceFieldExtensions,
+ sale_price: salePriceFieldExtensions,
+ schedule_sale: scheduleSaleFieldExtensions,
+ date_on_sale_from: dateOnSaleFromFieldExtensions,
+ date_on_sale_to: dateOnSaleToFieldExtensions,
+ on_sale: onSaleFieldExtensions,
+ price_summary: priceSummaryFieldExtensions,
+ stock: stockFieldExtensions,
+ stock_quantity: stockQuantityFieldExtensions,
+ manage_stock: manageStockFieldExtensions,
+ inventory_summary: inventorySummaryFieldExtensions,
+ categories: categoriesFieldExtensions,
+ tags: tagsFieldExtensions,
+ organization_summary: organizationSummaryFieldExtensions,
+ type: typeFieldExtensions,
+ featured: featuredFieldExtensions,
+ catalog_visibility: catalogVisibilityFieldExtensions,
+ visibility_summary: visibilitySummaryFieldExtensions,
+ downloadable: downloadableFieldExtensions,
+ downloadable_count: downloadableCountFieldExtensions,
+ external_url: externalUrlFieldExtensions,
+ button_text: buttonTextFieldExtensions,
+ weight: weightFieldExtensions,
+ length: lengthFieldExtensions,
+ width: widthFieldExtensions,
+ height: heightFieldExtensions,
+ shipping_class: shippingClassFieldExtensions,
+ shipping_summary: shippingSummaryFieldExtensions,
+ tax_status: taxStatusFieldExtensions,
+ upsell_ids: upsellIdsFieldExtensions,
+ cross_sell_ids: crossSellIdsFieldExtensions,
+ linked_products_count: linkedProductsCountFieldExtensions,
+};
+
+export function createProductField( id: ProductFieldId ): ProductField {
+ return {
+ id,
+ ...PRODUCT_FIELD_EXTENSIONS[ id ],
+ };
+}
+
+export function createProductFields(
+ fieldIds: readonly ProductFieldId[]
+): ProductField[] {
+ return fieldIds.map( createProductField );
+}
diff --git a/packages/js/experimental-products-app/src/index.ts b/packages/js/experimental-products-app/src/index.ts
index 5880661f224..bfe124e06b3 100644
--- a/packages/js/experimental-products-app/src/index.ts
+++ b/packages/js/experimental-products-app/src/index.ts
@@ -1,2 +1,3 @@
export * from './products';
export { ProductsApp } from './app';
+export { initializeVariationView } from './variation-view-app';
diff --git a/packages/js/experimental-products-app/src/product-list/fields.tsx b/packages/js/experimental-products-app/src/product-list/fields.tsx
index 7cd45203ff1..c876dd5a394 100644
--- a/packages/js/experimental-products-app/src/product-list/fields.tsx
+++ b/packages/js/experimental-products-app/src/product-list/fields.tsx
@@ -1,146 +1,9 @@
-/**
- * External dependencies
- */
-import type { Field } from '@wordpress/dataviews';
-
/**
* Internal dependencies
*/
-import { fieldExtensions as buttonTextFieldExtensions } from '../fields/button_text/field';
-import { fieldExtensions as catalogVisibilityFieldExtensions } from '../fields/catalog_visibility/field';
-import { fieldExtensions as categoriesFieldExtensions } from '../fields/categories/field';
-import { fieldExtensions as crossSellIdsFieldExtensions } from '../fields/cross_sell_ids/field';
-import { fieldExtensions as dateOnSaleFromFieldExtensions } from '../fields/date_on_sale_from/field';
-import { fieldExtensions as dateOnSaleToFieldExtensions } from '../fields/date_on_sale_to/field';
-import { fieldExtensions as descriptionFieldExtensions } from '../fields/description/field';
-import { fieldExtensions as downloadableFieldExtensions } from '../fields/downloadable/field';
-import { fieldExtensions as downloadableCountFieldExtensions } from '../fields/downloadable_count/field';
-import { fieldExtensions as externalUrlFieldExtensions } from '../fields/external_url/field';
-import { fieldExtensions as featuredFieldExtensions } from '../fields/featured/field';
-import { fieldExtensions as heightFieldExtensions } from '../fields/height/field';
-import { fieldExtensions as imagesFieldExtensions } from '../fields/images/field';
-import { fieldExtensions as imagesCountFieldExtensions } from '../fields/images_count/field';
-import { fieldExtensions as inventorySummaryFieldExtensions } from '../fields/inventory_summary/field';
-import { fieldExtensions as lengthFieldExtensions } from '../fields/length/field';
-import { fieldExtensions as linkedProductsCountFieldExtensions } from '../fields/linked_products_count/field';
-import { fieldExtensions as manageStockFieldExtensions } from '../fields/manage_stock/field';
-import { fieldExtensions as nameFieldExtensions } from '../fields/name/field';
-import { fieldExtensions as onSaleFieldExtensions } from '../fields/on_sale/field';
-import { fieldExtensions as organizationSummaryFieldExtensions } from '../fields/organization_summary/field';
-import { fieldExtensions as priceFieldExtensions } from '../fields/price/field';
-import { fieldExtensions as priceSummaryFieldExtensions } from '../fields/price_summary/field';
-import { fieldExtensions as productStatusFieldExtensions } from '../fields/product_status/field';
-import { fieldExtensions as regularPriceFieldExtensions } from '../fields/regular_price/field';
-import { fieldExtensions as salePriceFieldExtensions } from '../fields/sale_price/field';
-import { fieldExtensions as scheduleSaleFieldExtensions } from '../fields/schedule_sale/field';
-import { fieldExtensions as shippingClassFieldExtensions } from '../fields/shipping_class/field';
-import { fieldExtensions as shippingSummaryFieldExtensions } from '../fields/shipping_summary/field';
-import { fieldExtensions as shortDescriptionFieldExtensions } from '../fields/short_description/field';
-import { fieldExtensions as skuFieldExtensions } from '../fields/sku/field';
-import { fieldExtensions as stockFieldExtensions } from '../fields/stock/field';
-import { fieldExtensions as stockQuantityFieldExtensions } from '../fields/stock_quantity/field';
-import { fieldExtensions as tagsFieldExtensions } from '../fields/tags/field';
-import { fieldExtensions as taxStatusFieldExtensions } from '../fields/tax_status/field';
-import type { ProductEntityRecord } from '../fields/types';
-import { fieldExtensions as typeFieldExtensions } from '../fields/type/field';
-import { fieldExtensions as upsellIdsFieldExtensions } from '../fields/upsell_ids/field';
-import { fieldExtensions as visibilitySummaryFieldExtensions } from '../fields/visibility_summary/field';
-import { fieldExtensions as weightFieldExtensions } from '../fields/weight/field';
-import { fieldExtensions as widthFieldExtensions } from '../fields/width/field';
-
-type ProductField = Field< ProductEntityRecord >;
-type ProductFieldExtensions = Partial< ProductField >;
-
-const PRODUCT_LIST_FIELD_IDS = [
- 'name',
- 'short_description',
- 'description',
- 'images',
- 'images_count',
- 'product_status',
- 'sku',
- 'price',
- 'regular_price',
- 'sale_price',
- 'schedule_sale',
- 'date_on_sale_from',
- 'date_on_sale_to',
- 'on_sale',
- 'price_summary',
- 'stock',
- 'stock_quantity',
- 'manage_stock',
- 'inventory_summary',
- 'categories',
- 'tags',
- 'organization_summary',
- 'type',
- 'featured',
- 'catalog_visibility',
- 'visibility_summary',
- 'downloadable',
- 'downloadable_count',
- 'external_url',
- 'button_text',
- 'weight',
- 'length',
- 'width',
- 'height',
- 'shipping_class',
- 'shipping_summary',
- 'tax_status',
- 'upsell_ids',
- 'cross_sell_ids',
- 'linked_products_count',
-] as const;
-
-const PRODUCT_LIST_FIELD_EXTENSIONS: Record< string, ProductFieldExtensions > =
- {
- name: nameFieldExtensions,
- short_description: shortDescriptionFieldExtensions,
- description: descriptionFieldExtensions,
- images: imagesFieldExtensions,
- images_count: imagesCountFieldExtensions,
- product_status: productStatusFieldExtensions,
- sku: skuFieldExtensions,
- price: priceFieldExtensions as ProductFieldExtensions,
- regular_price: regularPriceFieldExtensions,
- sale_price: salePriceFieldExtensions,
- schedule_sale: scheduleSaleFieldExtensions,
- date_on_sale_from: dateOnSaleFromFieldExtensions,
- date_on_sale_to: dateOnSaleToFieldExtensions,
- on_sale: onSaleFieldExtensions,
- price_summary: priceSummaryFieldExtensions,
- stock: stockFieldExtensions,
- stock_quantity: stockQuantityFieldExtensions,
- manage_stock: manageStockFieldExtensions,
- inventory_summary: inventorySummaryFieldExtensions,
- categories: categoriesFieldExtensions,
- tags: tagsFieldExtensions,
- organization_summary: organizationSummaryFieldExtensions,
- type: typeFieldExtensions,
- featured: featuredFieldExtensions,
- catalog_visibility: catalogVisibilityFieldExtensions,
- visibility_summary: visibilitySummaryFieldExtensions,
- downloadable: downloadableFieldExtensions,
- downloadable_count: downloadableCountFieldExtensions,
- external_url: externalUrlFieldExtensions,
- button_text: buttonTextFieldExtensions,
- weight: weightFieldExtensions,
- length: lengthFieldExtensions,
- width: widthFieldExtensions,
- height: heightFieldExtensions,
- shipping_class: shippingClassFieldExtensions,
- shipping_summary: shippingSummaryFieldExtensions,
- tax_status: taxStatusFieldExtensions,
- upsell_ids: upsellIdsFieldExtensions,
- cross_sell_ids: crossSellIdsFieldExtensions,
- linked_products_count: linkedProductsCountFieldExtensions,
- };
+import {
+ PRODUCT_FIELD_IDS as PRODUCT_LIST_FIELD_IDS,
+ createProductFields,
+} from '../fields/registry';
-export const productFields: ProductField[] = PRODUCT_LIST_FIELD_IDS.map(
- ( id ) => ( {
- id,
- ...PRODUCT_LIST_FIELD_EXTENSIONS[ id ],
- } )
-);
+export const productFields = createProductFields( PRODUCT_LIST_FIELD_IDS );
diff --git a/packages/js/experimental-products-app/src/style.scss b/packages/js/experimental-products-app/src/style.scss
index 943196696c1..8376a7b3813 100644
--- a/packages/js/experimental-products-app/src/style.scss
+++ b/packages/js/experimental-products-app/src/style.scss
@@ -95,3 +95,5 @@
overflow: auto;
padding: 16px;
}
+
+@import "./variation-view/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
new file mode 100644
index 00000000000..d822b8a8896
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view-app.tsx
@@ -0,0 +1,39 @@
+/**
+ * External dependencies
+ */
+import { StrictMode, Suspense, createRoot, lazy } from '@wordpress/element';
+
+const VariationView = lazy( () =>
+ import(
+ /* webpackChunkName: "experimental-products-app-variation-view-main" */
+ './variation-view'
+ ).then( ( module ) => ( {
+ default: module.VariationView,
+ } ) )
+);
+
+/**
+ * Initializes the classic product editor variation view.
+ *
+ * @param {string} containerId DOM element ID.
+ * @param {number} productId Parent product ID.
+ */
+export function initializeVariationView(
+ containerId: string,
+ productId: number
+): void {
+ const target = document.getElementById( containerId );
+
+ if ( ! target ) {
+ return undefined;
+ }
+
+ const root = createRoot( target );
+ root.render(
+ <StrictMode>
+ <Suspense fallback={ null }>
+ <VariationView productId={ productId } />
+ </Suspense>
+ </StrictMode>
+ );
+}
diff --git a/packages/js/experimental-products-app/src/variation-view/constants.ts b/packages/js/experimental-products-app/src/variation-view/constants.ts
new file mode 100644
index 00000000000..32eb997ef90
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/constants.ts
@@ -0,0 +1,42 @@
+/**
+ * External dependencies
+ */
+import type { SupportedLayouts, ViewTable } from '@wordpress/dataviews';
+
+export const PAGE_SIZE = 20;
+
+export const DEFAULT_VARIATION_TABLE_TITLE_FIELD = 'name';
+export const DEFAULT_VARIATION_TABLE_MEDIA_FIELD = 'images';
+
+export const DEFAULT_VARIATION_TABLE_FIELDS = [
+ 'variation_options',
+ 'price',
+ 'stock',
+] as const;
+
+export const DEFAULT_LAYOUTS = {
+ table: {
+ fields: [ ...DEFAULT_VARIATION_TABLE_FIELDS ],
+ layout: {
+ density: 'compact',
+ styles: {
+ price: {
+ align: 'end',
+ },
+ },
+ },
+ } satisfies NonNullable< SupportedLayouts[ 'table' ] >,
+};
+
+export const DEFAULT_VIEW: ViewTable = {
+ type: 'table',
+ page: 1,
+ perPage: PAGE_SIZE,
+ search: '',
+ filters: [],
+ titleField: DEFAULT_VARIATION_TABLE_TITLE_FIELD,
+ mediaField: DEFAULT_VARIATION_TABLE_MEDIA_FIELD,
+ fields: [ ...DEFAULT_VARIATION_TABLE_FIELDS ],
+ showMedia: true,
+ layout: DEFAULT_LAYOUTS.table.layout,
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields.tsx b/packages/js/experimental-products-app/src/variation-view/fields.tsx
new file mode 100644
index 00000000000..c464c4e37d0
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields.tsx
@@ -0,0 +1,34 @@
+/**
+ * External dependencies
+ */
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import { createProductField, type ProductFieldId } from '../fields/registry';
+import type { VariationEntityRecord } from './types';
+
+const REUSED_VARIATION_FIELD_IDS = [
+ 'name',
+ 'sku',
+ 'price',
+ 'regular_price',
+ 'sale_price',
+ 'stock',
+ 'stock_quantity',
+ 'manage_stock',
+ 'product_status',
+ 'images',
+ 'downloadable',
+ 'weight',
+ 'length',
+ 'width',
+ 'height',
+ 'shipping_class',
+ 'tax_status',
+] as const satisfies readonly ProductFieldId[];
+
+export const variationFields = REUSED_VARIATION_FIELD_IDS.map(
+ ( id ) => createProductField( id ) as Field< VariationEntityRecord >
+);
diff --git a/packages/js/experimental-products-app/src/variation-view/index.tsx b/packages/js/experimental-products-app/src/variation-view/index.tsx
new file mode 100644
index 00000000000..d3483456187
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/index.tsx
@@ -0,0 +1,145 @@
+/**
+ * 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 { useSelect } from '@wordpress/data';
+import { experimentalProductVariationsStore } from '@woocommerce/data';
+
+/**
+ * Internal dependencies
+ */
+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';
+
+const EMPTY_ARRAY: VariationEntityRecord[] = [];
+
+type VariationViewProps = {
+ productId: number;
+};
+
+export function VariationView( { productId }: VariationViewProps ) {
+ const [ view, setView ] = useState< View >( DEFAULT_VIEW );
+ const [ selection, setSelection ] = useState< string[] >( [] );
+ const [ editingVariation, setEditingVariation ] =
+ useState< VariationEntityRecord | null >( null );
+
+ const query = useMemo(
+ () => buildVariationViewQuery( view, productId ),
+ [ productId, view ]
+ );
+
+ const { records, totalItems, error } = useSelect(
+ ( select ) => {
+ const store = select( experimentalProductVariationsStore );
+ 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 ),
+ };
+ },
+ [ query ]
+ );
+
+ const variations = useMemo(
+ () => records?.map( normalizeVariation ) || EMPTY_ARRAY,
+ [ records ]
+ );
+ const perPage = view.perPage || PAGE_SIZE;
+ const paginationInfo = useMemo(
+ () => ( {
+ totalItems: totalItems ?? 0,
+ totalPages: Math.ceil( ( totalItems ?? 0 ) / perPage ),
+ } ),
+ [ perPage, totalItems ]
+ );
+
+ const handleEditVariation = useCallback(
+ ( variation: VariationEntityRecord ) => {
+ setEditingVariation( variation );
+ },
+ []
+ );
+
+ const actions: Action< VariationEntityRecord >[] = useMemo(
+ () => [
+ {
+ id: 'edit',
+ label: __( 'Edit', 'woocommerce' ),
+ isPrimary: true,
+ callback: ( items ) => handleEditVariation( items[ 0 ] ),
+ },
+ {
+ id: 'delete-variation',
+ label: __( 'Delete variation', 'woocommerce' ),
+ supportsBulk: true,
+ callback: () => {},
+ },
+ ],
+ [ handleEditVariation ]
+ );
+
+ if ( error ) {
+ return (
+ <Notice status="error" isDismissible={ false }>
+ { __( 'Failed to load variations.', 'woocommerce' ) }
+ </Notice>
+ );
+ }
+
+ return (
+ <div className="woocommerce-variation-view">
+ <DataViews
+ data={ variations }
+ fields={ variationFields }
+ view={ view }
+ onClickItem={ handleEditVariation }
+ onChangeView={ setView }
+ isLoading={ ! records }
+ paginationInfo={ paginationInfo }
+ getItemId={ ( item: VariationEntityRecord ) =>
+ String( item.id )
+ }
+ defaultLayouts={ DEFAULT_LAYOUTS }
+ actions={ actions }
+ selection={ selection }
+ onChangeSelection={ setSelection }
+ >
+ <Stack
+ direction="row"
+ align="center"
+ justify="space-between"
+ className="woocommerce-variation-view__toolbar"
+ >
+ <DataViews.Search
+ label={ __( 'Search variations', 'woocommerce' ) }
+ />
+ <Stack direction="row" gap="xs">
+ <DataViews.ViewConfig />
+ <Button disabled>
+ { __( 'Edit options', 'woocommerce' ) }
+ </Button>
+ </Stack>
+ </Stack>
+ <DataViews.Layout />
+ <DataViews.Footer />
+ </DataViews>
+ { editingVariation && (
+ <VariationEditModal
+ variation={ editingVariation }
+ onClose={ () => setEditingVariation( null ) }
+ />
+ ) }
+ </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
new file mode 100644
index 00000000000..35fb9f069ee
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/normalization.test.ts
@@ -0,0 +1,62 @@
+/**
+ * External dependencies
+ */
+import type { ProductVariation } from '@woocommerce/data';
+
+/**
+ * Internal dependencies
+ */
+import { normalizeVariation } from './normalization';
+
+describe( 'normalizeVariation', () => {
+ it( 'maps variation image and product fields for reusable field renderers', () => {
+ const variation = {
+ id: 11,
+ parent_id: 99,
+ name: '',
+ attributes: [
+ {
+ id: 1,
+ name: 'Color',
+ slug: 'pa_color',
+ option: 'Blue',
+ },
+ ],
+ image: {
+ id: 5,
+ src: 'https://example.com/blue.jpg',
+ name: 'Blue image',
+ alt: 'Blue',
+ },
+ manage_stock: 'parent',
+ } as ProductVariation;
+
+ const normalized = normalizeVariation( variation );
+
+ expect( normalized.name ).toBe( 'Blue' );
+ expect( normalized.type ).toBe( 'variation' );
+ expect( normalized.categories ).toEqual( [] );
+ expect( normalized.tags ).toEqual( [] );
+ expect( normalized.manage_stock ).toBe( false );
+ expect( normalized.images ).toEqual( [
+ expect.objectContaining( {
+ id: 5,
+ src: 'https://example.com/blue.jpg',
+ alt: 'Blue',
+ name: 'Blue image',
+ } ),
+ ] );
+ } );
+
+ it( 'falls back to the variation ID when there are no attributes', () => {
+ const normalized = normalizeVariation( {
+ id: 12,
+ parent_id: 99,
+ name: '',
+ attributes: [],
+ manage_stock: false,
+ } as unknown as ProductVariation );
+
+ expect( normalized.name ).toBe( 'Variation #12' );
+ } );
+} );
diff --git a/packages/js/experimental-products-app/src/variation-view/normalization.ts b/packages/js/experimental-products-app/src/variation-view/normalization.ts
new file mode 100644
index 00000000000..7293c5b42d1
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/normalization.ts
@@ -0,0 +1,64 @@
+/**
+ * External dependencies
+ */
+import type { ProductVariation } from '@woocommerce/data';
+import { __, sprintf } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../fields/types';
+import type { VariationEntityRecord } from './types';
+
+function getVariationName( variation: ProductVariation ) {
+ if ( variation.name ) {
+ return variation.name;
+ }
+
+ if ( variation.attributes?.length > 0 ) {
+ return variation.attributes.map( ( attr ) => attr.option ).join( ', ' );
+ }
+
+ return sprintf(
+ /* translators: %d: variation ID. */
+ __( 'Variation #%d', 'woocommerce' ),
+ variation.id
+ );
+}
+
+function getVariationImages(
+ variation: ProductVariation
+): ProductEntityRecord[ 'images' ] {
+ if ( ! variation.image ) {
+ return [];
+ }
+
+ return [
+ {
+ id: variation.image.id,
+ src: variation.image.src,
+ alt: variation.image.alt,
+ name: variation.image.name,
+ thumbnail: variation.image.src,
+ date_created: variation.image.date_created || '',
+ date_created_gmt: variation.image.date_created_gmt || '',
+ date_modified: variation.image.date_modified || '',
+ date_modified_gmt: variation.image.date_modified_gmt || '',
+ },
+ ];
+}
+
+export function normalizeVariation(
+ variation: ProductVariation
+): VariationEntityRecord {
+ return {
+ ...variation,
+ categories: [],
+ tags: [],
+ images: getVariationImages( variation ),
+ name: getVariationName( 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
new file mode 100644
index 00000000000..c2ea782c2f6
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/query.test.ts
@@ -0,0 +1,56 @@
+/**
+ * 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
+ );
+
+ 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',
+ 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',
+ } )
+ );
+ } );
+} );
diff --git a/packages/js/experimental-products-app/src/variation-view/query.ts b/packages/js/experimental-products-app/src/variation-view/query.ts
new file mode 100644
index 00000000000..faa2a8e6ee7
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/query.ts
@@ -0,0 +1,38 @@
+/**
+ * External dependencies
+ */
+import type { View } from '@wordpress/dataviews';
+
+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 function buildVariationViewQuery(
+ view: View,
+ productId: number
+): VariationViewQuery {
+ const query: VariationViewQuery = {
+ product_id: productId,
+ page: view.page ?? 1,
+ per_page: view.perPage ?? 20,
+ };
+
+ 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;
+}
diff --git a/packages/js/experimental-products-app/src/variation-view/style.scss b/packages/js/experimental-products-app/src/variation-view/style.scss
new file mode 100644
index 00000000000..7835fc2de85
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/style.scss
@@ -0,0 +1,50 @@
+// overflow: hidden on .panel-wrap creates a scroll container that prevents
+// position: sticky on .dataviews-footer from working relative to the viewport.
+// overflow: clip preserves the float-clearfix BFC without creating a scroll
+// container, so the footer can stick to the browser bottom.
+#woocommerce-product-data .panel-wrap:has(#woocommerce-variations-classic-root) {
+ overflow: clip;
+}
+
+.woocommerce-variation-view {
+ // DataViews is designed for a constrained editor viewport. Override to let
+ // the table grow to its natural height in the traditional metabox document flow.
+ .dataviews-wrapper {
+ height: auto;
+ }
+
+ .dataviews-layout__container {
+ overflow: visible;
+ }
+
+ &__toolbar {
+ padding: var(--wpds-dimension-padding-lg, 24px);
+ }
+
+ &__variation-options--empty {
+ color: var(--wpds-color-fg-content-neutral-weak, #757575);
+ }
+
+ &__variation-options-cell {
+ position: relative;
+ width: 100%;
+ min-width: 0;
+ }
+
+ &__variation-options-row {
+ flex-wrap: nowrap;
+ overflow: hidden;
+ min-width: 0;
+ }
+
+ &__variation-options-measure {
+ position: absolute;
+ inset-block-start: 0;
+ inset-inline-start: 0;
+ visibility: hidden;
+ pointer-events: none;
+ height: 0;
+ overflow: hidden;
+ white-space: nowrap;
+ }
+}
diff --git a/packages/js/experimental-products-app/src/variation-view/types.ts b/packages/js/experimental-products-app/src/variation-view/types.ts
new file mode 100644
index 00000000000..9cb933bed54
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/types.ts
@@ -0,0 +1,23 @@
+/**
+ * External dependencies
+ */
+import type { ProductVariation } from '@woocommerce/data';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../fields/types';
+
+export type VariationEntityRecord = Omit<
+ ProductEntityRecord,
+ 'attributes' | 'images' | 'manage_stock' | 'type'
+> &
+ Omit< ProductVariation, 'manage_stock' | 'type' > & {
+ attributes: ProductVariation[ 'attributes' ];
+ images: ProductEntityRecord[ 'images' ];
+ image?: ProductVariation[ 'image' ] | null;
+ manage_stock: boolean;
+ parent_id: number;
+ slug: string;
+ type: 'variation';
+ };
diff --git a/packages/js/experimental-products-app/src/variation-view/variation-edit-modal.tsx b/packages/js/experimental-products-app/src/variation-view/variation-edit-modal.tsx
new file mode 100644
index 00000000000..d6dc6873c7c
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/variation-edit-modal.tsx
@@ -0,0 +1,34 @@
+/**
+ * External dependencies
+ */
+import { __, sprintf } from '@wordpress/i18n';
+import { Modal } from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import type { VariationEntityRecord } from './types';
+
+type VariationEditModalProps = {
+ variation: VariationEntityRecord;
+ onClose: () => void;
+};
+
+export function VariationEditModal( {
+ variation,
+ onClose,
+}: VariationEditModalProps ) {
+ return (
+ // @ts-expect-error missing types.
+ <Modal
+ title={ sprintf(
+ /* translators: %d: variation ID. */
+ __( 'Edit variation #%d', 'woocommerce' ),
+ variation.id
+ ) }
+ onRequestClose={ onClose }
+ >
+ { /* TODO: variation edit form */ }
+ </Modal>
+ );
+}
diff --git a/plugins/woocommerce/changelog/add-product-variations-classic-redesign-stage1 b/plugins/woocommerce/changelog/add-product-variations-classic-redesign-stage1
new file mode 100644
index 00000000000..4835c9871ba
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-product-variations-classic-redesign-stage1
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add Stage 1 of the variations classic redesign: a DataViews-based Variations tab in the classic product editor, behind the product_variations_classic_redesign feature flag (default off).
diff --git a/plugins/woocommerce/client/admin/config/core.json b/plugins/woocommerce/client/admin/config/core.json
index 221e75c4bfa..b62ff072f13 100644
--- a/plugins/woocommerce/client/admin/config/core.json
+++ b/plugins/woocommerce/client/admin/config/core.json
@@ -44,6 +44,7 @@
"launch-your-store": true,
"product-editor-template-system": false,
"use-wp-horizon": false,
- "rest-api-v4": false
+ "rest-api-v4": false,
+ "product-variations-classic-redesign": false
}
}
diff --git a/plugins/woocommerce/client/admin/config/development.json b/plugins/woocommerce/client/admin/config/development.json
index 4605c0171d3..3ce9855beaa 100644
--- a/plugins/woocommerce/client/admin/config/development.json
+++ b/plugins/woocommerce/client/admin/config/development.json
@@ -44,6 +44,7 @@
"launch-your-store": true,
"product-editor-template-system": false,
"use-wp-horizon": false,
- "rest-api-v4": false
+ "rest-api-v4": false,
+ "product-variations-classic-redesign": false
}
}
diff --git a/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-data-variations.php b/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-data-variations.php
index 80ecbe9e01d..9e0d6b29297 100644
--- a/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-data-variations.php
+++ b/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-data-variations.php
@@ -5,12 +5,25 @@
* @package WooCommerce\Admin\Metaboxes\Views
*/
+use Automattic\WooCommerce\Admin\Features\Features;
+use Automattic\WooCommerce\Admin\Features\ProductVariationsClassicRedesign\Init;
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CostOfGoodsSoldController;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
+if ( Features::exists( Init::FEATURE_ID ) ) {
+ ?>
+ <div id="variable_product_options" class="panel wc-metaboxes-wrapper hidden">
+ <div id="variable_product_options_inner">
+ <div id="<?php echo esc_attr( Init::ROOT_ID ); ?>"></div>
+ </div>
+ </div>
+ <?php
+ return;
+}
+
$add_attributes_img_url = WC_ADMIN_IMAGES_FOLDER_URL . '/icons/info.svg';
$background_img_url = WC_ADMIN_IMAGES_FOLDER_URL . '/product_data/no-variation-background-image.svg';
$arrow_img_url = WC_ADMIN_IMAGES_FOLDER_URL . '/product_data/no-variation-arrow.svg';
diff --git a/plugins/woocommerce/src/Admin/Features/ProductVariationsClassicRedesign/Init.php b/plugins/woocommerce/src/Admin/Features/ProductVariationsClassicRedesign/Init.php
new file mode 100644
index 00000000000..588a5e71035
--- /dev/null
+++ b/plugins/woocommerce/src/Admin/Features/ProductVariationsClassicRedesign/Init.php
@@ -0,0 +1,71 @@
+<?php
+/**
+ * WooCommerce Product Variations Classic Redesign
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Admin\Features\ProductVariationsClassicRedesign;
+
+/**
+ * Loads assets for the product variations classic redesign feature.
+ */
+class Init {
+ const FEATURE_ID = 'product-variations-classic-redesign';
+ const SCRIPT_HANDLE = 'wc-experimental-products-app';
+ const SCRIPT_PATH = 'experimental-products-app';
+ const ROOT_ID = 'woocommerce-variations-classic-root';
+
+ /**
+ * Constructor
+ */
+ public function __construct() {
+ if ( ! is_admin() ) {
+ return;
+ }
+
+ add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_scripts' ), 20 );
+ }
+
+ /**
+ * Returns true if we are on a product edit screen.
+ */
+ public static function is_product_edit_page(): bool {
+ $screen = get_current_screen();
+ return $screen && 'product' === $screen->post_type && 'post' === $screen->base;
+ }
+
+ /**
+ * Returns true if the user has requested legacy editing for a specific variation.
+ */
+ public static function is_legacy_variation_edit(): bool {
+ // phpcs:disable WordPress.Security.NonceVerification
+ return isset( $_GET['edit_variation'] ) && is_numeric( $_GET['edit_variation'] );
+ // phpcs:enable WordPress.Security.NonceVerification
+ }
+
+ /**
+ * Enqueue scripts and styles for the variations table.
+ */
+ public function enqueue_scripts(): void {
+ if ( ! self::is_product_edit_page() || self::is_legacy_variation_edit() ) {
+ return;
+ }
+
+ wp_enqueue_script( self::SCRIPT_HANDLE );
+ wp_enqueue_style( self::SCRIPT_HANDLE );
+
+ global $post;
+ $product_id = $post ? $post->ID : 0;
+
+ wp_add_inline_script(
+ self::SCRIPT_HANDLE,
+ sprintf(
+ 'window.wc.experimentalProductsApp.initializeVariationView( %s, %d );',
+ wp_json_encode( self::ROOT_ID ),
+ $product_id
+ ),
+ 'after'
+ );
+ }
+}