Commit 3d781463939 for woocommerce
commit 3d7814639395eeb4c209bafb3d34ce1f63d3cb22
Author: Poli Gilad <83961704+poligilad-auto@users.noreply.github.com>
Date: Fri May 22 15:40:31 2026 +0200
Add variation-specific edit panel to new variation experience (#65256)
* WIP: scaffold variation-view edit panel with forked fields
Paused mid-work. Fork of the field components into variation-view/fields/
plus a new variation-view/edit/ drawer + form + form-fields. Build does
not yet succeed (missing copied .ts/.tsx siblings like validation.ts).
Coming back to this later.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor: pull shared variation panel fields from main registry
Remove duplicate field definitions from variation-view/fields/ and rewrite
registry.tsx to compose from the main registry via createProductFields().
Shipping fields get an isVisible guard to hide them when virtual=true.
Add empty stubs for variation-only fields to be implemented in later tasks.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor: rename variation downloadable field to downloadable_files
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat: add variation-exclusive fields (tax_class, virtual, backorders, download options)
Replace 6 stub field files with real implementations: tax_class (SelectControl), virtual (checkbox), allow_backorders (SelectControl, manage_stock-gated), low_stock_amount (TextControl, manage_stock-gated), download_limit (checkbox + TextControl, downloadable-gated), download_expiry (checkbox + TextControl, downloadable-gated).
* feat: update variation panel form layout to match approved designs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* dev: add changelog for variation panel field registry
* feat: hide shipping section header when virtual is enabled
* feat: update variation panel form layout to match approved designs
* Fix variations quick edit crashes and save errors
* Add Active/Inactive status for variations with badge and descriptions
* Fix prettier formatting in product_status field
* feat: variation edit panel UI improvements
- Rewrite downloadable files field with Choose File / trash buttons, separator between entries, 40px inputs
- Add download limit and expiry fields with checkbox-controlled inputs and days suffix
- Add images label via BaseControl wrapper; shrink description textarea to 4 rows
- Move downloadable/virtual to unlabeled checkbox group; move tax_class to Price section
- Add product_status Active/Inactive badge; fix shipping section hidden when virtual
- Group download_limit and download_expiry in a sub-group under Downloadable files
- Various style and layout fixes across the drawer form
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat: add Attributes section to variation edit drawer
Adds a new field that renders one SelectControl per variation attribute
at the bottom of the edit drawer. Uses a two-column CSS grid (last item
spans full width when count is odd). Matches by name for local attributes
(id=0) to avoid all dropdowns updating simultaneously when IDs collide.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: preserve post-save entity record edits so drawer re-opens with correct data
After a successful save, saveVariation writes fresh server data into the
parent entity record's edits via editEntityRecord. The previous closeDrawer
immediately called clearEntityRecordEdits, wiping those edits before the
async re-fetch could update the base state. On drawer re-open the base
state was stale, so fields like downloadable files appeared unsaved.
Split close behaviour: navigateAway (used after save) preserves the edits
that hold the just-saved state; closeDrawer (Cancel / X) still clears edits
to discard unsaved changes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Add label above images upload area in product edit
* Redesign downloadable files field to match WooCommerce Core UI
* Fix images label visibility and downloadable default row
* fix: move drawer close navigation to VariationView where location.path is valid
The drawer was calling navigate() with an undefined path because useLocation()
returns path=undefined in the WP admin classic editor context. Navigation
called from index.tsx (VariationView) works correctly. Pass onClose from
VariationView so Cancel/X use the same working navigate call as open does.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: resolve TypeScript errors blocking ESM build in variation edit fields
- attributes/field.tsx: simplify redundant `parentProduct !== false` check
- downloadable_files/field.tsx: ensure id defaults to '' when mapping UploadedItem to ProductDownload
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: use variation_active field (Luigi's) instead of product_status in variation drawer
Replace our own product_status (Published/Draft) in the variation edit panel
with the variation_active field from the main registry, which shows
Active/Inactive — the correct statuses for variations.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix variation save rejecting COGS with null defined_value
* Fix save test assertion - saveEditedEntityRecord not called for variation-only saves
* Fix ESLint errors in variation-view, downloadable, and admin files
* Revert accidental JSDoc change in confirmation-modal.jsx
* Fix variation-view field issues from code review
* Add unit tests for variation-view currency utilities
* Decouple variation edit panel from main product field registry
* Revert unrelated product list field changes to trunk state
* Revert all main product field changes to trunk state
* restore changes
* restore changes
* remove .gitignore
* fix build
* add comment
* lint code
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Luigi Teschio <gigitux@gmail.com>
diff --git a/packages/js/experimental-products-app/changelog/add-variation-panel-fields b/packages/js/experimental-products-app/changelog/add-variation-panel-fields
new file mode 100644
index 00000000000..bc51e6fe12d
--- /dev/null
+++ b/packages/js/experimental-products-app/changelog/add-variation-panel-fields
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add variation-exclusive fields (tax class, virtual, backorders, downloadable options) and refactor panel field registry to compose from shared main registry
diff --git a/packages/js/experimental-products-app/changelog/fix-variation-shipping-tax-class-display b/packages/js/experimental-products-app/changelog/fix-variation-shipping-tax-class-display
new file mode 100644
index 00000000000..d43ebec7da4
--- /dev/null
+++ b/packages/js/experimental-products-app/changelog/fix-variation-shipping-tax-class-display
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix shipping class and tax class selects in variation edit panel to show "Same as parent" instead of blank when no override is set.
diff --git a/packages/js/experimental-products-app/src/variation-view/edit/drawer.tsx b/packages/js/experimental-products-app/src/variation-view/edit/drawer.tsx
new file mode 100644
index 00000000000..dabb8a5a73f
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/edit/drawer.tsx
@@ -0,0 +1,454 @@
+/**
+ * External dependencies
+ */
+import { Button, Spinner } from '@wordpress/components';
+import { store as coreStore } from '@wordpress/core-data';
+import { select as wpSelect, useDispatch, useSelect } from '@wordpress/data';
+import { useCallback, useState } from '@wordpress/element';
+import { __, sprintf } from '@wordpress/i18n';
+import { store as noticesStore } from '@wordpress/notices';
+import { privateApis as routerPrivateApis } from '@wordpress/router';
+import { Drawer } from '@wordpress/ui';
+
+/**
+ * Internal dependencies
+ */
+import { unlock } from '../../lock-unlock';
+import { getSelectionFromPostId } from '../../product-list/utils';
+import {
+ findProductInList,
+ getProductEditRecord,
+ getProductEditFields,
+ getProductWithUpdatedVariation,
+ isProductVariation,
+} from '../../product-edit/utils';
+import { saveSelectedProducts } from '../../product-edit/save';
+import { buildVariationViewQuery } from '../query';
+import type { ProductEntityRecord } from '../fields/types';
+import { variationEditFields } from '../fields/registry';
+import { VariationEditForm } from './form';
+
+const { useLocation } = unlock( routerPrivateApis );
+
+type VariationEditDrawerProps = {
+ products: ProductEntityRecord[];
+ isOpen: boolean;
+ productId: number;
+ onClose: () => void;
+};
+
+function getSaveNoticeMessage( successCount: number, failedCount: number ) {
+ if ( failedCount === 0 ) {
+ if ( successCount === 1 ) {
+ return __( 'Variation saved.', 'woocommerce' );
+ }
+
+ return sprintf(
+ /* translators: %d number of saved variations. */
+ __( '%d variations saved.', 'woocommerce' ),
+ successCount
+ );
+ }
+
+ if ( successCount === 0 ) {
+ if ( failedCount === 1 ) {
+ return __( 'Failed to save variation.', 'woocommerce' );
+ }
+
+ return sprintf(
+ /* translators: %d number of variations that could not be saved. */
+ __( 'Failed to save %d variations.', 'woocommerce' ),
+ failedCount
+ );
+ }
+
+ return sprintf(
+ /* translators: 1: successful variation count, 2: failed variation count. */
+ __(
+ 'Saved %1$d variations. %2$d variations could not be saved.',
+ 'woocommerce'
+ ),
+ successCount,
+ failedCount
+ );
+}
+
+export function VariationEditDrawer( {
+ products,
+ isOpen,
+ productId,
+ onClose,
+}: VariationEditDrawerProps ) {
+ const { query = {} } = useLocation();
+ const requestedProductIdsFromRoute = getSelectionFromPostId( query.postId )
+ .map( ( postId ) => Number( postId ) )
+ .filter( ( postId ) => Number.isSafeInteger( postId ) && postId > 0 );
+ const requestedProductIds = Array.from(
+ new Set( requestedProductIdsFromRoute )
+ );
+
+ const [ isSaving, setIsSaving ] = useState( false );
+
+ const editableFields = getProductEditFields( variationEditFields );
+ const {
+ selectedProducts,
+ isResolving,
+ hasResolved,
+ hasMissingProducts,
+ hasEdits,
+ } = useSelect(
+ ( select ) => {
+ if ( requestedProductIds.length === 0 ) {
+ return {
+ selectedProducts: [],
+ isResolving: false,
+ hasResolved: true,
+ hasMissingProducts: false,
+ hasEdits: false,
+ };
+ }
+
+ const coreSelect = select( coreStore );
+ const productResults = requestedProductIds.map(
+ ( reqProductId ) => {
+ const resolutionArgs = [ 'root', 'product', reqProductId ];
+ const rootRecord = coreSelect.getEditedEntityRecord(
+ 'root',
+ 'product',
+ reqProductId
+ ) as unknown as ProductEntityRecord | false | undefined;
+ const rootRecordEdits = coreSelect.getEntityRecordEdits(
+ 'root',
+ 'product',
+ reqProductId
+ ) as Partial< ProductEntityRecord > | undefined;
+ const listedProduct = findProductInList(
+ products,
+ reqProductId
+ );
+ const product = getProductEditRecord(
+ listedProduct,
+ rootRecord,
+ rootRecordEdits
+ );
+ let record: ProductEntityRecord | false | undefined =
+ product ?? rootRecord;
+
+ if (
+ product &&
+ isProductVariation( product ) &&
+ product.parent_id
+ ) {
+ const parentProduct = coreSelect.getEditedEntityRecord(
+ 'root',
+ 'product',
+ product.parent_id
+ ) as unknown as ProductEntityRecord | false | undefined;
+ const editedParentProduct =
+ parentProduct !== false ? parentProduct : undefined;
+ const editedVariation =
+ editedParentProduct?._embedded?.variations?.find(
+ ( variation ) => variation.id === product.id
+ );
+
+ record = editedVariation || product;
+ }
+
+ return {
+ productId: reqProductId,
+ record,
+ isResolving: listedProduct
+ ? false
+ : coreSelect.isResolving(
+ 'getEditedEntityRecord',
+ resolutionArgs
+ ),
+ hasFinishedResolution: listedProduct
+ ? true
+ : coreSelect.hasFinishedResolution(
+ 'getEditedEntityRecord',
+ resolutionArgs
+ ),
+ };
+ }
+ );
+ const resolvedProducts = productResults
+ .map( ( { record } ) => record )
+ .filter(
+ ( product ): product is ProductEntityRecord =>
+ product !== undefined && product !== false
+ );
+ const editedProductIds = Array.from(
+ new Set(
+ resolvedProducts.map( ( product ) =>
+ isProductVariation( product ) && product.parent_id
+ ? product.parent_id
+ : product.id
+ )
+ )
+ );
+
+ return {
+ selectedProducts: resolvedProducts,
+ isResolving: productResults.some(
+ ( result ) =>
+ result.isResolving || ! result.hasFinishedResolution
+ ),
+ hasResolved: productResults.every(
+ ( result ) => result.hasFinishedResolution
+ ),
+ hasMissingProducts: productResults.some(
+ ( result ) =>
+ result.hasFinishedResolution && result.record === false
+ ),
+ hasEdits: editedProductIds.some( ( editedProductId ) =>
+ coreSelect.hasEditsForEntityRecord(
+ 'root',
+ 'product',
+ editedProductId
+ )
+ ),
+ };
+ },
+ [ products, requestedProductIds ]
+ );
+
+ const {
+ clearEntityRecordEdits,
+ editEntityRecord,
+ saveEditedEntityRecord,
+ invalidateResolution,
+ } = useDispatch( coreStore );
+
+ const { createSuccessNotice, createErrorNotice } =
+ useDispatch( noticesStore );
+
+ const hasNoRequestedProducts = requestedProductIds.length === 0;
+ const isReady =
+ hasResolved &&
+ ! isResolving &&
+ ! hasMissingProducts &&
+ selectedProducts.length === requestedProductIds.length &&
+ selectedProducts.length > 0;
+
+ let title = __( 'Edit variation', 'woocommerce' );
+
+ if ( isReady ) {
+ if ( selectedProducts.length === 1 ) {
+ title = selectedProducts[ 0 ]?.name || title;
+ } else {
+ title = sprintf(
+ /* translators: %d number of selected variations. */
+ __( 'Edit %d variations', 'woocommerce' ),
+ selectedProducts.length
+ );
+ }
+ }
+
+ const onChange = useCallback(
+ ( changes: Partial< ProductEntityRecord > ) => {
+ const updatedParentProductsById = new Map<
+ number,
+ ProductEntityRecord
+ >();
+
+ selectedProducts.forEach( ( product ) => {
+ if ( ! product.parent_id ) {
+ return;
+ }
+
+ const parentProduct =
+ updatedParentProductsById.get( product.parent_id ) ??
+ ( wpSelect( coreStore ).getEditedEntityRecord(
+ 'root',
+ 'product',
+ product.parent_id
+ ) as ProductEntityRecord | false | undefined );
+
+ if ( ! parentProduct ) {
+ return;
+ }
+
+ updatedParentProductsById.set(
+ product.parent_id,
+ getProductWithUpdatedVariation( parentProduct, {
+ ...product,
+ ...changes,
+ } )
+ );
+ } );
+
+ updatedParentProductsById.forEach( ( parentProduct ) => {
+ editEntityRecord( 'root', 'product', parentProduct.id, {
+ _embedded: parentProduct._embedded,
+ } );
+ } );
+ },
+ [ editEntityRecord, selectedProducts ]
+ );
+
+ // Discard unsaved edits and close (Cancel / X button).
+ const closeDrawer = useCallback( () => {
+ const editedProductIds = new Set(
+ selectedProducts.map( ( product ) =>
+ isProductVariation( product ) && product.parent_id
+ ? product.parent_id
+ : product.id
+ )
+ );
+
+ editedProductIds.forEach( ( editedProductId ) => {
+ clearEntityRecordEdits( 'root', 'product', editedProductId );
+ } );
+
+ onClose();
+ }, [ clearEntityRecordEdits, onClose, selectedProducts ] );
+
+ const onSave = useCallback( async () => {
+ if ( selectedProducts.length === 0 || isSaving ) {
+ return;
+ }
+
+ setIsSaving( true );
+
+ try {
+ const results = await saveSelectedProducts( {
+ selectedProducts,
+ editEntityRecord,
+ saveEditedEntityRecord,
+ } );
+
+ const successfulCount = results.filter(
+ ( result ) => result.status === 'fulfilled'
+ ).length;
+ const failedCount = results.length - successfulCount;
+ const message = getSaveNoticeMessage(
+ successfulCount,
+ failedCount
+ );
+
+ if ( failedCount > 0 ) {
+ createErrorNotice( message, {
+ type: 'snackbar',
+ } );
+ return;
+ }
+
+ if ( successfulCount > 0 ) {
+ createSuccessNotice( message, {
+ type: 'snackbar',
+ } );
+ }
+
+ // Invalidate the entity records cache so that VariationView
+ // re-fetches fresh data (including updated _embedded.variations)
+ // the next time the drawer is opened.
+ invalidateResolution( 'getEntityRecords', [
+ 'root',
+ 'product',
+ buildVariationViewQuery( productId ),
+ ] );
+
+ // Navigate without clearing entity record edits. The edits set
+ // by saveVariation hold the fresh server-saved state. Clearing
+ // them here would revert to the stale base state before the
+ // re-fetch completes, making the next drawer open appear to show
+ // unsaved data (e.g. downloads missing).
+ onClose();
+ } finally {
+ setIsSaving( false );
+ }
+ }, [
+ createErrorNotice,
+ createSuccessNotice,
+ editEntityRecord,
+ invalidateResolution,
+ isSaving,
+ onClose,
+ productId,
+ saveEditedEntityRecord,
+ selectedProducts,
+ ] );
+
+ return (
+ <Drawer.Root open={ isOpen } swipeDirection="right">
+ <Drawer.Popup
+ className="woocommerce-product-edit__drawer"
+ portal={
+ <Drawer.Portal className="woocommerce-product-edit__drawer-portal" />
+ }
+ style={ { width: 450 } }
+ >
+ <Drawer.Header className="woocommerce-product-edit__header">
+ <Drawer.Title className="woocommerce-product-edit__title">
+ { title }
+ </Drawer.Title>
+ <Drawer.CloseIcon
+ onClick={ closeDrawer }
+ label={ __( 'Close', 'woocommerce' ) }
+ />
+ </Drawer.Header>
+
+ <Drawer.Content className="woocommerce-product-edit">
+ { hasNoRequestedProducts && (
+ <div className="woocommerce-product-edit__empty-state">
+ <p>
+ { __(
+ 'Select one or more variations to edit them here.',
+ 'woocommerce'
+ ) }
+ </p>
+ </div>
+ ) }
+
+ { ! hasNoRequestedProducts && isResolving && (
+ <div className="woocommerce-product-edit__loading">
+ <Spinner />
+ </div>
+ ) }
+
+ { ! hasNoRequestedProducts &&
+ ! isResolving &&
+ hasMissingProducts && (
+ <div className="woocommerce-product-edit__empty-state">
+ <p>
+ { __(
+ 'Select one or more variations to edit them here.',
+ 'woocommerce'
+ ) }
+ </p>
+ </div>
+ ) }
+
+ { isReady && (
+ <VariationEditForm
+ editableFields={ editableFields }
+ onChange={ onChange }
+ selectedVariations={ selectedProducts }
+ />
+ ) }
+ </Drawer.Content>
+
+ { isReady && (
+ <Drawer.Footer className="woocommerce-product-edit__footer">
+ <Button
+ variant="tertiary"
+ onClick={ closeDrawer }
+ disabled={ isSaving }
+ >
+ { __( 'Cancel', 'woocommerce' ) }
+ </Button>
+ <Button
+ variant="primary"
+ onClick={ onSave }
+ isBusy={ isSaving }
+ disabled={ isSaving || ! hasEdits }
+ >
+ { __( 'Save', 'woocommerce' ) }
+ </Button>
+ </Drawer.Footer>
+ ) }
+ </Drawer.Popup>
+ </Drawer.Root>
+ );
+}
diff --git a/packages/js/experimental-products-app/src/variation-view/edit/form-fields.ts b/packages/js/experimental-products-app/src/variation-view/edit/form-fields.ts
new file mode 100644
index 00000000000..653f078c296
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/edit/form-fields.ts
@@ -0,0 +1,90 @@
+/**
+ * External dependencies
+ */
+import type { FormField } from '@wordpress/dataviews';
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import type { VariationEditFieldId } from '../fields/registry';
+
+type VariationFormField = VariationEditFieldId | FormField;
+
+function createFormGroup(
+ id: string,
+ label: string,
+ children: VariationFormField[]
+): FormField {
+ return { id, label, children };
+}
+
+const SALE_SCHEDULE_DATES: FormField = {
+ id: 'sale-schedule-dates',
+ layout: { type: 'row' as const },
+ children: [ 'date_on_sale_from', 'date_on_sale_to' ],
+};
+
+const DIMENSIONS: FormField = {
+ id: 'dimensions',
+ layout: { type: 'row' as const },
+ children: [ 'length', 'width', 'height' ],
+};
+
+const BACKORDERS_ROW: FormField = {
+ id: 'backorders-row',
+ layout: { type: 'row' as const },
+ children: [ 'allow_backorders', 'low_stock_amount' ],
+};
+
+// Unlabeled groups render children in a nested wrapper without a section header,
+// allowing CSS to tighten the gap specifically for that inner wrapper.
+const GENERAL_CHECKBOXES: FormField = {
+ id: 'general-checkboxes',
+ children: [ 'downloadable', 'virtual' ],
+};
+
+const DOWNLOAD_OPTIONS: FormField = {
+ id: 'download-options',
+ children: [ 'download_limit', 'download_expiry' ],
+};
+
+export const VARIATION_FORM_FIELDS: VariationFormField[] = [
+ createFormGroup( 'general-fields', __( 'General', 'woocommerce' ), [
+ 'variation_active',
+ GENERAL_CHECKBOXES,
+ ] ),
+ createFormGroup( 'price-fields', __( 'Price', 'woocommerce' ), [
+ 'regular_price',
+ 'sale_price',
+ 'schedule_sale',
+ SALE_SCHEDULE_DATES,
+ 'cost_of_goods_sold',
+ 'tax_class',
+ ] ),
+ createFormGroup( 'details-fields', __( 'Details', 'woocommerce' ), [
+ 'images',
+ 'description',
+ ] ),
+ createFormGroup(
+ 'downloadable-files-fields',
+ __( 'Downloadable files', 'woocommerce' ),
+ [ 'downloadable_files', DOWNLOAD_OPTIONS ]
+ ),
+ createFormGroup( 'inventory-fields', __( 'Inventory', 'woocommerce' ), [
+ 'sku',
+ 'global_unique_id',
+ 'manage_stock',
+ 'stock_quantity',
+ 'stock',
+ BACKORDERS_ROW,
+ ] ),
+ createFormGroup( 'shipping-fields', __( 'Shipping', 'woocommerce' ), [
+ 'shipping_class',
+ DIMENSIONS,
+ 'weight',
+ ] ),
+ createFormGroup( 'attributes-fields', __( 'Attributes', 'woocommerce' ), [
+ 'attributes',
+ ] ),
+];
diff --git a/packages/js/experimental-products-app/src/variation-view/edit/form.tsx b/packages/js/experimental-products-app/src/variation-view/edit/form.tsx
new file mode 100644
index 00000000000..70d16c052e8
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/edit/form.tsx
@@ -0,0 +1,93 @@
+/**
+ * External dependencies
+ */
+import { DataForm } from '@wordpress/dataviews';
+import type { FormField } from '@wordpress/dataviews';
+import { getSetting } from '@woocommerce/settings';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../fields/types';
+import {
+ buildMergedProductEditData,
+ getProductEditFields,
+} from '../../product-edit/utils';
+import { VARIATION_FORM_FIELDS } from './form-fields';
+import type { VariationEditFieldId } from '../fields/registry';
+
+type VariationFormField = VariationEditFieldId | FormField;
+
+type VariationEditFormProps = {
+ editableFields: ReturnType< typeof getProductEditFields >;
+ onChange: ( changes: Partial< ProductEntityRecord > ) => void;
+ selectedVariations: ProductEntityRecord[];
+};
+
+function pruneFormFields(
+ fields: VariationFormField[],
+ visibleIds: Set< string >
+): VariationFormField[] {
+ return fields.reduce< VariationFormField[] >( ( acc, field ) => {
+ if ( typeof field === 'string' ) {
+ if ( visibleIds.has( field ) ) {
+ acc.push( field );
+ }
+ return acc;
+ }
+ const prunedChildren = pruneFormFields(
+ ( field.children ?? [] ) as VariationFormField[],
+ visibleIds
+ );
+ if ( prunedChildren.length > 0 ) {
+ acc.push( { ...field, children: prunedChildren } );
+ }
+ return acc;
+ }, [] );
+}
+
+export function VariationEditForm( {
+ editableFields,
+ onChange,
+ selectedVariations,
+}: VariationEditFormProps ) {
+ const mergedData = buildMergedProductEditData( selectedVariations );
+ const isBulkEdit = selectedVariations.length > 1;
+ const adminSettings = getSetting( 'admin', {} );
+ const isCostOfGoodsSoldFeatureEnabled =
+ // @ts-expect-error - This setting is not typed yet.
+ adminSettings?.wcAdminFeatures?.includes( 'cost-of-goods-sold' );
+ const visibleFields = editableFields.filter( ( field ) => {
+ if (
+ field.id === 'cost_of_goods_sold' &&
+ ! isCostOfGoodsSoldFeatureEnabled
+ ) {
+ return false;
+ }
+ if ( isBulkEdit && field.id === 'sku' ) {
+ return false;
+ }
+ if ( typeof field.isVisible !== 'function' ) {
+ return true;
+ }
+ return selectedVariations.every( ( v ) => field.isVisible!( v ) );
+ } );
+ const visibleFieldIds = new Set( visibleFields.map( ( f ) => f.id ) );
+
+ const form = {
+ type: 'regular' as const,
+ labelPosition: 'top' as const,
+ fields: pruneFormFields( VARIATION_FORM_FIELDS, visibleFieldIds ),
+ };
+
+ return (
+ <div className="woocommerce-product-edit__form">
+ <DataForm
+ data={ mergedData }
+ fields={ visibleFields }
+ form={ form }
+ onChange={ onChange }
+ />
+ </div>
+ );
+}
diff --git a/packages/js/experimental-products-app/src/variation-view/fields.tsx b/packages/js/experimental-products-app/src/variation-view/fields.tsx
index 9757d6d7e97..bf76fd56cea 100644
--- a/packages/js/experimental-products-app/src/variation-view/fields.tsx
+++ b/packages/js/experimental-products-app/src/variation-view/fields.tsx
@@ -1,6 +1,8 @@
/**
* External dependencies
*/
+import { __ } from '@wordpress/i18n';
+import { Badge } from '@wordpress/ui';
import type { Field } from '@wordpress/dataviews';
/**
@@ -29,6 +31,28 @@ const REUSED_VARIATION_FIELD_IDS = [
'tax_status',
] as const satisfies readonly ProductFieldId[];
-export const variationFields = REUSED_VARIATION_FIELD_IDS.map(
- ( id ) => createProductField( id ) as Field< VariationEntityRecord >
-);
+export const variationFields = REUSED_VARIATION_FIELD_IDS.map( ( id ) => {
+ const field = createProductField( id ) as Field< VariationEntityRecord >;
+
+ if ( id === 'name' ) {
+ return {
+ ...field,
+ render( { item }: { item: VariationEntityRecord } ) {
+ return (
+ <span className="woocommerce-variation-name">
+ <span className="woocommerce-variation-name__label">
+ { item.name }
+ </span>
+ { item.status === 'private' && (
+ <Badge intent="none">
+ { __( 'Inactive', 'woocommerce' ) }
+ </Badge>
+ ) }
+ </span>
+ );
+ },
+ };
+ }
+
+ return field;
+} );
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/allow_backorders/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/allow_backorders/field.tsx
new file mode 100644
index 00000000000..3c3f493ffa8
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/allow_backorders/field.tsx
@@ -0,0 +1,47 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { SelectControl } from '@wordpress/ui';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ type: 'text',
+ label: __( 'Allow backorders', 'woocommerce' ),
+ enableSorting: false,
+ filterBy: false,
+ isVisible: ( item ) => !! item.manage_stock,
+ getValue: ( { item } ) => item.backorders ?? 'no',
+ Edit: ( { data, onChange, field } ) => {
+ const options = [
+ { value: 'no', label: __( 'Do not allow', 'woocommerce' ) },
+ {
+ value: 'notify',
+ label: __( 'Allow but notify customer', 'woocommerce' ),
+ },
+ { value: 'yes', label: __( 'Allow', 'woocommerce' ) },
+ ];
+ const selected = options.find(
+ ( o ) => o.value === ( data.backorders ?? 'no' )
+ );
+ return (
+ <SelectControl
+ label={ field.label }
+ value={ selected }
+ items={ options }
+ onValueChange={ ( option ) => {
+ if ( option ) {
+ onChange( {
+ backorders: option.value as 'no' | 'notify' | 'yes',
+ } );
+ }
+ } }
+ />
+ );
+ },
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/attributes/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/attributes/field.tsx
new file mode 100644
index 00000000000..885c0075c03
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/attributes/field.tsx
@@ -0,0 +1,137 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { SelectControl } from '@wordpress/components';
+import { store as coreStore } from '@wordpress/core-data';
+import { useSelect } from '@wordpress/data';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+type VariationAttribute = { id: number; name: string; option: string };
+type ParentAttribute = {
+ id: number;
+ name: string;
+ options: string[];
+ variation?: boolean;
+};
+
+// Match by ID for taxonomy attributes (id > 0), by name for local attributes (id === 0).
+function findVariationAttr(
+ variationAttrs: VariationAttribute[],
+ parentAttr: ParentAttribute
+): VariationAttribute | undefined {
+ if ( parentAttr.id !== 0 ) {
+ return variationAttrs.find( ( va ) => va.id === parentAttr.id );
+ }
+ return variationAttrs.find(
+ ( va ) => va.name.toLowerCase() === parentAttr.name.toLowerCase()
+ );
+}
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ label: __( 'Attributes', 'woocommerce' ),
+ enableSorting: false,
+ filterBy: false,
+ isVisible: ( item ) => !! item.parent_id,
+ getValue: ( { item } ) => item.attributes,
+ Edit: ( { data, onChange } ) => {
+ const parentProduct = useSelect(
+ ( select ) => {
+ if ( ! data.parent_id ) {
+ return null;
+ }
+ return select( coreStore ).getEditedEntityRecord(
+ 'root',
+ 'product',
+ data.parent_id
+ ) as ProductEntityRecord | false | undefined;
+ },
+ [ data.parent_id ]
+ );
+
+ const allParentAttributes = parentProduct
+ ? ( ( parentProduct.attributes ??
+ [] ) as unknown as ParentAttribute[] )
+ : [];
+
+ // Only show attributes marked as variation attributes.
+ const parentAttributes = allParentAttributes.filter(
+ ( attr ) => attr.variation !== false
+ );
+
+ const variationAttributes = ( data.attributes ??
+ [] ) as unknown as VariationAttribute[];
+
+ if ( parentAttributes.length === 0 ) {
+ return null;
+ }
+
+ const handleChange = (
+ attrId: number,
+ attrName: string,
+ newOption: string
+ ) => {
+ const updated = variationAttributes.map( ( attr ) => {
+ const matches =
+ attrId !== 0
+ ? attr.id === attrId
+ : attr.name.toLowerCase() === attrName.toLowerCase();
+ return matches ? { ...attr, option: newOption } : attr;
+ } );
+ onChange( {
+ attributes:
+ updated as unknown as ProductEntityRecord[ 'attributes' ],
+ } );
+ };
+
+ const isOdd = parentAttributes.length % 2 !== 0;
+
+ return (
+ <div
+ style={ {
+ display: 'grid',
+ gridTemplateColumns: '1fr 1fr',
+ gap: '16px',
+ } }
+ >
+ { parentAttributes.map( ( attr, index ) => {
+ const selected = findVariationAttr(
+ variationAttributes,
+ attr
+ );
+ const isLastOdd =
+ isOdd && index === parentAttributes.length - 1;
+ const options = attr.options.map( ( opt ) => ( {
+ label: opt,
+ value: opt,
+ } ) );
+
+ return (
+ <div
+ key={ `${ attr.id }-${ attr.name }` }
+ style={
+ isLastOdd ? { gridColumn: '1 / -1' } : undefined
+ }
+ >
+ <SelectControl
+ __nextHasNoMarginBottom
+ __next40pxDefaultSize
+ label={ attr.name }
+ value={ selected?.option ?? '' }
+ options={ options }
+ onChange={ ( value ) =>
+ handleChange( attr.id, attr.name, value )
+ }
+ />
+ </div>
+ );
+ } ) }
+ </div>
+ );
+ },
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/components/currency-input.tsx b/packages/js/experimental-products-app/src/variation-view/fields/components/currency-input.tsx
new file mode 100644
index 00000000000..51be9972f3f
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/components/currency-input.tsx
@@ -0,0 +1,119 @@
+/* eslint-disable @wordpress/no-unsafe-wp-apis -- Currency inputs use wrappers for the private ValidatedInputControl API. */
+/**
+ * External dependencies
+ */
+import {
+ privateApis,
+ __experimentalInputControlPrefixWrapper as InputControlPrefixWrapper,
+ __experimentalInputControlSuffixWrapper as InputControlSuffixWrapper,
+} from '@wordpress/components';
+
+import type { DataFormControlProps } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import { unlock } from '../../../lock-unlock';
+
+import type { ProductEntityRecord } from '../types';
+
+import { getCurrencyObject } from '../utils/currency';
+
+const { ValidatedInputControl } = unlock( privateApis );
+
+const { symbol, symbolPosition, precision } = getCurrencyObject();
+const isCurrencyLeft =
+ symbolPosition === 'left' || symbolPosition === 'left_space';
+// Step matches store decimal precision (e.g. precision=2 → step=0.01).
+const step = Math.pow( 10, -precision );
+
+type CurrencyField = 'regular_price' | 'sale_price';
+
+type CurrencyControlProps = {
+ id: string;
+ label: string;
+ value: string | number;
+ onChange: ( newValue: string ) => void;
+ customValidity?: NonNullable<
+ DataFormControlProps< ProductEntityRecord >[ 'validity' ]
+ >[ 'custom' ];
+ hideLabelFromVision?: boolean;
+ placeholder?: string;
+ disabled?: boolean;
+};
+
+export function CurrencyControl( {
+ id,
+ label,
+ value,
+ onChange,
+ customValidity,
+ hideLabelFromVision,
+ placeholder,
+ disabled,
+}: CurrencyControlProps ) {
+ return (
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-call -- ValidatedInputControl is a private API
+ <ValidatedInputControl
+ id={ id }
+ label={ label }
+ hideLabelFromVision={ hideLabelFromVision }
+ value={ value }
+ placeholder={ placeholder }
+ disabled={ disabled }
+ onChange={ onChange }
+ type="number"
+ min={ 0 }
+ step={ step }
+ customValidity={ customValidity }
+ prefix={
+ isCurrencyLeft ? (
+ <InputControlPrefixWrapper>
+ { symbol }
+ </InputControlPrefixWrapper>
+ ) : undefined
+ }
+ suffix={
+ ! isCurrencyLeft ? (
+ <InputControlSuffixWrapper>
+ { symbol }
+ </InputControlSuffixWrapper>
+ ) : undefined
+ }
+ />
+ );
+}
+
+/**
+ * Shared Edit component for currency fields.
+ * Renders a number input with min=0 and currency prefix/suffix.
+ *
+ * @param root0 Props from DataForm.
+ * @param root0.data Current product entity record.
+ * @param root0.field Normalized field definition.
+ * @param root0.onChange Callback to update entity values.
+ * @param root0.validity Per-rule validation state from useFormValidity.
+ */
+export function CurrencyInput( {
+ data,
+ field,
+ onChange,
+ validity,
+}: DataFormControlProps< ProductEntityRecord > ) {
+ if ( field.id !== 'regular_price' && field.id !== 'sale_price' ) {
+ return null;
+ }
+ const fieldId: CurrencyField = field.id;
+
+ return (
+ <CurrencyControl
+ id={ `currency-input-${ fieldId }` }
+ label={ field.label }
+ value={ data[ fieldId ] ?? '' }
+ onChange={ ( newValue: string ) => {
+ onChange( { [ fieldId ]: newValue } );
+ } }
+ customValidity={ validity?.custom }
+ />
+ );
+}
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/components/date-picker.tsx b/packages/js/experimental-products-app/src/variation-view/fields/components/date-picker.tsx
new file mode 100644
index 00000000000..ef2a5896217
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/components/date-picker.tsx
@@ -0,0 +1,67 @@
+/**
+ * External dependencies
+ */
+import { InputControl } from '@wordpress/ui';
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+type DateFieldKey = 'date_on_sale_from' | 'date_on_sale_to';
+
+type DatePickerProps = {
+ data: ProductEntityRecord;
+ onChange: ( value: Partial< ProductEntityRecord > ) => void;
+ field: {
+ id?: string;
+ label?: string;
+ };
+ fieldKey: DateFieldKey;
+ min?: Date;
+};
+
+export function parseDateTimeLocal( value: string ) {
+ if ( ! value ) {
+ return null;
+ }
+
+ const parsedDate = new Date( value );
+
+ if ( Number.isNaN( parsedDate.getTime() ) ) {
+ return null;
+ }
+
+ return parsedDate;
+}
+
+export function formatDateTimeLocal( date: Date ) {
+ const year = date.getFullYear();
+ const month = String( date.getMonth() + 1 ).padStart( 2, '0' );
+ const day = String( date.getDate() ).padStart( 2, '0' );
+ const hours = String( date.getHours() ).padStart( 2, '0' );
+ const minutes = String( date.getMinutes() ).padStart( 2, '0' );
+
+ return `${ year }-${ month }-${ day }T${ hours }:${ minutes }`;
+}
+
+export function DatePicker( {
+ data,
+ onChange,
+ field,
+ fieldKey,
+ min,
+}: DatePickerProps ) {
+ return (
+ <InputControl
+ label={ field.label ?? '' }
+ type="datetime-local"
+ value={ data[ fieldKey ] || '' }
+ min={ min ? formatDateTimeLocal( min ) : undefined }
+ onChange={ ( event ) =>
+ onChange( {
+ [ fieldKey ]: event.target.value || null,
+ } as Partial< ProductEntityRecord > )
+ }
+ />
+ );
+}
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/components/dimension.tsx b/packages/js/experimental-products-app/src/variation-view/fields/components/dimension.tsx
new file mode 100644
index 00000000000..23982bbe7a6
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/components/dimension.tsx
@@ -0,0 +1,195 @@
+/**
+ * External dependencies
+ */
+import { useEntityRecord, store as coreStore } from '@wordpress/core-data';
+import { useSelect } from '@wordpress/data';
+import type { Field } from '@wordpress/dataviews';
+import { InputControl, InputLayout } from '@wordpress/ui';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord, SettingsEntityRecord } from '../types';
+
+export type DimensionKey = 'height' | 'width' | 'length';
+
+export function isDimensionVisible( item: ProductEntityRecord ) {
+ const isSellableInstance =
+ ( item.type === 'simple' && ! item.parent_id ) ||
+ ( item.type === 'variable' && ! item.parent_id ) ||
+ item.type === 'variation' ||
+ Boolean( item.parent_id );
+
+ return ! item.virtual && ( isSellableInstance || item.downloadable );
+}
+
+export const createVariationDimensionField = (
+ key: DimensionKey
+): Partial< Field< ProductEntityRecord > > => {
+ return {
+ Edit: ( { data, onChange, field } ) => {
+ const {
+ record: storeProductsSettings,
+ isResolving: storeProductsSettingsResolving,
+ } = useEntityRecord< SettingsEntityRecord >(
+ 'root',
+ 'settings',
+ 'products'
+ );
+
+ const parentProduct = useSelect(
+ ( select ) => {
+ if ( ! data.parent_id ) {
+ return undefined;
+ }
+ return select( coreStore ).getEditedEntityRecord(
+ 'root',
+ 'product',
+ data.parent_id
+ ) as unknown as ProductEntityRecord | undefined;
+ },
+ [ data.parent_id ]
+ );
+
+ if ( storeProductsSettingsResolving ) {
+ return null;
+ }
+
+ const dimensionUnit =
+ storeProductsSettings?.values?.woocommerce_dimension_unit;
+ const parentValue = parentProduct?.dimensions?.[ key ];
+ const placeholder = parentValue ? String( parentValue ) : undefined;
+
+ return (
+ <InputControl
+ label={ field.label }
+ value={ data.dimensions?.[ key ] ?? '' }
+ placeholder={ placeholder }
+ onChange={ ( event ) => {
+ onChange( {
+ dimensions: {
+ ...( data.dimensions ?? {} ),
+ [ key ]: event.target.value,
+ },
+ } );
+ } }
+ type="number"
+ min={ 0 }
+ step="any"
+ suffix={
+ <InputLayout.Slot padding="minimal">
+ { dimensionUnit }
+ </InputLayout.Slot>
+ }
+ />
+ );
+ },
+ };
+};
+
+export const createVariationWeightField = (): Partial<
+ Field< ProductEntityRecord >
+> => {
+ return {
+ Edit: ( { data, onChange, field } ) => {
+ const {
+ record: storeProductsSettings,
+ isResolving: storeProductsSettingsResolving,
+ } = useEntityRecord< SettingsEntityRecord >(
+ 'root',
+ 'settings',
+ 'products'
+ );
+
+ const parentProduct = useSelect(
+ ( select ) => {
+ if ( ! data.parent_id ) {
+ return undefined;
+ }
+ return select( coreStore ).getEditedEntityRecord(
+ 'root',
+ 'product',
+ data.parent_id
+ ) as unknown as ProductEntityRecord | undefined;
+ },
+ [ data.parent_id ]
+ );
+
+ if ( storeProductsSettingsResolving ) {
+ return null;
+ }
+
+ const weightUnit =
+ storeProductsSettings?.values?.woocommerce_weight_unit;
+ const parentValue = parentProduct?.weight;
+ const placeholder = parentValue ? String( parentValue ) : undefined;
+
+ return (
+ <InputControl
+ label={ field.label }
+ value={ data.weight }
+ placeholder={ placeholder }
+ onChange={ ( event ) =>
+ onChange( { weight: event.target.value } )
+ }
+ type="number"
+ min={ 0 }
+ step="any"
+ suffix={
+ <InputLayout.Slot padding="minimal">
+ { weightUnit }
+ </InputLayout.Slot>
+ }
+ />
+ );
+ },
+ };
+};
+
+export const createDimensionField = (
+ key: DimensionKey
+): Partial< Field< ProductEntityRecord > > => {
+ return {
+ isVisible: isDimensionVisible,
+ Edit: ( { data, onChange, field } ) => {
+ const {
+ record: storeProductsSettings,
+ isResolving: storeProductsSettingsResolving,
+ } = useEntityRecord< SettingsEntityRecord >(
+ 'root',
+ 'settings',
+ 'products'
+ );
+
+ if ( storeProductsSettingsResolving ) {
+ return null;
+ }
+
+ const dimensionUnit =
+ storeProductsSettings?.values?.woocommerce_dimension_unit;
+
+ return (
+ <InputControl
+ label={ field.label }
+ value={ data.dimensions?.[ key ] ?? '' }
+ onChange={ ( event ) => {
+ onChange( {
+ dimensions: {
+ ...( data.dimensions ?? {} ),
+ [ key ]: event.target.value,
+ },
+ } );
+ } }
+ type="number"
+ min={ 0 }
+ step="any"
+ suffix={
+ <InputLayout.Slot padding="minimal">
+ { dimensionUnit }
+ </InputLayout.Slot>
+ }
+ />
+ );
+ },
+ };
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/components/list-item/index.tsx b/packages/js/experimental-products-app/src/variation-view/fields/components/list-item/index.tsx
new file mode 100644
index 00000000000..a21ce73ec11
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/components/list-item/index.tsx
@@ -0,0 +1,80 @@
+/**
+ * External dependencies
+ */
+import { Button } from '@wordpress/components';
+import { close } from '@wordpress/icons';
+import { __ } from '@wordpress/i18n';
+import { isValidElement } from '@wordpress/element';
+import { Stack } from '@wordpress/ui';
+
+type Item = {
+ id: number | string;
+ title: string;
+ thumbnail?: string | JSX.Element;
+ meta?: string;
+ altText?: string;
+};
+
+interface ListItemProps {
+ items: Item[];
+ onRemove?: ( item: Item ) => void;
+ showRemoveButton?: boolean;
+}
+
+export const ListItem = ( {
+ items,
+ onRemove,
+ showRemoveButton = true,
+}: ListItemProps ) => {
+ return (
+ <Stack direction="column">
+ { items.map( ( item ) => (
+ <Stack
+ key={ item.id }
+ direction="row"
+ align="center"
+ justify="space-between"
+ className="woocommerce-list-item"
+ gap={ 'md' }
+ >
+ { item.thumbnail && (
+ <div className="woocommerce-list-item__thumbnail">
+ { typeof item.thumbnail === 'string' && (
+ <img
+ src={ item.thumbnail }
+ alt={ item.altText ?? '' }
+ className="woocommerce-list-item__thumbnail-image"
+ />
+ ) }
+ { isValidElement( item.thumbnail ) &&
+ item.thumbnail }
+ </div>
+ ) }
+ <Stack
+ className="woocommerce-list-item__info"
+ direction="column"
+ >
+ <div className="woocommerce-list-item__title">
+ { item.title }
+ </div>
+ { item.meta && (
+ <div className="woocommerce-list-item__meta">
+ { item.meta }
+ </div>
+ ) }
+ </Stack>
+ { showRemoveButton && onRemove && (
+ <Button
+ icon={ close }
+ variant="tertiary"
+ iconSize={ 16 }
+ onClick={ () => onRemove( item ) }
+ aria-label={ __( 'Remove item', 'woocommerce' ) }
+ className="woocommerce-list-item__remove-button"
+ />
+ ) }
+ </Stack>
+ ) ) }
+ </Stack>
+ );
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/components/list-item/style.scss b/packages/js/experimental-products-app/src/variation-view/fields/components/list-item/style.scss
new file mode 100644
index 00000000000..888194ea7fc
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/components/list-item/style.scss
@@ -0,0 +1,46 @@
+.woocommerce-list-item {
+ padding: 12px 0;
+ border-bottom: 1px solid #e0e0e0;
+ width: 100%;
+
+ &:last-of-type {
+ border-bottom: none;
+ }
+
+ &__thumbnail {
+ width: 48px;
+ height: 48px;
+ border-radius: 4px;
+ flex-shrink: 0;
+
+ &-image {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ border-radius: 4px;
+ }
+ }
+
+ &__info {
+ flex: 1;
+ min-width: 0;
+ }
+
+ &__title {
+ font-size: 14px;
+ font-weight: 500;
+ color: #1e1e1e;
+ }
+
+ &__meta {
+ font-size: 12px;
+ color: #757575;
+ font-weight: 500;
+ }
+
+ &__remove-button {
+ color: #757575;
+ padding: 8px;
+ min-width: auto;
+ }
+}
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/components/product-status-badge.tsx b/packages/js/experimental-products-app/src/variation-view/fields/components/product-status-badge.tsx
new file mode 100644
index 00000000000..4eae8aed9c4
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/components/product-status-badge.tsx
@@ -0,0 +1,60 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { Badge } from '@wordpress/ui';
+import type { ProductStatus } from '@woocommerce/data';
+
+type BadgeStatusConfig = {
+ label: string;
+ intent?: React.ComponentProps< typeof Badge >[ 'intent' ];
+};
+
+const statuses = {
+ draft: {
+ label: __( 'Draft', 'woocommerce' ),
+ intent: 'draft',
+ },
+ publish: {
+ label: __( 'Published', 'woocommerce' ),
+ intent: 'stable',
+ },
+ trash: {
+ label: __( 'Trash', 'woocommerce' ),
+ intent: 'none',
+ },
+ 'auto-draft': {
+ label: __( 'Draft', 'woocommerce' ),
+ intent: 'draft',
+ },
+ deleted: {
+ label: __( 'Deleted', 'woocommerce' ),
+ intent: 'none',
+ },
+ pending: {
+ label: __( 'Pending review', 'woocommerce' ),
+ intent: 'informational',
+ },
+ private: {
+ label: __( 'Private', 'woocommerce' ),
+ intent: 'none',
+ },
+ future: {
+ label: __( 'Scheduled', 'woocommerce' ),
+ intent: 'none',
+ },
+ any: {
+ label: __( 'Any', 'woocommerce' ),
+ intent: 'none',
+ },
+} satisfies Record< ProductStatus, BadgeStatusConfig >;
+
+export const ProductStatusBadge = ( { status }: { status: ProductStatus } ) => {
+ const statusData = statuses[ status ];
+
+ if ( ! statusData ) {
+ return <Badge intent="none">{ __( 'Unknown', 'woocommerce' ) }</Badge>;
+ }
+
+ return <Badge intent={ statusData.intent }>{ statusData.label }</Badge>;
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/cost_of_goods_sold/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/cost_of_goods_sold/field.tsx
new file mode 100644
index 00000000000..d698f2daf1d
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/cost_of_goods_sold/field.tsx
@@ -0,0 +1,92 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import type { DataFormControlProps, Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+import { validatePrice, toNumberOrNaN } from '../utils/price';
+import { formatCurrency, getCurrencyObject } from '../utils/currency';
+import { CurrencyControl } from '../components/currency-input';
+
+const fieldDefinition = {
+ type: 'text',
+ label: __( 'Cost of goods', 'woocommerce' ),
+ enableSorting: false,
+ enableHiding: false,
+ filterBy: false,
+ isVisible: ( item: ProductEntityRecord ) =>
+ item.cost_of_goods_sold !== undefined,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+function getDefinedCostValue( item: ProductEntityRecord ) {
+ return item.cost_of_goods_sold?.values?.[ 0 ]?.defined_value;
+}
+
+function CostOfGoodsSoldInput( {
+ data,
+ field,
+ hideLabelFromVision,
+ onChange,
+ validity,
+}: DataFormControlProps< ProductEntityRecord > ) {
+ const costOfGoodsSold = data.cost_of_goods_sold ?? {};
+ const disabled = field.isDisabled( { item: data, field } );
+ const [ firstValue = {}, ...remainingValues ] =
+ costOfGoodsSold.values ?? [];
+
+ return (
+ <CurrencyControl
+ id={ `currency-input-${ field.id }` }
+ label={ field.label }
+ hideLabelFromVision={ hideLabelFromVision }
+ value={ getDefinedCostValue( data ) ?? '' }
+ placeholder={ field.placeholder }
+ onChange={ ( newValue: string ) => {
+ onChange( {
+ cost_of_goods_sold: {
+ ...costOfGoodsSold,
+ values:
+ newValue === ''
+ ? []
+ : [
+ {
+ ...firstValue,
+ defined_value: newValue,
+ },
+ ...remainingValues,
+ ],
+ },
+ } );
+ } }
+ customValidity={ validity?.custom }
+ disabled={ disabled }
+ />
+ );
+}
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ ...fieldDefinition,
+ getValue: ( { item } ) => getDefinedCostValue( item ),
+ getValueFormatted: ( { item } ) => {
+ const value = getDefinedCostValue( item );
+ const numberValue = toNumberOrNaN( value );
+
+ if (
+ value === undefined ||
+ value === null ||
+ Number.isNaN( numberValue )
+ ) {
+ return '—';
+ }
+
+ return formatCurrency( numberValue, getCurrencyObject().code );
+ },
+ isValid: {
+ custom: ( item ) => validatePrice( getDefinedCostValue( item ) ),
+ },
+ Edit: CostOfGoodsSoldInput,
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/date_on_sale_from/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/date_on_sale_from/field.tsx
new file mode 100644
index 00000000000..342f1eaace4
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/date_on_sale_from/field.tsx
@@ -0,0 +1,86 @@
+/**
+ * External dependencies
+ */
+import { useCallback, useMemo } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+import {
+ DatePicker,
+ parseDateTimeLocal,
+ formatDateTimeLocal,
+} from '../components/date-picker';
+
+const fieldDefinition = {
+ type: 'datetime',
+ label: __( 'Start sale on', 'woocommerce' ),
+ enableSorting: false,
+ enableHiding: false,
+ filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ ...fieldDefinition,
+ isVisible: ( product ) => {
+ return !! product.date_on_sale_to || !! product.date_on_sale_from;
+ },
+ Edit: ( { data, onChange, field } ) => {
+ const today = useMemo( () => {
+ const d = new Date();
+ d.setHours( 0, 0, 0, 0 );
+ return d;
+ }, [] );
+
+ const handleChange = useCallback(
+ ( value: { date_on_sale_from?: string | null } ) => {
+ const newStart = value.date_on_sale_from;
+ const currentEnd = data.date_on_sale_to;
+
+ if (
+ typeof newStart !== 'string' ||
+ ! newStart ||
+ typeof currentEnd !== 'string' ||
+ ! currentEnd
+ ) {
+ onChange( value );
+ return;
+ }
+
+ const startDate = parseDateTimeLocal( newStart );
+ const endDate = parseDateTimeLocal( currentEnd );
+
+ if (
+ startDate &&
+ endDate &&
+ startDate.getTime() >= endDate.getTime()
+ ) {
+ const newEndDate = new Date( startDate );
+ newEndDate.setDate( newEndDate.getDate() + 1 );
+
+ onChange( {
+ ...value,
+ date_on_sale_to: formatDateTimeLocal( newEndDate ),
+ } );
+ return;
+ }
+
+ onChange( value );
+ },
+ [ data.date_on_sale_to, onChange ]
+ );
+
+ return (
+ <DatePicker
+ data={ data }
+ onChange={ handleChange }
+ field={ field }
+ fieldKey="date_on_sale_from"
+ min={ today }
+ />
+ );
+ },
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/date_on_sale_to/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/date_on_sale_to/field.tsx
new file mode 100644
index 00000000000..2780ace531a
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/date_on_sale_to/field.tsx
@@ -0,0 +1,58 @@
+/**
+ * External dependencies
+ */
+import { useMemo } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+import { DatePicker, parseDateTimeLocal } from '../components/date-picker';
+
+const fieldDefinition = {
+ type: 'datetime',
+ label: __( 'End sale on', 'woocommerce' ),
+ enableSorting: false,
+ enableHiding: false,
+ filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ ...fieldDefinition,
+ isVisible: ( product ) => {
+ return !! product.date_on_sale_to || !! product.date_on_sale_from;
+ },
+ Edit: ( { data, onChange, field } ) => {
+ const dateOnSaleFrom = useMemo(
+ () =>
+ typeof data?.date_on_sale_from === 'string' &&
+ data.date_on_sale_from
+ ? parseDateTimeLocal( data.date_on_sale_from )
+ : null,
+ [ data?.date_on_sale_from ]
+ );
+
+ const minDate = useMemo( () => {
+ if ( dateOnSaleFrom ) {
+ const min = new Date( dateOnSaleFrom );
+ min.setMinutes( min.getMinutes() + 1 );
+ return min;
+ }
+ const today = new Date();
+ today.setHours( 0, 0, 0, 0 );
+ return today;
+ }, [ dateOnSaleFrom ] );
+
+ return (
+ <DatePicker
+ data={ data }
+ onChange={ onChange }
+ field={ field }
+ fieldKey="date_on_sale_to"
+ min={ minDate }
+ />
+ );
+ },
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/description/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/description/field.tsx
new file mode 100644
index 00000000000..823c647f552
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/description/field.tsx
@@ -0,0 +1,39 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { TextareaControl } from '@wordpress/components';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+import { convertHtmlToPlainText } from '../../../utilites';
+
+const fieldDefinition = {
+ type: 'text',
+ label: __( 'Description', 'woocommerce' ),
+ description: __(
+ 'Share the full story—include product details, features, and benefits to help customers decide.',
+ 'woocommerce'
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ ...fieldDefinition,
+ getValue: ( { item } ) => convertHtmlToPlainText( item.description ),
+ Edit: ( { data, onChange, field } ) => {
+ return (
+ <TextareaControl
+ label={ field.label }
+ rows={ 4 }
+ value={ convertHtmlToPlainText( data.description || '' ) }
+ onChange={ ( value ) => onChange( { description: value } ) }
+ />
+ );
+ },
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/download_expiry/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/download_expiry/field.tsx
new file mode 100644
index 00000000000..83606978633
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/download_expiry/field.tsx
@@ -0,0 +1,67 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { CheckboxControl } from '@wordpress/components';
+import { InputControl, InputLayout } from '@wordpress/ui';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ type: 'integer',
+ label: __( 'Download expiry', 'woocommerce' ),
+ enableSorting: false,
+ filterBy: false,
+ isVisible: ( item ) => !! item.downloadable,
+ getValue: ( { item } ) => item.download_expiry ?? -1,
+ Edit: ( { data, onChange, field } ) => {
+ const hasExpiry = ( data.download_expiry ?? -1 ) !== -1;
+ return (
+ <div
+ className="woocommerce-fields-field__download-expiry"
+ style={ {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '12px',
+ } }
+ >
+ <CheckboxControl
+ __nextHasNoMarginBottom
+ label={ __( 'Expire download link', 'woocommerce' ) }
+ checked={ hasExpiry }
+ onChange={ ( checked ) => {
+ onChange( {
+ download_expiry: checked ? 1 : -1,
+ } );
+ } }
+ />
+ { hasExpiry && (
+ <InputControl
+ label={ field.label }
+ type="number"
+ min={ 1 }
+ value={ String( data.download_expiry ) }
+ onChange={ ( event ) => {
+ const parsed = parseInt( event.target.value, 10 );
+ onChange( {
+ download_expiry:
+ Number.isNaN( parsed ) || parsed < 1
+ ? 1
+ : parsed,
+ } );
+ } }
+ suffix={
+ <InputLayout.Slot padding="minimal">
+ { __( 'days', 'woocommerce' ) }
+ </InputLayout.Slot>
+ }
+ />
+ ) }
+ </div>
+ );
+ },
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/download_limit/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/download_limit/field.tsx
new file mode 100644
index 00000000000..55ed074a82b
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/download_limit/field.tsx
@@ -0,0 +1,66 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { CheckboxControl, TextControl } from '@wordpress/components';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ type: 'integer',
+ label: __( 'Download limit', 'woocommerce' ),
+ enableSorting: false,
+ filterBy: false,
+ isVisible: ( item ) => !! item.downloadable,
+ getValue: ( { item } ) => item.download_limit ?? -1,
+ Edit: ( { data, onChange, field } ) => {
+ const isLimited = ( data.download_limit ?? -1 ) !== -1;
+ return (
+ <div
+ className="woocommerce-fields-field__download-limit"
+ style={ {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '12px',
+ marginBottom: isLimited ? '8px' : 0,
+ } }
+ >
+ <CheckboxControl
+ __nextHasNoMarginBottom
+ label={ __(
+ 'Limit downloads per customer',
+ 'woocommerce'
+ ) }
+ checked={ isLimited }
+ onChange={ ( checked ) => {
+ onChange( {
+ download_limit: checked ? 1 : -1,
+ } );
+ } }
+ />
+ { isLimited && (
+ <TextControl
+ __nextHasNoMarginBottom
+ __next40pxDefaultSize
+ label={ field.label }
+ type="number"
+ min={ 1 }
+ value={ String( data.download_limit ) }
+ onChange={ ( next ) => {
+ const parsed = parseInt( next, 10 );
+ onChange( {
+ download_limit: Number.isNaN( parsed )
+ ? 1
+ : parsed,
+ } );
+ } }
+ />
+ ) }
+ </div>
+ );
+ },
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/downloadable/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/downloadable/field.tsx
new file mode 100644
index 00000000000..c4e5f929340
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/downloadable/field.tsx
@@ -0,0 +1,20 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ type: 'boolean',
+ label: __( 'Downloadable', 'woocommerce' ),
+ enableSorting: false,
+ enableHiding: false,
+ filterBy: false,
+ getValue: ( { item } ) => item.downloadable ?? false,
+ Edit: 'checkbox',
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/downloadable_files/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/downloadable_files/field.tsx
new file mode 100644
index 00000000000..a265b293731
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/downloadable_files/field.tsx
@@ -0,0 +1,207 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { Button, TextControl } from '@wordpress/components';
+import { trash } from '@wordpress/icons';
+import { useCallback, useRef, useState } from '@wordpress/element';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+import type { UploadedItem } from './utils';
+
+declare global {
+ interface Window {
+ wp?: {
+ media?: ( config: {
+ title?: string;
+ button?: { text?: string };
+ multiple?: boolean;
+ } ) => WPMediaFrame;
+ };
+ }
+}
+
+interface WPMediaFrame {
+ on( event: 'select', callback: () => void ): WPMediaFrame;
+ open(): void;
+ state(): {
+ get( key: 'selection' ): {
+ first(): {
+ toJSON(): {
+ url: string;
+ title: string;
+ filename: string;
+ id: number;
+ };
+ };
+ };
+ };
+}
+
+function openMediaLibrary(
+ onSelect: ( url: string, name: string, id?: string ) => void
+) {
+ if ( ! window.wp?.media ) {
+ return;
+ }
+ const frame = window.wp.media( {
+ title: __( 'Choose a file', 'woocommerce' ),
+ button: { text: __( 'Choose file', 'woocommerce' ) },
+ multiple: false,
+ } );
+ frame.on( 'select', () => {
+ const attachment = frame.state().get( 'selection' ).first().toJSON();
+ onSelect(
+ attachment.url,
+ attachment.title || attachment.filename || '',
+ attachment.id ? String( attachment.id ) : undefined
+ );
+ } );
+ frame.open();
+}
+
+function DownloadableFilesEdit( {
+ data,
+ onChange,
+}: {
+ data: ProductEntityRecord;
+ onChange: ( changes: Partial< ProductEntityRecord > ) => void;
+} ) {
+ type KeyedItem = UploadedItem & { _key: number };
+
+ const savedDownloads = ( data.downloads ?? [] ) as UploadedItem[];
+ const keyCounter = useRef( Math.max( savedDownloads.length, 1 ) );
+
+ // State is initialised from saved data once per mount.
+ // The drawer unmounts on close/cancel, so re-opening always starts fresh.
+ const [ downloads, setDownloads ] = useState< KeyedItem[] >(
+ savedDownloads.length > 0
+ ? savedDownloads.map( ( d, i ) => ( { ...d, _key: i } ) )
+ : [ { file: '', name: '', _key: 0 } ]
+ );
+
+ const commit = useCallback(
+ ( next: KeyedItem[] ) => {
+ setDownloads( next );
+ // Only persist entries that have a URL.
+ onChange( {
+ downloads: next
+ .filter( ( d ) => d.file.trim() !== '' )
+ .map( ( { _key: _, ...d } ) => ( {
+ ...d,
+ id: d.id ?? '',
+ } ) ),
+ } );
+ },
+ [ onChange ]
+ );
+
+ const updateEntry = ( index: number, changes: Partial< KeyedItem > ) => {
+ commit(
+ downloads.map( ( d, i ) =>
+ i === index ? { ...d, ...changes } : d
+ )
+ );
+ };
+
+ const removeEntry = ( index: number ) => {
+ commit( downloads.filter( ( _, i ) => i !== index ) );
+ };
+
+ const canDelete = downloads.length > 1;
+
+ return (
+ <div className="woocommerce-fields-downloadable-files">
+ { downloads.map( ( download, index ) => (
+ <div
+ key={ download._key }
+ className="woocommerce-fields-downloadable-files__entry"
+ >
+ { index > 0 && (
+ <hr className="woocommerce-fields-downloadable-files__separator" />
+ ) }
+
+ <div className="woocommerce-fields-downloadable-files__url-row">
+ <TextControl
+ __nextHasNoMarginBottom
+ __next40pxDefaultSize
+ label={ __( 'URL', 'woocommerce' ) }
+ type="url"
+ placeholder="https://"
+ value={ download.file }
+ onChange={ ( val ) =>
+ updateEntry( index, { file: val } )
+ }
+ />
+ <Button
+ className="woocommerce-fields-downloadable-files__choose-button"
+ variant="secondary"
+ __next40pxDefaultSize
+ onClick={ () =>
+ openMediaLibrary( ( url, name, id ) => {
+ updateEntry( index, {
+ file: url,
+ name: download.name || name,
+ ...( id ? { id } : {} ),
+ } );
+ } )
+ }
+ >
+ { __( 'Choose file', 'woocommerce' ) }
+ </Button>
+ </div>
+
+ <div className="woocommerce-fields-downloadable-files__name-row">
+ <TextControl
+ __nextHasNoMarginBottom
+ __next40pxDefaultSize
+ label={ __( 'Name', 'woocommerce' ) }
+ value={ download.name }
+ onChange={ ( val ) =>
+ updateEntry( index, { name: val } )
+ }
+ />
+ { canDelete && (
+ <Button
+ className="woocommerce-fields-downloadable-files__delete-button"
+ variant="secondary"
+ __next40pxDefaultSize
+ icon={ trash }
+ label={ __( 'Remove file', 'woocommerce' ) }
+ onClick={ () => removeEntry( index ) }
+ />
+ ) }
+ </div>
+ </div>
+ ) ) }
+
+ <Button
+ className="woocommerce-fields-downloadable-files__add-button"
+ variant="tertiary"
+ __next40pxDefaultSize
+ onClick={ () =>
+ commit( [
+ ...downloads,
+ { file: '', name: '', _key: keyCounter.current++ },
+ ] )
+ }
+ >
+ { __( '+ Add file', 'woocommerce' ) }
+ </Button>
+ </div>
+ );
+}
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ label: __( 'Downloadable files', 'woocommerce' ),
+ enableSorting: false,
+ enableHiding: false,
+ filterBy: false,
+ isVisible: ( item ) => !! item.downloadable,
+ getValue: ( { item } ) => item.downloads ?? [],
+ Edit: DownloadableFilesEdit,
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/downloadable_files/style.scss b/packages/js/experimental-products-app/src/variation-view/fields/downloadable_files/style.scss
new file mode 100644
index 00000000000..de47013e48e
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/downloadable_files/style.scss
@@ -0,0 +1,56 @@
+.woocommerce-fields-field__download-limit,
+.woocommerce-fields-field__download-expiry {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.woocommerce-fields-downloadable-files {
+ display: flex;
+ flex-direction: column;
+
+ &__entry {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ }
+
+ &__separator {
+ border: none;
+ border-top: 1px solid var(--wpds-color-stroke-surface-neutral-weak, #ddd);
+ margin: 24px 0 8px;
+ }
+
+ &__url-row,
+ &__name-row {
+ display: flex;
+ align-items: flex-end;
+ gap: var(--wpds-dimension-gap-lg, 16px);
+
+ // The TextControl wrapper takes all available space.
+ > *:first-child {
+ flex: 1;
+ min-width: 0;
+ }
+ }
+
+ &__choose-button.components-button {
+ flex-shrink: 0;
+ align-self: flex-end;
+ }
+
+ &__delete-button.components-button {
+ flex-shrink: 0;
+ align-self: flex-end;
+ width: 40px;
+ justify-content: center;
+ // Override the accent color so the secondary button uses black instead of blue.
+ --wp-components-color-accent: var(--wp-components-color-foreground, #1e1e1e);
+ --wp-components-color-accent-darker-10: #000;
+ }
+
+ &__add-button.components-button {
+ align-self: flex-start;
+ margin-top: 8px;
+ }
+}
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/downloadable_files/utils.tsx b/packages/js/experimental-products-app/src/variation-view/fields/downloadable_files/utils.tsx
new file mode 100644
index 00000000000..c278ee5bbfd
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/downloadable_files/utils.tsx
@@ -0,0 +1,51 @@
+/**
+ * External dependencies
+ */
+import { Icon } from '@wordpress/ui';
+import { file as FileIcon } from '@wordpress/icons';
+/**
+ * Internal dependencies
+ */
+import { ProductEntityRecord } from '../types';
+
+export type UploadedItem = Omit<
+ ProductEntityRecord[ 'downloads' ][ 0 ],
+ 'id'
+> & {
+ id?: string;
+};
+
+export type ItemToUpload = UploadedItem & { type: string };
+
+export const getLastPathFromUrl = ( url: string ) => {
+ try {
+ const urlObj = new URL( url );
+ return urlObj.pathname.split( '/' ).pop() ?? url;
+ } catch {
+ const parts = url.split( '/' );
+ return parts[ parts.length - 1 ];
+ }
+};
+
+export const GenericThumbnail = () => (
+ <Icon style={ { width: '100%', height: '100%' } } icon={ FileIcon } />
+);
+
+export const isImageFromLink = ( link: string ) => {
+ const normalizedLink = link.toLowerCase();
+ return [ '.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg' ].some(
+ ( extension ) => normalizedLink.endsWith( extension )
+ );
+};
+
+export const isItemToUpload = (
+ download: UploadedItem | ItemToUpload
+): download is ItemToUpload => {
+ return 'type' in download;
+};
+
+export const isUploadedItem = (
+ download: UploadedItem | ItemToUpload
+): download is UploadedItem => {
+ return ! ( 'type' in download );
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/global_unique_id/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/global_unique_id/field.tsx
new file mode 100644
index 00000000000..1aea8192671
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/global_unique_id/field.tsx
@@ -0,0 +1,31 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+const fieldDefinition = {
+ type: 'text',
+ label: __( 'GTIN, UPC, EAN, ISBN', 'woocommerce' ),
+ enableSorting: false,
+ filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ ...fieldDefinition,
+ getValue: ( { item } ) => item.global_unique_id ?? '',
+ render: ( { item } ) => {
+ const value = item.global_unique_id;
+
+ if ( ! value ) {
+ return <span>{ '—' }</span>;
+ }
+
+ return <span>{ value }</span>;
+ },
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/height/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/height/field.tsx
new file mode 100644
index 00000000000..cefcb7f0090
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/height/field.tsx
@@ -0,0 +1,24 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+import { createVariationDimensionField } from '../components/dimension';
+
+const fieldDefinition = {
+ type: 'text',
+ label: __( 'Height', 'woocommerce' ),
+ enableSorting: false,
+ enableHiding: false,
+ filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ ...fieldDefinition,
+ ...createVariationDimensionField( 'height' ),
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/images/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/images/field.tsx
new file mode 100644
index 00000000000..b2068b9ba46
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/images/field.tsx
@@ -0,0 +1,299 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { useMemo, useCallback, useEffect, useState } from '@wordpress/element';
+import { BaseControl } from '@wordpress/components';
+import { IconButton } from '@wordpress/ui';
+import clsx from 'clsx';
+import type { Field } from '@wordpress/dataviews';
+import { upload, closeSmall, dragHandle } from '@wordpress/icons';
+import { MediaUpload } from '@wordpress/media-utils';
+import { DragDropProvider, type DragEndEvent } from '@dnd-kit/react';
+import { isSortable, useSortable } from '@dnd-kit/react/sortable';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+type Attachment = {
+ id: number;
+ url?: string;
+ alt?: string;
+ title?: string;
+ date?: string;
+ date_gmt?: string;
+ modified?: string;
+ modified_gmt?: string;
+ sizes?: Record< string, { source_url?: string; url?: string } >;
+ media_details?: {
+ sizes?: Record< string, { source_url?: string; url?: string } >;
+ };
+};
+
+const toProductImage = (
+ att: Attachment
+): ProductEntityRecord[ 'images' ][ number ] => {
+ const sizes = att.media_details?.sizes || att.sizes;
+ const thumbnailUrl =
+ sizes?.woocommerce_thumbnail?.source_url ||
+ sizes?.woocommerce_thumbnail?.url ||
+ sizes?.thumbnail?.source_url ||
+ sizes?.thumbnail?.url ||
+ '';
+
+ return {
+ id: att.id,
+ src: att.url || '',
+ alt: att.alt || '',
+ name: att.title || '',
+ thumbnail: thumbnailUrl,
+ date_created: att.date || '',
+ date_created_gmt: att.date_gmt || '',
+ date_modified: att.modified || '',
+ date_modified_gmt: att.modified_gmt || '',
+ };
+};
+
+interface SortableImageProps {
+ image: ProductEntityRecord[ 'images' ][ number ];
+ index: number;
+ alt: string;
+ onRemove: () => void;
+ showDragHandle: boolean;
+}
+
+function SortableImage( {
+ image,
+ index,
+ alt,
+ onRemove,
+ showDragHandle,
+}: SortableImageProps ) {
+ const previewSrc = image.thumbnail || image.src;
+ const { ref, handleRef, isDragging } = useSortable( {
+ id: image.id,
+ index,
+ disabled: ! showDragHandle,
+ } );
+
+ const stopPropagation = useCallback( ( event: React.SyntheticEvent ) => {
+ event.stopPropagation();
+ }, [] );
+
+ return (
+ <div
+ ref={ ref }
+ role="group"
+ aria-label={ image.name }
+ className={ clsx( 'woocommerce-fields-controls__image-wrapper', {
+ 'is-dragging': isDragging,
+ } ) }
+ >
+ <img className="product-image" src={ previewSrc } alt={ alt } />
+ <div className="woocommerce-fields-controls__image-overlay" />
+ { showDragHandle && (
+ <div className="woocommerce-fields-controls__image-drag-handle-container">
+ <IconButton
+ ref={ handleRef }
+ icon={ dragHandle }
+ label={ __( 'Drag to reorder', 'woocommerce' ) }
+ className="woocommerce-fields-controls__image-drag-handle"
+ variant="minimal"
+ size="small"
+ tone="neutral"
+ />
+ </div>
+ ) }
+ { ! isDragging && (
+ <IconButton
+ icon={ closeSmall }
+ label={ __( 'Remove image', 'woocommerce' ) }
+ onClick={ onRemove }
+ onPointerDown={ stopPropagation }
+ onKeyDown={ stopPropagation }
+ className="woocommerce-fields-controls__image-remove-button"
+ variant="minimal"
+ size="small"
+ tone="neutral"
+ />
+ ) }
+ </div>
+ );
+}
+
+const fieldDefinition = {
+ label: __( 'Images', 'woocommerce' ),
+ enableSorting: false,
+ filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ ...fieldDefinition,
+ render: ( { item } ) => {
+ const featuredImage = item.images?.at( 0 );
+
+ if ( ! featuredImage ) {
+ return null;
+ }
+
+ return (
+ <img
+ className="product-image"
+ src={ featuredImage.src }
+ alt={ featuredImage.alt || featuredImage.name || item.name }
+ style={ {
+ objectFit: 'cover',
+ borderRadius: 8,
+ } }
+ />
+ );
+ },
+ Edit: ( { data, onChange, field } ) => {
+ const isVariation = data.type === 'variation';
+ const dataImages = useMemo( () => {
+ const nextImages = data.images ?? [];
+
+ return isVariation ? nextImages.slice( 0, 1 ) : nextImages;
+ }, [ data.images, isVariation ] );
+ const [ images, setImages ] = useState( dataImages );
+ const uploadLabel = isVariation
+ ? __( 'Add image', 'woocommerce' )
+ : __( 'Add images', 'woocommerce' );
+
+ useEffect( () => {
+ setImages( dataImages );
+ }, [ dataImages ] );
+
+ const commitImages = useCallback(
+ ( nextImages: ProductEntityRecord[ 'images' ] ) => {
+ setImages( nextImages );
+ onChange( {
+ images: nextImages,
+ } );
+ },
+ [ onChange ]
+ );
+
+ const handleSelect = useCallback(
+ ( selection: Attachment | Attachment[] ) => {
+ const attachments = Array.isArray( selection )
+ ? selection
+ : [ selection ];
+ const mappedImages = attachments.map( toProductImage );
+
+ commitImages(
+ isVariation ? mappedImages.slice( 0, 1 ) : mappedImages
+ );
+ },
+ [ commitImages, isVariation ]
+ );
+
+ const handleRemoveImage = useCallback(
+ ( imageToRemove: ProductEntityRecord[ 'images' ][ number ] ) => {
+ commitImages(
+ images.filter( ( image ) => image.id !== imageToRemove.id )
+ );
+ },
+ [ commitImages, images ]
+ );
+
+ const handleDragEnd = useCallback(
+ ( event: DragEndEvent ) => {
+ if ( event.canceled ) {
+ return;
+ }
+
+ const { source } = event.operation;
+
+ if ( ! isSortable( source ) ) {
+ return;
+ }
+
+ const { initialIndex, index } = source;
+
+ if (
+ initialIndex === index ||
+ initialIndex < 0 ||
+ index < 0 ||
+ initialIndex >= images.length ||
+ index >= images.length
+ ) {
+ return;
+ }
+
+ const reorderedImages = [ ...images ];
+ const [ movedImage ] = reorderedImages.splice(
+ initialIndex,
+ 1
+ );
+ reorderedImages.splice( index, 0, movedImage );
+
+ commitImages( reorderedImages );
+ },
+ [ commitImages, images ]
+ );
+
+ const removeCallbacks = useMemo( () => {
+ const callbacks = new Map< number | string, () => void >();
+ images.forEach( ( image ) => {
+ callbacks.set( image.id, () => handleRemoveImage( image ) );
+ } );
+ return callbacks;
+ }, [ images, handleRemoveImage ] );
+
+ return (
+ <div className="woocommerce-fields-control__images">
+ <BaseControl.VisualLabel>
+ { field.label }
+ </BaseControl.VisualLabel>
+ <DragDropProvider onDragEnd={ handleDragEnd }>
+ <div className="woocommerce-fields-control__featured-image">
+ <div className="woocommerce-fields-controls__featured-image-uploaded-images">
+ { images.map( ( image, index ) => {
+ const onRemove = removeCallbacks.get(
+ image.id
+ );
+
+ if ( ! onRemove ) {
+ return null;
+ }
+
+ return (
+ <SortableImage
+ key={ image.id }
+ image={ image }
+ index={ index }
+ alt={ image.alt || data.name }
+ onRemove={ onRemove }
+ showDragHandle={
+ ! isVariation && images.length > 1
+ }
+ />
+ );
+ } ) }
+ </div>
+ <div className="woocommerce-fields-control__featured-image-actions">
+ <MediaUpload
+ allowedTypes={ [ 'image' ] }
+ multiple={ isVariation ? false : 'add' }
+ onSelect={ handleSelect }
+ title={ uploadLabel }
+ value={ images.map( ( image ) => image.id ) }
+ render={ ( { open }: { open: () => void } ) => (
+ <IconButton
+ variant="minimal"
+ icon={ upload }
+ label={ uploadLabel }
+ onClick={ open }
+ />
+ ) }
+ />
+ </div>
+ </div>
+ </DragDropProvider>
+ </div>
+ );
+ },
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/length/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/length/field.tsx
new file mode 100644
index 00000000000..31b86967db3
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/length/field.tsx
@@ -0,0 +1,24 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+import { createVariationDimensionField } from '../components/dimension';
+
+const fieldDefinition = {
+ type: 'text',
+ label: __( 'Length', 'woocommerce' ),
+ enableSorting: false,
+ enableHiding: false,
+ filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ ...fieldDefinition,
+ ...createVariationDimensionField( 'length' ),
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/low_stock_amount/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/low_stock_amount/field.tsx
new file mode 100644
index 00000000000..84f848fd612
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/low_stock_amount/field.tsx
@@ -0,0 +1,44 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { TextControl } from '@wordpress/components';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ type: 'integer',
+ label: __( 'Low stock threshold', 'woocommerce' ),
+ enableSorting: false,
+ filterBy: false,
+ isVisible: ( item ) => !! item.manage_stock,
+ getValue: ( { item } ) => item.low_stock_amount ?? '',
+ Edit: ( { data, onChange, field } ) => {
+ const value = data.low_stock_amount;
+ return (
+ <TextControl
+ __nextHasNoMarginBottom
+ label={ field.label }
+ type="number"
+ min={ 0 }
+ value={
+ value === null || value === undefined ? '' : String( value )
+ }
+ placeholder={ __( 'Store wide threshold', 'woocommerce' ) }
+ onChange={ ( next ) => {
+ const parsed =
+ next === '' ? undefined : parseInt( next, 10 );
+ onChange( {
+ low_stock_amount: Number.isNaN( parsed )
+ ? undefined
+ : parsed,
+ } );
+ } }
+ />
+ );
+ },
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/manage_stock/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/manage_stock/field.tsx
new file mode 100644
index 00000000000..d4766052054
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/manage_stock/field.tsx
@@ -0,0 +1,24 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+const fieldDefinition = {
+ type: 'boolean',
+ label: __( 'Track inventory', 'woocommerce' ),
+ enableSorting: false,
+ enableHiding: false,
+ filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ ...fieldDefinition,
+ type: 'boolean',
+ Edit: 'checkbox',
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/product_status/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/product_status/field.tsx
new file mode 100644
index 00000000000..e449a717379
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/product_status/field.tsx
@@ -0,0 +1,70 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { SelectControl } from '@wordpress/ui';
+
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+import { ProductStatusBadge } from '../components/product-status-badge';
+
+function isValidStatus( value: string ) {
+ return (
+ value === 'draft' ||
+ value === 'pending' ||
+ value === 'publish' ||
+ value === 'trash'
+ );
+}
+
+const fieldDefinition = {
+ type: 'text',
+ label: __( 'Status', 'woocommerce' ),
+ enableSorting: false,
+ filterBy: false,
+ elements: [
+ { value: 'publish', label: __( 'Published', 'woocommerce' ) },
+ { value: 'draft', label: __( 'Draft', 'woocommerce' ) },
+ { value: 'pending', label: __( 'Pending review', 'woocommerce' ) },
+ { value: 'trash', label: __( 'Trash', 'woocommerce' ) },
+ ],
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ ...fieldDefinition,
+ getValue: ( { item } ) => item.status,
+ render: ( { item }: { item: ProductEntityRecord } ) => (
+ <ProductStatusBadge status={ item.status } />
+ ),
+ Edit: ( { data, onChange, field } ) => {
+ const options =
+ field.elements?.filter(
+ ( element: { label: string; value: string } ) =>
+ element.value !== 'trash'
+ ) ?? [];
+ const selectedOption = options.find(
+ ( option ) => option.value === data.status
+ );
+
+ return (
+ <SelectControl
+ label={ field.label }
+ value={ selectedOption }
+ items={ options }
+ onValueChange={ ( option ) => {
+ const value = option?.value;
+
+ if ( typeof value === 'string' && isValidStatus( value ) ) {
+ onChange( {
+ status: value,
+ } );
+ }
+ } }
+ />
+ );
+ },
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/registry.tsx b/packages/js/experimental-products-app/src/variation-view/fields/registry.tsx
new file mode 100644
index 00000000000..eed3a7f9a4f
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/registry.tsx
@@ -0,0 +1,126 @@
+/**
+ * External dependencies
+ */
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from './types';
+import { fieldExtensions as allowBackordersFieldExtensions } from './allow_backorders/field';
+import { fieldExtensions as attributesFieldExtensions } from './attributes/field';
+import { fieldExtensions as costOfGoodsSoldFieldExtensions } from './cost_of_goods_sold/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 downloadableFilesFieldExtensions } from './downloadable_files/field';
+import { fieldExtensions as downloadExpiryFieldExtensions } from './download_expiry/field';
+import { fieldExtensions as downloadLimitFieldExtensions } from './download_limit/field';
+import { fieldExtensions as globalUniqueIdFieldExtensions } from './global_unique_id/field';
+import { fieldExtensions as heightFieldExtensions } from './height/field';
+import { fieldExtensions as imagesFieldExtensions } from './images/field';
+import { fieldExtensions as lengthFieldExtensions } from './length/field';
+import { fieldExtensions as lowStockAmountFieldExtensions } from './low_stock_amount/field';
+import { fieldExtensions as manageStockFieldExtensions } from './manage_stock/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 skuFieldExtensions } from './sku/field';
+import { fieldExtensions as stockFieldExtensions } from './stock/field';
+import { fieldExtensions as stockQuantityFieldExtensions } from './stock_quantity/field';
+import { fieldExtensions as taxClassFieldExtensions } from './tax_class/field';
+import { fieldExtensions as taxStatusFieldExtensions } from './tax_status/field';
+import { fieldExtensions as variationActiveFieldExtensions } from './variation_active/field';
+import { fieldExtensions as virtualFieldExtensions } from './virtual/field';
+import { fieldExtensions as weightFieldExtensions } from './weight/field';
+import { fieldExtensions as widthFieldExtensions } from './width/field';
+
+type VariationEditField = Field< ProductEntityRecord >;
+
+// TODO: Move all product field definitions into a common fields folder once
+// the product list/edit pages and variation edit drawer standardize their
+// UI/UX. The current page-specific designs require separate field
+// implementations even when the underlying product data is the same.
+
+function withVirtualGuard( field: VariationEditField ): VariationEditField {
+ const existing = field.isVisible;
+ return {
+ ...field,
+ isVisible: ( item: ProductEntityRecord ) =>
+ ! item.virtual && ( existing === undefined || existing( item ) ),
+ };
+}
+
+const SHARED_FIELDS: Array< {
+ id: string;
+ extensions: Partial< VariationEditField >;
+} > = [
+ { id: 'description', extensions: descriptionFieldExtensions },
+ { id: 'sku', extensions: skuFieldExtensions },
+ { id: 'global_unique_id', extensions: globalUniqueIdFieldExtensions },
+ { id: 'regular_price', extensions: regularPriceFieldExtensions },
+ { id: 'sale_price', extensions: salePriceFieldExtensions },
+ { id: 'schedule_sale', extensions: scheduleSaleFieldExtensions },
+ { id: 'date_on_sale_from', extensions: dateOnSaleFromFieldExtensions },
+ { id: 'date_on_sale_to', extensions: dateOnSaleToFieldExtensions },
+ { id: 'cost_of_goods_sold', extensions: costOfGoodsSoldFieldExtensions },
+ { id: 'images', extensions: imagesFieldExtensions },
+ { id: 'manage_stock', extensions: manageStockFieldExtensions },
+ { id: 'stock', extensions: stockFieldExtensions },
+ { id: 'stock_quantity', extensions: stockQuantityFieldExtensions },
+ { id: 'tax_status', extensions: taxStatusFieldExtensions },
+ { id: 'variation_active', extensions: variationActiveFieldExtensions },
+];
+
+const SHIPPING_FIELDS: Array< {
+ id: string;
+ extensions: Partial< VariationEditField >;
+} > = [
+ { id: 'shipping_class', extensions: shippingClassFieldExtensions },
+ { id: 'weight', extensions: weightFieldExtensions },
+ { id: 'length', extensions: lengthFieldExtensions },
+ { id: 'width', extensions: widthFieldExtensions },
+ { id: 'height', extensions: heightFieldExtensions },
+];
+
+const VARIATION_ONLY_FIELDS: Array< {
+ id: string;
+ extensions: Partial< VariationEditField >;
+} > = [
+ { id: 'downloadable', extensions: downloadableFieldExtensions },
+ { id: 'tax_class', extensions: taxClassFieldExtensions },
+ { id: 'virtual', extensions: virtualFieldExtensions },
+ { id: 'allow_backorders', extensions: allowBackordersFieldExtensions },
+ { id: 'low_stock_amount', extensions: lowStockAmountFieldExtensions },
+ { id: 'downloadable_files', extensions: downloadableFilesFieldExtensions },
+ { id: 'download_limit', extensions: downloadLimitFieldExtensions },
+ { id: 'download_expiry', extensions: downloadExpiryFieldExtensions },
+ { id: 'attributes', extensions: attributesFieldExtensions },
+];
+
+const sharedFields: VariationEditField[] = SHARED_FIELDS.map(
+ ( { id, extensions } ) => ( { id, ...extensions } as VariationEditField )
+);
+
+const shippingFields: VariationEditField[] = SHIPPING_FIELDS.map(
+ ( { id, extensions } ) =>
+ withVirtualGuard( { id, ...extensions } as VariationEditField )
+);
+
+const variationOnlyFields: VariationEditField[] = VARIATION_ONLY_FIELDS.map(
+ ( { id, extensions } ) => ( { id, ...extensions } as VariationEditField )
+);
+
+// The full field list used by the variation edit drawer.
+export const variationEditFields: VariationEditField[] = [
+ ...sharedFields,
+ ...shippingFields,
+ ...variationOnlyFields,
+];
+
+export type VariationEditFieldId =
+ | ( typeof SHARED_FIELDS )[ number ][ 'id' ]
+ | ( typeof SHIPPING_FIELDS )[ number ][ 'id' ]
+ | ( typeof VARIATION_ONLY_FIELDS )[ number ][ 'id' ];
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/regular_price/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/regular_price/field.tsx
new file mode 100644
index 00000000000..a9a250229f3
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/regular_price/field.tsx
@@ -0,0 +1,28 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+import { CurrencyInput } from '../components/currency-input';
+import { validatePrice } from '../utils/price';
+
+const fieldDefinition = {
+ type: 'text',
+ label: __( 'Regular Price', 'woocommerce' ),
+ enableSorting: false,
+ enableHiding: false,
+ filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ ...fieldDefinition,
+ isValid: {
+ custom: ( item ) => validatePrice( item.regular_price ),
+ },
+ Edit: CurrencyInput,
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/sale_price/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/sale_price/field.tsx
new file mode 100644
index 00000000000..2c360ecefe2
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/sale_price/field.tsx
@@ -0,0 +1,28 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+import { CurrencyInput } from '../components/currency-input';
+import { validateSalePrice } from './validation';
+
+const fieldDefinition = {
+ type: 'text',
+ label: __( 'Sale Price', 'woocommerce' ),
+ enableSorting: false,
+ enableHiding: false,
+ filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ ...fieldDefinition,
+ isValid: {
+ custom: ( item ) => validateSalePrice( item ),
+ },
+ Edit: CurrencyInput,
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/sale_price/validation.ts b/packages/js/experimental-products-app/src/variation-view/fields/sale_price/validation.ts
new file mode 100644
index 00000000000..7460f62bf80
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/sale_price/validation.ts
@@ -0,0 +1,33 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+import { toNumberOrNaN, validatePrice } from '../utils/price';
+
+export function validateSalePrice( item: ProductEntityRecord ): string | null {
+ const numericError = validatePrice( item.sale_price );
+ if ( numericError ) {
+ return numericError;
+ }
+
+ const regularPrice = toNumberOrNaN( item.regular_price );
+
+ if ( Number.isNaN( regularPrice ) ) {
+ return null;
+ }
+
+ const salePrice = toNumberOrNaN( item.sale_price );
+ if ( salePrice >= regularPrice ) {
+ return __(
+ 'Sale price must be lower than the regular price.',
+ 'woocommerce'
+ );
+ }
+
+ return null;
+}
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/schedule_sale/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/schedule_sale/field.tsx
new file mode 100644
index 00000000000..f033b1ac37d
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/schedule_sale/field.tsx
@@ -0,0 +1,186 @@
+/**
+ * External dependencies
+ */
+import { CheckboxControl } from '@wordpress/components';
+import { useCallback, useMemo, useState } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+import { getLocalDefaultSaleStart } from '../utils/price';
+import {
+ DatePicker,
+ formatDateTimeLocal,
+ parseDateTimeLocal,
+} from '../components/date-picker';
+
+const fieldDefinition = {
+ type: 'boolean',
+ label: __( 'Schedule sale', 'woocommerce' ),
+ enableSorting: false,
+ enableHiding: false,
+ filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ ...fieldDefinition,
+ Edit: ( { data, onChange, field } ) => {
+ const [ tempDateOnSaleFrom, setTempDateOnSaleFrom ] = useState(
+ data.date_on_sale_from || ''
+ );
+ const [ tempDateOnSaleTo, setTempDateOnSaleTo ] = useState(
+ data.date_on_sale_to || ''
+ );
+ const checked = !! data.date_on_sale_to || !! data.date_on_sale_from;
+ const today = useMemo( () => {
+ const d = new Date();
+ d.setHours( 0, 0, 0, 0 );
+ return d;
+ }, [] );
+ const dateOnSaleFrom = useMemo(
+ () =>
+ typeof data.date_on_sale_from === 'string' &&
+ data.date_on_sale_from
+ ? parseDateTimeLocal( data.date_on_sale_from )
+ : null,
+ [ data.date_on_sale_from ]
+ );
+ const minDateOnSaleTo = useMemo( () => {
+ if ( dateOnSaleFrom ) {
+ const min = new Date( dateOnSaleFrom );
+ min.setMinutes( min.getMinutes() + 1 );
+ return min;
+ }
+
+ return today;
+ }, [ dateOnSaleFrom, today ] );
+ const handleScheduleChange = useCallback(
+ ( value: boolean ) => {
+ if ( ! value ) {
+ setTempDateOnSaleFrom( data.date_on_sale_from || '' );
+ setTempDateOnSaleTo( data.date_on_sale_to || '' );
+ onChange( {
+ date_on_sale_from: '',
+ date_on_sale_to: '',
+ } );
+ return;
+ }
+
+ let nextDateOnSaleFrom =
+ data.date_on_sale_from || tempDateOnSaleFrom;
+ const nextDateOnSaleTo =
+ data.date_on_sale_to || tempDateOnSaleTo;
+
+ if ( ! nextDateOnSaleFrom && ! nextDateOnSaleTo ) {
+ nextDateOnSaleFrom = getLocalDefaultSaleStart();
+ }
+
+ onChange( {
+ date_on_sale_from: nextDateOnSaleFrom,
+ date_on_sale_to: nextDateOnSaleTo,
+ } );
+ },
+ [
+ data.date_on_sale_from,
+ data.date_on_sale_to,
+ onChange,
+ tempDateOnSaleFrom,
+ tempDateOnSaleTo,
+ ]
+ );
+ const handleDateOnSaleFromChange = useCallback(
+ ( value: { date_on_sale_from?: string | null } ) => {
+ const newStart = value.date_on_sale_from;
+ const currentEnd = data.date_on_sale_to;
+
+ if (
+ typeof newStart !== 'string' ||
+ ! newStart ||
+ typeof currentEnd !== 'string' ||
+ ! currentEnd
+ ) {
+ onChange( value );
+ return;
+ }
+
+ const startDate = parseDateTimeLocal( newStart );
+ const endDate = parseDateTimeLocal( currentEnd );
+
+ if (
+ startDate &&
+ endDate &&
+ startDate.getTime() >= endDate.getTime()
+ ) {
+ const newEndDate = new Date( startDate );
+ newEndDate.setDate( newEndDate.getDate() + 1 );
+
+ onChange( {
+ ...value,
+ date_on_sale_to: formatDateTimeLocal( newEndDate ),
+ } );
+ return;
+ }
+
+ onChange( value );
+ },
+ [ data.date_on_sale_to, onChange ]
+ );
+
+ return (
+ <div className="woocommerce-schedule-sale-control">
+ <CheckboxControl
+ label={ field.label }
+ checked={ checked }
+ onChange={ handleScheduleChange }
+ />
+ { checked && (
+ <div className="woocommerce-schedule-sale-control__dates">
+ <DatePicker
+ data={ data }
+ onChange={ handleDateOnSaleFromChange }
+ field={ {
+ label: __( 'Start sale on', 'woocommerce' ),
+ } }
+ fieldKey="date_on_sale_from"
+ min={ today }
+ />
+ <DatePicker
+ data={ data }
+ onChange={ onChange }
+ field={ {
+ label: __( 'End sale on', 'woocommerce' ),
+ } }
+ fieldKey="date_on_sale_to"
+ min={ minDateOnSaleTo }
+ />
+ </div>
+ ) }
+ </div>
+ );
+ },
+ getValue: ( { item } ) =>
+ !! item.date_on_sale_to || !! item.date_on_sale_from,
+ setValue: ( { item, value } ) => {
+ if ( ! value ) {
+ return {
+ date_on_sale_from: '',
+ date_on_sale_to: '',
+ };
+ }
+
+ let dateOnSaleFrom = item.date_on_sale_from || '';
+ const dateOnSaleTo = item.date_on_sale_to || '';
+
+ if ( ! dateOnSaleFrom && ! dateOnSaleTo ) {
+ dateOnSaleFrom = getLocalDefaultSaleStart();
+ }
+
+ return {
+ date_on_sale_from: dateOnSaleFrom,
+ date_on_sale_to: dateOnSaleTo,
+ };
+ },
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/shipping_class/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/shipping_class/field.tsx
new file mode 100644
index 00000000000..235a1f6c742
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/shipping_class/field.tsx
@@ -0,0 +1,75 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { useSelect } from '@wordpress/data';
+import { SelectControl } from '@wordpress/ui';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+interface ProductShippingClass {
+ id: number;
+ slug: string;
+ name: string;
+ description: string;
+ count: number;
+}
+
+// Radix UI (used by @wordpress/ui SelectControl) rejects empty-string values.
+// We use 'parent' as a UI sentinel for the '' API value ("Same as parent").
+const SAME_AS_PARENT = 'parent';
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ Edit: ( { data, onChange, field } ) => {
+ const { shippingClasses } = useSelect( ( select ) => {
+ // eslint-disable-next-line @wordpress/data-no-store-string-literals
+ const store = select(
+ 'experimental/wc/admin/products/shipping-classes'
+ );
+ return {
+ shippingClasses:
+ // @ts-expect-error - The store return type lives in Woo core.
+ ( store?.getProductShippingClasses?.() ??
+ [] ) as ProductShippingClass[],
+ };
+ }, [] );
+
+ const options = [
+ {
+ label: __( 'Same as parent', 'woocommerce' ),
+ value: SAME_AS_PARENT,
+ },
+ ...( shippingClasses?.length
+ ? shippingClasses.map( ( shippingClass ) => ( {
+ label: shippingClass.name,
+ value: shippingClass.slug,
+ } ) )
+ : [] ),
+ ];
+
+ const apiValue = data.shipping_class ?? '';
+ const uiValue = apiValue === '' ? SAME_AS_PARENT : apiValue;
+ const selectedOption = options.find( ( o ) => o.value === uiValue );
+
+ return (
+ <SelectControl
+ label={ field.label }
+ value={ selectedOption }
+ items={ options }
+ onValueChange={ ( option ) => {
+ if ( option !== null && option !== undefined ) {
+ const apiVal =
+ option.value === SAME_AS_PARENT
+ ? ''
+ : option.value ?? '';
+ onChange( { shipping_class: apiVal } );
+ }
+ } }
+ />
+ );
+ },
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/sku/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/sku/field.tsx
new file mode 100644
index 00000000000..3f6169cde9c
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/sku/field.tsx
@@ -0,0 +1,30 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+const fieldDefinition = {
+ type: 'text',
+ label: __( 'SKU', 'woocommerce' ),
+ enableSorting: false,
+ filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ ...fieldDefinition,
+ render: ( { item } ) => {
+ const sku = item.sku;
+
+ if ( sku === undefined || sku === null || sku === '' ) {
+ return <span>{ '—' }</span>;
+ }
+
+ return <span>{ sku }</span>;
+ },
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/stock/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/stock/field.tsx
new file mode 100644
index 00000000000..8d180793348
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/stock/field.tsx
@@ -0,0 +1,102 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { Badge, SelectControl } from '@wordpress/ui';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+type StockStatus = 'instock' | 'outofstock' | 'onbackorder';
+
+function isValidStockStatus( value: string ): value is StockStatus {
+ return (
+ value === 'instock' || value === 'outofstock' || value === 'onbackorder'
+ );
+}
+
+const stockStatusBadgeIntent: Record<
+ StockStatus,
+ React.ComponentProps< typeof Badge >[ 'intent' ]
+> = {
+ instock: 'none',
+ outofstock: 'high',
+ onbackorder: 'draft',
+};
+
+const fieldDefinition = {
+ label: __( 'Stock', 'woocommerce' ),
+ enableSorting: false,
+ enableHiding: false,
+ filterBy: {
+ operators: [ 'isAny' ],
+ },
+ elements: [
+ { label: __( 'In stock', 'woocommerce' ), value: 'instock' },
+ { label: __( 'Out of stock', 'woocommerce' ), value: 'outofstock' },
+ { label: __( 'On backorder', 'woocommerce' ), value: 'onbackorder' },
+ ],
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ ...fieldDefinition,
+ isVisible: ( item ) => {
+ return ! item.manage_stock;
+ },
+ getValue: ( { item } ) => item.stock_status,
+ render: ( { item, field } ) => {
+ const match = field?.elements?.find(
+ ( status ) => status.value === item.stock_status
+ );
+
+ if ( ! match || ! isValidStockStatus( match.value ) ) {
+ return item.stock_status;
+ }
+
+ const stockLabel =
+ item.stock_quantity && item.stock_quantity > 0
+ ? `${ match.label } (${ item.stock_quantity })`
+ : match.label;
+
+ return (
+ <div className="woocommerce-fields-field__stock">
+ <Badge intent={ stockStatusBadgeIntent[ match.value ] }>
+ { stockLabel }
+ </Badge>
+ </div>
+ );
+ },
+ Edit: ( { data, onChange, field } ) => {
+ const options = field?.elements ?? [];
+ const selectedOption =
+ field.placeholder && ! data.stock_status
+ ? undefined
+ : options.find(
+ ( option ) => option.value === data.stock_status
+ );
+
+ return (
+ <SelectControl
+ label={ __( 'Stock status', 'woocommerce' ) }
+ placeholder={ field.placeholder }
+ value={ selectedOption }
+ items={ options }
+ onValueChange={ ( option ) => {
+ const value = option?.value;
+
+ if (
+ typeof value === 'string' &&
+ isValidStockStatus( value )
+ ) {
+ onChange( {
+ stock_status: value,
+ } );
+ }
+ } }
+ />
+ );
+ },
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/stock_quantity/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/stock_quantity/field.tsx
new file mode 100644
index 00000000000..a96cb84cbf8
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/stock_quantity/field.tsx
@@ -0,0 +1,129 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { InputControl, Stack } from '@wordpress/ui';
+import type { DataFormControlProps, Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+type StockQuantityRange = [ number | string, number | string ];
+type StockQuantityFilterRecord = Omit<
+ ProductEntityRecord,
+ 'stock_quantity'
+> & {
+ stock_quantity?: number | string | null | StockQuantityRange;
+};
+
+const castValueToString = (
+ value: number | string | null | StockQuantityRange | undefined
+): string => {
+ if ( typeof value === 'number' ) {
+ return String( value );
+ } else if ( typeof value === 'string' ) {
+ return value;
+ }
+ return '';
+};
+
+const fieldDefinition = {
+ type: 'integer',
+ label: __( 'Stock quantity', 'woocommerce' ),
+ enableSorting: false,
+ enableHiding: false,
+ filterBy: {
+ operators: [
+ 'is',
+ 'greaterThan',
+ 'greaterThanOrEqual',
+ 'lessThan',
+ 'lessThanOrEqual',
+ 'between',
+ ],
+ },
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ ...fieldDefinition,
+ isVisible: ( item ) => {
+ return !! item.manage_stock;
+ },
+ Edit: ( {
+ data,
+ onChange,
+ hideLabelFromVision,
+ operator,
+ field,
+ }: DataFormControlProps< ProductEntityRecord > ) => {
+ const onChangeBetween = onChange as (
+ data: Partial< StockQuantityFilterRecord >
+ ) => void;
+ const onChangeStockQuantity = onChange as (
+ data: Partial< StockQuantityFilterRecord >
+ ) => void;
+ const raw = ( data as StockQuantityFilterRecord ).stock_quantity;
+ const disabled = field.isDisabled( { item: data, field } );
+
+ if ( operator === 'between' ) {
+ const [ minRaw = '', maxRaw = '' ] = Array.isArray( raw )
+ ? raw
+ : [];
+ const min = String( minRaw );
+ const max = String( maxRaw );
+
+ return (
+ <Stack direction="row">
+ <InputControl
+ label={ __( 'From', 'woocommerce' ) }
+ type="number"
+ step={ 1 }
+ value={ min }
+ onChange={ ( event ) => {
+ const next = event.target.value;
+ const nextMin = next === '' ? '' : Number( next );
+ onChangeBetween( {
+ stock_quantity: [ nextMin, max ],
+ } );
+ } }
+ />
+ <InputControl
+ label={ __( 'To', 'woocommerce' ) }
+ type="number"
+ step={ 1 }
+ value={ max }
+ onChange={ ( event ) => {
+ const next = event.target.value;
+ const nextMax = next === '' ? '' : Number( next );
+ onChangeBetween( {
+ stock_quantity: [ min, nextMax ],
+ } );
+ } }
+ />
+ </Stack>
+ );
+ }
+
+ const value = castValueToString( raw );
+
+ return (
+ <InputControl
+ label={ field.label }
+ hideLabelFromVision={ hideLabelFromVision }
+ type="number"
+ step={ 1 }
+ value={ value }
+ placeholder={ field.placeholder }
+ disabled={ disabled }
+ onChange={ ( event ) => {
+ const next = event.target.value;
+ const stockQuantity: StockQuantityFilterRecord[ 'stock_quantity' ] =
+ next === '' ? null : Number( next );
+ onChangeStockQuantity( { stock_quantity: stockQuantity } );
+ } }
+ />
+ );
+ },
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/tax_class/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/tax_class/field.tsx
new file mode 100644
index 00000000000..ec4f7536bc6
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/tax_class/field.tsx
@@ -0,0 +1,59 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { SelectControl } from '@wordpress/ui';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+// Radix UI (used by @wordpress/ui SelectControl) rejects empty-string values.
+// We use 'parent' as a UI sentinel for the '' API value ("Same as parent").
+const SAME_AS_PARENT = 'parent';
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ type: 'text',
+ label: __( 'Tax class', 'woocommerce' ),
+ enableSorting: false,
+ filterBy: false,
+ getValue: ( { item } ) => item.tax_class ?? '',
+ Edit: ( { data, onChange, field } ) => {
+ const options = [
+ {
+ value: SAME_AS_PARENT,
+ label: __( 'Same as parent', 'woocommerce' ),
+ },
+ { value: 'standard', label: __( 'Standard', 'woocommerce' ) },
+ {
+ value: 'reduced-rate',
+ label: __( 'Reduced rate', 'woocommerce' ),
+ },
+ { value: 'zero-rate', label: __( 'Zero rate', 'woocommerce' ) },
+ ];
+
+ const apiValue = data.tax_class ?? '';
+ const uiValue = apiValue === '' ? SAME_AS_PARENT : apiValue;
+ const selected = options.find( ( o ) => o.value === uiValue );
+
+ return (
+ <SelectControl
+ label={ field.label }
+ value={ selected }
+ items={ options }
+ onValueChange={ ( option ) => {
+ if ( option !== null && option !== undefined ) {
+ const apiVal =
+ option.value === SAME_AS_PARENT ? '' : option.value;
+ onChange( {
+ tax_class:
+ apiVal as ProductEntityRecord[ 'tax_class' ],
+ } );
+ }
+ } }
+ />
+ );
+ },
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/tax_status/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/tax_status/field.tsx
new file mode 100644
index 00000000000..55722c3aa30
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/tax_status/field.tsx
@@ -0,0 +1,37 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { CheckboxControl } from '@wordpress/components';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+const fieldDefinition = {
+ label: __( 'Tax Status', 'woocommerce' ),
+ enableSorting: false,
+ enableHiding: false,
+ filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ ...fieldDefinition,
+ enableSorting: false,
+ Edit: ( { data, onChange, field } ) => {
+ return (
+ <CheckboxControl
+ label={ field.label }
+ checked={ data.tax_status === 'taxable' }
+ onChange={ () =>
+ onChange( {
+ tax_status:
+ data.tax_status === 'taxable' ? 'none' : 'taxable',
+ } )
+ }
+ />
+ );
+ },
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/types.ts b/packages/js/experimental-products-app/src/variation-view/fields/types.ts
new file mode 100644
index 00000000000..2356e484226
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/types.ts
@@ -0,0 +1,67 @@
+/**
+ * External dependencies
+ */
+import type {
+ Product,
+ ProductStatus as WooProductStatus,
+} from '@woocommerce/data';
+
+export type ProductEntityRecord = Omit< Product, 'categories' | 'tags' > & {
+ cost_of_goods_sold?: {
+ values?: Array< {
+ defined_value?: number | string | null;
+ effective_value?: number | string | null;
+ } >;
+ defined_value_is_additive?: boolean;
+ total_value?: number | string | null;
+ };
+ categories: Array< {
+ id: number;
+ name?: string;
+ image?: {
+ src?: string;
+ alt?: string;
+ };
+ } >;
+ tags: Array< {
+ id: number;
+ name?: string;
+ } >;
+ brands?: Array< {
+ id: number;
+ name?: string;
+ slug?: string;
+ } >;
+ global_unique_id?: string;
+ cross_sell_ids?: number[];
+ upsell_ids?: number[];
+ date_on_sale_from?: string | null;
+ date_on_sale_to?: string | null;
+ parent_id?: number;
+ _embedded?: {
+ variations?: ProductEntityRecord[];
+ };
+ seo_title?: string;
+ seo_description?: string;
+ visible_in_pos?: boolean;
+ images: Array< {
+ alt: string;
+ date_created: string;
+ date_created_gmt: string;
+ date_modified: string;
+ date_modified_gmt: string;
+ id: number;
+ name: string;
+ src: string;
+ thumbnail: string;
+ } >;
+};
+
+export type ProductStatus = WooProductStatus;
+
+export type SettingsEntityRecord = {
+ values?: {
+ woocommerce_dimension_unit?: string;
+ woocommerce_weight_unit?: string;
+ };
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/utils/currency.test.ts b/packages/js/experimental-products-app/src/variation-view/fields/utils/currency.test.ts
new file mode 100644
index 00000000000..db5428fde12
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/utils/currency.test.ts
@@ -0,0 +1,90 @@
+/**
+ * Internal dependencies
+ */
+import { getCurrencyObject, formatCurrency } from './currency';
+
+jest.mock( '@woocommerce/settings', () => ( {
+ CURRENCY: {
+ code: 'USD',
+ symbol: '$',
+ symbolPosition: 'left',
+ precision: '2',
+ decimalSeparator: '.',
+ thousandSeparator: ',',
+ },
+} ) );
+
+const getCurrencyMock = () =>
+ jest.requireMock( '@woocommerce/settings' ).CURRENCY;
+
+describe( 'getCurrencyObject', () => {
+ afterEach( () => {
+ const mock = getCurrencyMock();
+ mock.code = 'USD';
+ mock.symbol = '$';
+ mock.symbolPosition = 'left';
+ mock.precision = '2';
+ } );
+
+ it( 'returns currency settings from CURRENCY global', () => {
+ const result = getCurrencyObject();
+ expect( result.code ).toBe( 'USD' );
+ expect( result.symbol ).toBe( '$' );
+ expect( result.precision ).toBe( 2 );
+ } );
+
+ it( 'falls back to USD when code is empty', () => {
+ getCurrencyMock().code = '';
+ expect( getCurrencyObject().code ).toBe( 'USD' );
+ } );
+
+ it( 'falls back to $ when symbol is empty', () => {
+ getCurrencyMock().symbol = '';
+ expect( getCurrencyObject().symbol ).toBe( '$' );
+ } );
+
+ it( 'falls back to precision 2 when precision is invalid', () => {
+ getCurrencyMock().precision = 'invalid';
+ expect( getCurrencyObject().precision ).toBe( 2 );
+ } );
+
+ it( 'falls back to precision 2 when precision is negative', () => {
+ getCurrencyMock().precision = '-1';
+ expect( getCurrencyObject().precision ).toBe( 2 );
+ } );
+} );
+
+describe( 'formatCurrency', () => {
+ it( 'formats a number as currency', () => {
+ const result = formatCurrency( 9.99 );
+ expect( result ).toContain( '9.99' );
+ } );
+
+ it( 'formats a string number as currency', () => {
+ const result = formatCurrency( '19.99' );
+ expect( result ).toContain( '19.99' );
+ } );
+
+ it( 'returns empty string for NaN input', () => {
+ expect( formatCurrency( 'not-a-number' ) ).toBe( '' );
+ } );
+
+ it( 'returns empty string for Infinity input', () => {
+ expect( formatCurrency( Infinity ) ).toBe( '' );
+ } );
+
+ it( 'handles zero correctly', () => {
+ const result = formatCurrency( 0 );
+ expect( result ).toContain( '0.00' );
+ } );
+
+ it( 'accepts a custom currency code', () => {
+ const result = formatCurrency( 10, 'EUR' );
+ expect( result ).toContain( '10.00' );
+ } );
+
+ it( 'falls back to USD formatting when currency code is invalid', () => {
+ const result = formatCurrency( 10, 'INVALID' );
+ expect( result ).toContain( '10.00' );
+ } );
+} );
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/utils/currency.ts b/packages/js/experimental-products-app/src/variation-view/fields/utils/currency.ts
new file mode 100644
index 00000000000..8316d9b28e4
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/utils/currency.ts
@@ -0,0 +1,60 @@
+/**
+ * External dependencies
+ */
+import { CURRENCY } from '@woocommerce/settings';
+
+type CurrencyObject = {
+ code: string;
+ symbol: string;
+ symbolPosition: string;
+ precision: number;
+ decimalSeparator?: string;
+ thousandSeparator?: string;
+};
+
+export function getCurrencyObject(): CurrencyObject {
+ const parsedPrecision = Number( CURRENCY.precision );
+ return {
+ code: CURRENCY.code || 'USD',
+ symbol: CURRENCY.symbol || '$',
+ symbolPosition: CURRENCY.symbolPosition || 'left',
+ precision:
+ Number.isFinite( parsedPrecision ) && parsedPrecision >= 0
+ ? parsedPrecision
+ : 2,
+ decimalSeparator: CURRENCY.decimalSeparator,
+ thousandSeparator: CURRENCY.thousandSeparator,
+ };
+}
+
+export function formatCurrency(
+ value: number | string,
+ currencyCode = getCurrencyObject().code
+) {
+ const amount = typeof value === 'number' ? value : Number( value );
+ if ( ! Number.isFinite( amount ) ) {
+ return '';
+ }
+ const locale =
+ typeof document !== 'undefined'
+ ? document.documentElement.lang || 'en-US'
+ : 'en-US';
+ const { precision } = getCurrencyObject();
+ const safeCurrencyCode = currencyCode || 'USD';
+
+ try {
+ return new Intl.NumberFormat( locale, {
+ style: 'currency',
+ currency: safeCurrencyCode,
+ minimumFractionDigits: precision,
+ maximumFractionDigits: precision,
+ } ).format( amount );
+ } catch {
+ return new Intl.NumberFormat( 'en-US', {
+ style: 'currency',
+ currency: 'USD',
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ } ).format( amount );
+ }
+}
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/utils/price.ts b/packages/js/experimental-products-app/src/variation-view/fields/utils/price.ts
new file mode 100644
index 00000000000..2afa8ddd4b7
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/utils/price.ts
@@ -0,0 +1,46 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+export const getLocalDefaultSaleStart = () => {
+ const tomorrow = new Date();
+ tomorrow.setDate( tomorrow.getDate() + 1 );
+ tomorrow.setHours( 7, 0, 0, 0 );
+ tomorrow.setMinutes( tomorrow.getMinutes() - tomorrow.getTimezoneOffset() );
+ return tomorrow.toISOString().slice( 0, 16 );
+};
+
+export const toNumberOrNaN = ( value: unknown ) => {
+ if ( typeof value === 'number' ) {
+ return value;
+ }
+
+ if ( typeof value === 'string' && value.trim() !== '' ) {
+ return Number.parseFloat( value );
+ }
+
+ return Number.NaN;
+};
+
+export function validatePrice( value: unknown ): string | null {
+ if (
+ value === undefined ||
+ value === null ||
+ ( typeof value === 'string' && value.trim() === '' )
+ ) {
+ return null;
+ }
+
+ const parsed = toNumberOrNaN( value );
+
+ if ( Number.isNaN( parsed ) ) {
+ return __( 'Please enter a valid price.', 'woocommerce' );
+ }
+
+ if ( parsed < 0 ) {
+ return __( 'Price must not be negative.', 'woocommerce' );
+ }
+
+ return null;
+}
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/variation_active/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/variation_active/field.tsx
new file mode 100644
index 00000000000..030699da6c8
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/variation_active/field.tsx
@@ -0,0 +1,125 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { Badge, SelectControl } from '@wordpress/ui';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord, ProductStatus } from '../types';
+
+type VariationActiveValue = 'active' | 'inactive';
+
+const ACTIVE_VALUE: VariationActiveValue = 'active';
+const INACTIVE_VALUE: VariationActiveValue = 'inactive';
+
+function isVariationActiveValue(
+ value: unknown
+): value is VariationActiveValue {
+ return value === ACTIVE_VALUE || value === INACTIVE_VALUE;
+}
+
+function hasPrice( item: Pick< ProductEntityRecord, 'price' > ) {
+ return item.price !== undefined && item.price !== null && item.price !== '';
+}
+
+export function isVariationActive(
+ item: Pick< ProductEntityRecord, 'price' | 'status' >
+) {
+ return item.status === 'publish' && hasPrice( item );
+}
+
+export function getVariationActiveValue(
+ item: Pick< ProductEntityRecord, 'price' | 'status' >
+): VariationActiveValue {
+ return isVariationActive( item ) ? ACTIVE_VALUE : INACTIVE_VALUE;
+}
+
+function getVariationActiveFormValue(
+ data: ProductEntityRecord & Record< string, unknown >
+): VariationActiveValue | undefined {
+ if ( isVariationActiveValue( data.variation_active ) ) {
+ return data.variation_active;
+ }
+
+ return getVariationActiveValue( data );
+}
+
+function getVariationActiveStatus(
+ value: VariationActiveValue
+): ProductStatus {
+ return value === ACTIVE_VALUE ? 'publish' : 'private';
+}
+
+export function getVariationActiveLabel( value: VariationActiveValue ) {
+ return value === ACTIVE_VALUE
+ ? __( 'Active', 'woocommerce' )
+ : __( 'Inactive', 'woocommerce' );
+}
+
+export function VariationActiveBadge( {
+ value,
+}: {
+ value: VariationActiveValue;
+} ) {
+ return value === ACTIVE_VALUE ? (
+ <Badge intent="stable">{ getVariationActiveLabel( value ) }</Badge>
+ ) : (
+ <Badge intent="draft">{ getVariationActiveLabel( value ) }</Badge>
+ );
+}
+
+const fieldDefinition = {
+ type: 'text',
+ label: __( 'Status', 'woocommerce' ),
+ enableSorting: false,
+ filterBy: false,
+ elements: [
+ { value: ACTIVE_VALUE, label: __( 'Active', 'woocommerce' ) },
+ { value: INACTIVE_VALUE, label: __( 'Inactive', 'woocommerce' ) },
+ ],
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ ...fieldDefinition,
+ getValue: ( { item } ) => getVariationActiveValue( item ),
+ render: ( { item }: { item: ProductEntityRecord } ) => (
+ <VariationActiveBadge value={ getVariationActiveValue( item ) } />
+ ),
+ Edit: ( { data, onChange, field } ) => {
+ const options = field.elements ?? [];
+ const formData = data as ProductEntityRecord &
+ Record< string, unknown >;
+ const formValue =
+ field.placeholder &&
+ ! isVariationActiveValue( formData.variation_active )
+ ? undefined
+ : getVariationActiveFormValue( formData );
+ const selectedOption = options.find(
+ ( option ) => option.value === formValue
+ );
+
+ return (
+ <SelectControl
+ label={ field.label }
+ placeholder={ field.placeholder }
+ value={ selectedOption }
+ items={ options }
+ onValueChange={ ( option ) => {
+ const selectedValue = option?.value;
+
+ if (
+ selectedValue === ACTIVE_VALUE ||
+ selectedValue === INACTIVE_VALUE
+ ) {
+ onChange( {
+ status: getVariationActiveStatus( selectedValue ),
+ } );
+ }
+ } }
+ />
+ );
+ },
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/virtual/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/virtual/field.tsx
new file mode 100644
index 00000000000..6e433858848
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/virtual/field.tsx
@@ -0,0 +1,20 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ type: 'boolean',
+ label: __( 'Virtual', 'woocommerce' ),
+ enableSorting: false,
+ enableHiding: false,
+ filterBy: false,
+ getValue: ( { item } ) => item.virtual ?? false,
+ Edit: 'checkbox',
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/weight/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/weight/field.tsx
new file mode 100644
index 00000000000..923cfc1f007
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/weight/field.tsx
@@ -0,0 +1,28 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+import {
+ createVariationWeightField,
+ isDimensionVisible,
+} from '../components/dimension';
+
+const fieldDefinition = {
+ type: 'text',
+ label: __( 'Weight', 'woocommerce' ),
+ enableSorting: false,
+ enableHiding: false,
+ filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ ...fieldDefinition,
+ isVisible: isDimensionVisible,
+ ...createVariationWeightField(),
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/width/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/width/field.tsx
new file mode 100644
index 00000000000..1e1fe2706e9
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/width/field.tsx
@@ -0,0 +1,24 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+import { createVariationDimensionField } from '../components/dimension';
+
+const fieldDefinition = {
+ type: 'text',
+ label: __( 'Width', 'woocommerce' ),
+ enableSorting: false,
+ enableHiding: false,
+ filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ ...fieldDefinition,
+ ...createVariationDimensionField( 'width' ),
+};
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 580c0d8f7de..07cb632617f 100644
--- a/packages/js/experimental-products-app/src/variation-view/index.tsx
+++ b/packages/js/experimental-products-app/src/variation-view/index.tsx
@@ -19,7 +19,7 @@ import { buildVariationViewQuery } from './query';
import { normalizeVariation } from './normalization';
import { variationFields } from './fields';
import type { VariationEntityRecord } from './types';
-import ProductEdit from '../product-edit';
+import { VariationEditDrawer } from './edit/drawer';
import { getProductWithUpdatedVariation } from '../product-edit/utils';
import type { ProductEntityRecord } from '../fields/types';
import { unlock } from '../lock-unlock';
@@ -266,6 +266,15 @@ export function VariationView( { productId }: VariationViewProps ) {
[ handleEditSelectedVariations ]
);
+ const handleCloseDrawer = useCallback( () => {
+ navigate(
+ getProductListNavigationPath( location.path, {
+ ...currentQuery,
+ quickEdit: undefined,
+ } )
+ );
+ }, [ currentQuery, location.path, navigate ] );
+
const actions: Action< VariationEntityRecord >[] = useMemo(
() => [
{
@@ -348,9 +357,11 @@ export function VariationView( { productId }: VariationViewProps ) {
<DataViews.Footer />
</DataViews>
{ productWithVariations && (
- <ProductEdit
+ <VariationEditDrawer
products={ [ productWithVariations ] }
isOpen={ showQuickEdit }
+ productId={ productId }
+ onClose={ handleCloseDrawer }
/>
) }
</div>
diff --git a/packages/js/experimental-products-app/src/variation-view/style.scss b/packages/js/experimental-products-app/src/variation-view/style.scss
index 0aee17eeded..8e66717096d 100644
--- a/packages/js/experimental-products-app/src/variation-view/style.scss
+++ b/packages/js/experimental-products-app/src/variation-view/style.scss
@@ -166,3 +166,28 @@
white-space: nowrap;
}
}
+
+.woocommerce-variation-name {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--wpds-dimension-padding-xs, 8px);
+ min-width: 0;
+}
+
+.woocommerce-variation-name__label {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.woocommerce-variation-status-option {
+ display: flex;
+ flex-direction: column;
+ gap: var(--wpds-dimension-padding-3xs, 2px);
+}
+
+.woocommerce-variation-status-option__description {
+ color: var(--wpds-color-fg-content-neutral-weak, #757575);
+ font-size: var(--wpds-typography-font-size-sm, 12px);
+ line-height: var(--wpds-typography-line-height-sm, 16px);
+}