Commit 36175778859 for woocommerce
commit 36175778859d965b349e8fecc887aaf9629287d0
Author: Luigi Teschio <gigitux@gmail.com>
Date: Thu May 21 15:52:43 2026 +0200
Add variation active status field (#65248)
* Add variation active status field
* Add changelog for variation active status
* Remove embedded variation normalization
diff --git a/packages/js/experimental-products-app/changelog/fix-variation-active-status b/packages/js/experimental-products-app/changelog/fix-variation-active-status
new file mode 100644
index 00000000000..395e8a6c6f2
--- /dev/null
+++ b/packages/js/experimental-products-app/changelog/fix-variation-active-status
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Show variation Active and Inactive status in the products app table and quick edit.
diff --git a/packages/js/experimental-products-app/src/fields/product_status/field.test.tsx b/packages/js/experimental-products-app/src/fields/product_status/field.test.tsx
new file mode 100644
index 00000000000..c7d83ff778b
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/product_status/field.test.tsx
@@ -0,0 +1,116 @@
+/**
+ * External dependencies
+ */
+import { render, screen } from '@testing-library/react';
+import React from 'react';
+
+jest.mock( '@wordpress/ui', () => {
+ const ReactActual = jest.requireActual( 'react' );
+
+ return {
+ Badge: ( {
+ children,
+ intent,
+ }: {
+ children?: React.ReactNode;
+ intent?: string;
+ } ) =>
+ ReactActual.createElement(
+ 'span',
+ { 'data-intent': intent },
+ children
+ ),
+ SelectControl: () => null,
+ };
+} );
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+import { fieldExtensions } from './field';
+
+const buildProduct = (
+ overrides: Partial< ProductEntityRecord > = {}
+): ProductEntityRecord =>
+ ( {
+ id: 12,
+ type: 'simple',
+ status: 'publish',
+ price: '12',
+ images: [],
+ categories: [],
+ tags: [],
+ ...overrides,
+ } as ProductEntityRecord );
+
+const renderStatus = ( item: ProductEntityRecord ) => {
+ if ( ! fieldExtensions.render ) {
+ throw new Error( 'product_status render not implemented' );
+ }
+
+ const Render = fieldExtensions.render as React.ComponentType< {
+ item: ProductEntityRecord;
+ } >;
+
+ return render( <Render item={ item } /> );
+};
+
+describe( 'product_status field', () => {
+ it( 'renders product post statuses for products', () => {
+ renderStatus( buildProduct( { status: 'draft' } ) );
+
+ expect( screen.getByText( 'Draft' ) ).toBeInTheDocument();
+ expect(
+ fieldExtensions.getValue?.( {
+ item: buildProduct( { status: 'draft' } ),
+ } as never )
+ ).toBe( 'draft' );
+ } );
+
+ it( 'renders active status for variation rows in DataViews tables', () => {
+ renderStatus(
+ buildProduct( {
+ parent_id: 12,
+ type: 'variation',
+ status: 'publish',
+ price: '12',
+ } )
+ );
+
+ expect( screen.getByText( 'Active' ) ).toBeInTheDocument();
+ expect(
+ fieldExtensions.getValue?.( {
+ item: buildProduct( {
+ parent_id: 12,
+ type: 'variation',
+ status: 'publish',
+ price: '12',
+ } ),
+ } as never )
+ ).toBe( 'active' );
+ } );
+
+ it( 'renders inactive status for variation rows without a price', () => {
+ renderStatus(
+ buildProduct( {
+ parent_id: 12,
+ type: 'variation',
+ status: 'publish',
+ price: '',
+ } )
+ );
+
+ expect( screen.getByText( 'Inactive' ) ).toBeInTheDocument();
+ expect(
+ fieldExtensions.getValue?.( {
+ item: buildProduct( {
+ parent_id: 12,
+ type: 'variation',
+ status: 'publish',
+ price: '',
+ } ),
+ } as never )
+ ).toBe( 'inactive' );
+ } );
+} );
diff --git a/packages/js/experimental-products-app/src/fields/product_status/field.tsx b/packages/js/experimental-products-app/src/fields/product_status/field.tsx
index a2ac124684c..12bbf4f464e 100644
--- a/packages/js/experimental-products-app/src/fields/product_status/field.tsx
+++ b/packages/js/experimental-products-app/src/fields/product_status/field.tsx
@@ -11,6 +11,14 @@ import type { Field } from '@wordpress/dataviews';
*/
import type { ProductEntityRecord } from '../types';
import { ProductStatusBadge } from '../components/product-status-badge';
+import {
+ getVariationActiveValue,
+ VariationActiveBadge,
+} from '../variation_active/field';
+
+function isVariation( item: ProductEntityRecord ) {
+ return item.type === 'variation' || Boolean( item.parent_id );
+}
function isValidStatus( value: string ) {
return (
@@ -36,10 +44,14 @@ const fieldDefinition = {
export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
...fieldDefinition,
- getValue: ( { item } ) => item.status,
- render: ( { item }: { item: ProductEntityRecord } ) => (
- <ProductStatusBadge status={ item.status } />
- ),
+ getValue: ( { item } ) =>
+ isVariation( item ) ? getVariationActiveValue( item ) : item.status,
+ render: ( { item }: { item: ProductEntityRecord } ) =>
+ isVariation( item ) ? (
+ <VariationActiveBadge value={ getVariationActiveValue( item ) } />
+ ) : (
+ <ProductStatusBadge status={ item.status } />
+ ),
Edit: ( { data, onChange, field } ) => {
const options =
field.elements?.filter(
diff --git a/packages/js/experimental-products-app/src/fields/registry.tsx b/packages/js/experimental-products-app/src/fields/registry.tsx
index 449d1fa5f0a..84104e3172c 100644
--- a/packages/js/experimental-products-app/src/fields/registry.tsx
+++ b/packages/js/experimental-products-app/src/fields/registry.tsx
@@ -49,6 +49,7 @@ import { fieldExtensions as taxStatusFieldExtensions } from './tax_status/field'
import type { ProductEntityRecord } from './types';
import { fieldExtensions as typeFieldExtensions } from './type/field';
import { fieldExtensions as upsellIdsFieldExtensions } from './upsell_ids/field';
+import { fieldExtensions as variationActiveFieldExtensions } from './variation_active/field';
import { fieldExtensions as visibilitySummaryFieldExtensions } from './visibility_summary/field';
import { fieldExtensions as weightFieldExtensions } from './weight/field';
import { fieldExtensions as widthFieldExtensions } from './width/field';
@@ -63,6 +64,7 @@ export const PRODUCT_FIELD_IDS = [
'images',
'images_count',
'product_status',
+ 'variation_active',
'sku',
'price',
'regular_price',
@@ -116,6 +118,7 @@ const PRODUCT_FIELD_EXTENSIONS: Record<
images: imagesFieldExtensions,
images_count: imagesCountFieldExtensions,
product_status: productStatusFieldExtensions,
+ variation_active: variationActiveFieldExtensions,
sku: skuFieldExtensions,
price: priceFieldExtensions as ProductFieldExtensions,
regular_price: regularPriceFieldExtensions,
diff --git a/packages/js/experimental-products-app/src/fields/variation_active/field.test.tsx b/packages/js/experimental-products-app/src/fields/variation_active/field.test.tsx
new file mode 100644
index 00000000000..b5d835d116c
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/variation_active/field.test.tsx
@@ -0,0 +1,206 @@
+/**
+ * External dependencies
+ */
+import { fireEvent, render, screen } from '@testing-library/react';
+import type { DataFormControlProps } from '@wordpress/dataviews';
+import React from 'react';
+
+jest.mock( '@wordpress/ui', () => {
+ const ReactActual = jest.requireActual( 'react' );
+
+ return {
+ Badge: ( {
+ children,
+ intent,
+ }: {
+ children?: React.ReactNode;
+ intent?: string;
+ } ) =>
+ ReactActual.createElement(
+ 'span',
+ { 'data-intent': intent },
+ children
+ ),
+ SelectControl: ( {
+ items,
+ label,
+ onValueChange,
+ placeholder,
+ value,
+ }: {
+ items: Array< { label: string; value: string } >;
+ label: string;
+ onValueChange: ( option: { value: string } ) => void;
+ placeholder?: string;
+ value?: { label: string; value: string };
+ } ) =>
+ ReactActual.createElement(
+ 'fieldset',
+ null,
+ ReactActual.createElement( 'legend', null, label ),
+ ReactActual.createElement(
+ 'div',
+ { 'data-testid': 'selected-option' },
+ value?.label ?? placeholder ?? ''
+ ),
+ items.map( ( item ) =>
+ ReactActual.createElement(
+ 'button',
+ {
+ key: item.value,
+ onClick: () => onValueChange( item ),
+ type: 'button',
+ },
+ item.label
+ )
+ )
+ ),
+ };
+} );
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+import {
+ fieldExtensions,
+ getVariationActiveValue,
+ isVariationActive,
+} from './field';
+
+const buildVariation = (
+ overrides: Partial< ProductEntityRecord > = {}
+): ProductEntityRecord =>
+ ( {
+ id: 34,
+ parent_id: 12,
+ type: 'variation',
+ status: 'publish',
+ price: '12',
+ images: [],
+ categories: [],
+ tags: [],
+ ...overrides,
+ } as ProductEntityRecord );
+
+describe( 'variation_active field', () => {
+ it.each( [
+ [ 'active', { status: 'publish', price: '12' }, true ],
+ [ 'active with a zero price', { status: 'publish', price: '0' }, true ],
+ [ 'inactive when private', { status: 'private', price: '12' }, false ],
+ [ 'inactive without a price', { status: 'publish', price: '' }, false ],
+ ] as const )(
+ 'identifies an %s variation',
+ ( _label, overrides, expected ) => {
+ expect( isVariationActive( buildVariation( overrides ) ) ).toBe(
+ expected
+ );
+ }
+ );
+
+ it( 'returns active and inactive values for DataViews cells', () => {
+ expect(
+ fieldExtensions.getValue?.( {
+ item: buildVariation( { status: 'publish', price: '12' } ),
+ } as never )
+ ).toBe( 'active' );
+ expect(
+ fieldExtensions.getValue?.( {
+ item: buildVariation( { status: 'publish', price: '' } ),
+ } as never )
+ ).toBe( 'inactive' );
+ expect(
+ getVariationActiveValue(
+ buildVariation( { status: 'private', price: '12' } )
+ )
+ ).toBe( 'inactive' );
+ } );
+
+ it( 'renders active and inactive badges for the table', () => {
+ if ( ! fieldExtensions.render ) {
+ throw new Error( 'variation_active render not implemented' );
+ }
+
+ const Render = fieldExtensions.render as React.ComponentType< {
+ item: ProductEntityRecord;
+ } >;
+ const { rerender } = render(
+ <Render
+ item={ buildVariation( { status: 'publish', price: '12' } ) }
+ />
+ );
+
+ expect( screen.getByText( 'Active' ) ).toBeInTheDocument();
+
+ rerender(
+ <Render
+ item={ buildVariation( { status: 'publish', price: '' } ) }
+ />
+ );
+
+ expect( screen.getByText( 'Inactive' ) ).toBeInTheDocument();
+ } );
+
+ it( 'maps active and inactive edits to variation post statuses', () => {
+ if ( ! fieldExtensions.Edit ) {
+ throw new Error( 'variation_active edit not implemented' );
+ }
+
+ const onChange = jest.fn();
+ const Edit = fieldExtensions.Edit as React.ComponentType<
+ DataFormControlProps< ProductEntityRecord >
+ >;
+
+ render(
+ <Edit
+ data={ buildVariation( { status: 'publish', price: '' } ) }
+ field={
+ {
+ ...fieldExtensions,
+ id: 'variation_active',
+ } as DataFormControlProps< ProductEntityRecord >[ 'field' ]
+ }
+ onChange={ onChange }
+ />
+ );
+
+ fireEvent.click( screen.getByRole( 'button', { name: 'Active' } ) );
+ expect( onChange ).toHaveBeenCalledWith( { status: 'publish' } );
+
+ fireEvent.click( screen.getByRole( 'button', { name: 'Inactive' } ) );
+ expect( onChange ).toHaveBeenCalledWith( { status: 'private' } );
+ } );
+
+ it( 'shows the mixed placeholder for bulk edits with different active values', () => {
+ if ( ! fieldExtensions.Edit ) {
+ throw new Error( 'variation_active edit not implemented' );
+ }
+
+ const Edit = fieldExtensions.Edit as React.ComponentType<
+ DataFormControlProps< ProductEntityRecord >
+ >;
+
+ render(
+ <Edit
+ data={
+ {
+ ...buildVariation(),
+ variation_active: undefined,
+ } as ProductEntityRecord & Record< string, unknown >
+ }
+ field={
+ {
+ ...fieldExtensions,
+ id: 'variation_active',
+ placeholder: 'Mixed',
+ } as DataFormControlProps< ProductEntityRecord >[ 'field' ]
+ }
+ onChange={ jest.fn() }
+ />
+ );
+
+ expect( screen.getByTestId( 'selected-option' ) ).toHaveTextContent(
+ 'Mixed'
+ );
+ } );
+} );
diff --git a/packages/js/experimental-products-app/src/fields/variation_active/field.tsx b/packages/js/experimental-products-app/src/fields/variation_active/field.tsx
new file mode 100644
index 00000000000..b03f6cd2fa1
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/variation_active/field.tsx
@@ -0,0 +1,126 @@
+/**
+ * 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/product-edit/bulk-edit.ts b/packages/js/experimental-products-app/src/product-edit/bulk-edit.ts
index d99835f79e9..211e9fe8e44 100644
--- a/packages/js/experimental-products-app/src/product-edit/bulk-edit.ts
+++ b/packages/js/experimental-products-app/src/product-edit/bulk-edit.ts
@@ -118,6 +118,10 @@ function getProductFieldValue(
return product.dimensions?.[ field.id ] ?? '';
}
+ if ( field.getValue ) {
+ return field.getValue( { item: product } );
+ }
+
const dataKey = getFieldDataKey( field.id );
return product[ dataKey as keyof ProductEntityRecord ];
diff --git a/packages/js/experimental-products-app/src/product-edit/index.tsx b/packages/js/experimental-products-app/src/product-edit/index.tsx
index d765ef2b834..c21cc7c30c1 100644
--- a/packages/js/experimental-products-app/src/product-edit/index.tsx
+++ b/packages/js/experimental-products-app/src/product-edit/index.tsx
@@ -182,6 +182,14 @@ function getBulkEditFormData(
return;
}
+ if ( fieldId === 'variation_active' ) {
+ ( data as Record< string, unknown > ).variation_active =
+ fieldStates[ fieldId ].isMixed
+ ? undefined
+ : fieldStates[ fieldId ].value;
+ return;
+ }
+
if ( ! isBulkNumericFieldId( fieldId ) ) {
return;
}
diff --git a/packages/js/experimental-products-app/src/product-edit/utils.test.ts b/packages/js/experimental-products-app/src/product-edit/utils.test.ts
index fa213bb1eca..04c11fb3dc1 100644
--- a/packages/js/experimental-products-app/src/product-edit/utils.test.ts
+++ b/packages/js/experimental-products-app/src/product-edit/utils.test.ts
@@ -252,6 +252,47 @@ describe( 'product edit utils', () => {
} );
} );
+ it( 'returns bulk field state for variation active values', () => {
+ const activeVariation = buildProduct( {
+ id: 1,
+ parent_id: 12,
+ type: 'variation',
+ status: 'publish',
+ price: '12',
+ } );
+ const inactiveVariation = buildProduct( {
+ id: 2,
+ parent_id: 12,
+ type: 'variation',
+ status: 'private',
+ price: '12',
+ } );
+
+ expect(
+ buildProductBulkEditData(
+ [ activeVariation ],
+ getProductEditFields( productFields )
+ ).fieldStates.variation_active
+ ).toEqual( {
+ isEmpty: false,
+ isMixed: false,
+ placeholder: undefined,
+ value: 'active',
+ } );
+
+ expect(
+ buildProductBulkEditData(
+ [ activeVariation, inactiveVariation ],
+ getProductEditFields( productFields )
+ ).fieldStates.variation_active
+ ).toEqual( {
+ isEmpty: false,
+ isMixed: true,
+ placeholder: 'Mixed',
+ value: undefined,
+ } );
+ } );
+
it( 'returns a mixed bulk field state for different grouped products', () => {
const products = [
buildProduct( {
@@ -1033,7 +1074,7 @@ describe( 'product edit utils', () => {
expect( fieldIds ).toEqual(
expect.arrayContaining( [
- 'product_status',
+ 'variation_active',
'regular_price',
'sale_price',
'schedule_sale',
@@ -1081,7 +1122,7 @@ describe( 'product edit utils', () => {
'cost_of_goods_sold',
'stock',
'manage_stock',
- 'product_status',
+ 'variation_active',
'shipping_class',
'weight',
'length',
@@ -1255,7 +1296,6 @@ describe( 'product edit utils', () => {
expect( fieldIds ).toEqual(
expect.arrayContaining( [
- 'product_status',
'images',
'manage_stock',
'shipping_class',
@@ -1268,6 +1308,8 @@ describe( 'product edit utils', () => {
expectFieldsHidden( fieldIds, [
...parentOwnedFieldIds,
...priceFieldIds,
+ 'product_status',
+ 'variation_active',
'downloadable',
'sku',
'stock',
@@ -1304,7 +1346,6 @@ describe( 'product edit utils', () => {
expect( fieldIds ).toEqual(
expect.arrayContaining( [
- 'product_status',
'images',
'manage_stock',
'shipping_class',
@@ -1317,6 +1358,8 @@ describe( 'product edit utils', () => {
expectFieldsHidden( fieldIds, [
...parentOwnedFieldIds,
...priceFieldIds,
+ 'product_status',
+ 'variation_active',
'downloadable',
'sku',
'stock',
@@ -1595,7 +1638,7 @@ describe( 'product edit utils', () => {
{
id: 'general-fields',
label: 'General',
- children: [ 'product_status' ],
+ children: [ 'variation_active' ],
},
{
id: 'price-fields',
diff --git a/packages/js/experimental-products-app/src/product-edit/utils.ts b/packages/js/experimental-products-app/src/product-edit/utils.ts
index 419e8872ab2..0b772069c2c 100644
--- a/packages/js/experimental-products-app/src/product-edit/utils.ts
+++ b/packages/js/experimental-products-app/src/product-edit/utils.ts
@@ -47,6 +47,7 @@ const PRODUCT_EDIT_FIELD_IDS = [
'images',
'images_count',
'product_status',
+ 'variation_active',
'sku',
'price',
'regular_price',
@@ -150,7 +151,7 @@ const VARIATION_PRODUCT_EDIT_FORM_FIELDS = [
createProductEditFormGroup(
'general-fields',
__( 'General', 'woocommerce' ),
- [ 'product_status' ]
+ [ 'variation_active' ]
),
createProductEditFormGroup( 'price-fields', __( 'Price', 'woocommerce' ), [
'regular_price',
diff --git a/packages/js/experimental-products-app/src/variation-view/constants.ts b/packages/js/experimental-products-app/src/variation-view/constants.ts
index 32eb997ef90..dab893696ee 100644
--- a/packages/js/experimental-products-app/src/variation-view/constants.ts
+++ b/packages/js/experimental-products-app/src/variation-view/constants.ts
@@ -10,6 +10,7 @@ export const DEFAULT_VARIATION_TABLE_MEDIA_FIELD = 'images';
export const DEFAULT_VARIATION_TABLE_FIELDS = [
'variation_options',
+ 'variation_active',
'price',
'stock',
] as const;
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 c464c4e37d0..9757d6d7e97 100644
--- a/packages/js/experimental-products-app/src/variation-view/fields.tsx
+++ b/packages/js/experimental-products-app/src/variation-view/fields.tsx
@@ -18,7 +18,7 @@ const REUSED_VARIATION_FIELD_IDS = [
'stock',
'stock_quantity',
'manage_stock',
- 'product_status',
+ 'variation_active',
'images',
'downloadable',
'weight',