Commit 79c22e8a226 for woocommerce
commit 79c22e8a226405422bd341767daa4f7b21a434f8
Author: Luigi Teschio <gigitux@gmail.com>
Date: Mon May 18 17:08:16 2026 +0200
Add cost of goods to product edit form (#65129)
* Add cost of goods to product edit form
* Add changelog entry for cost of goods field
diff --git a/packages/js/experimental-products-app/changelog/add-cost-of-goods-field b/packages/js/experimental-products-app/changelog/add-cost-of-goods-field
new file mode 100644
index 00000000000..1e152d89da0
--- /dev/null
+++ b/packages/js/experimental-products-app/changelog/add-cost-of-goods-field
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add the cost of goods field to the experimental products edit form.
diff --git a/packages/js/experimental-products-app/src/fields/components/currency-input.tsx b/packages/js/experimental-products-app/src/fields/components/currency-input.tsx
index dbf2abfb7c0..f35b8ae508c 100644
--- a/packages/js/experimental-products-app/src/fields/components/currency-input.tsx
+++ b/packages/js/experimental-products-app/src/fields/components/currency-input.tsx
@@ -1,3 +1,4 @@
+/* eslint-disable @wordpress/no-unsafe-wp-apis -- Currency inputs use wrappers for the private ValidatedInputControl API. */
/**
* External dependencies
*/
@@ -26,10 +27,56 @@ const isCurrencyLeft =
// Step matches store decimal precision (e.g. precision=2 → step=0.01).
const step = Math.pow( 10, -precision );
-type PriceField = 'regular_price' | 'sale_price';
+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' ];
+};
+
+export function CurrencyControl( {
+ id,
+ label,
+ value,
+ onChange,
+ customValidity,
+}: CurrencyControlProps ) {
+ return (
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-call -- ValidatedInputControl is a private API
+ <ValidatedInputControl
+ id={ id }
+ label={ label }
+ value={ value }
+ 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 price fields (regular_price, sale_price).
+ * Shared Edit component for currency fields.
* Renders a number input with min=0 and currency prefix/suffix.
*
* @param root0 Props from DataForm.
@@ -44,36 +91,17 @@ export function CurrencyInput( {
onChange,
validity,
}: DataFormControlProps< ProductEntityRecord > ) {
- const fieldId = field.id as PriceField;
- const value = data[ fieldId ] ?? '';
+ const fieldId = field.id as CurrencyField;
return (
- // eslint-disable-next-line @typescript-eslint/no-unsafe-call -- ValidatedInputControl is a private API
- <ValidatedInputControl
+ <CurrencyControl
id={ `currency-input-${ fieldId }` }
label={ field.label }
- value={ value }
+ value={ data[ fieldId ] ?? '' }
onChange={ ( newValue: string ) => {
onChange( { [ fieldId ]: newValue } );
} }
- type="number"
- min={ 0 }
- step={ step }
customValidity={ validity?.custom }
- prefix={
- isCurrencyLeft ? (
- <InputControlPrefixWrapper>
- { symbol }
- </InputControlPrefixWrapper>
- ) : undefined
- }
- suffix={
- ! isCurrencyLeft ? (
- <InputControlSuffixWrapper>
- { symbol }
- </InputControlSuffixWrapper>
- ) : undefined
- }
/>
);
}
diff --git a/packages/js/experimental-products-app/src/fields/cost_of_goods_sold/field.tsx b/packages/js/experimental-products-app/src/fields/cost_of_goods_sold/field.tsx
new file mode 100644
index 00000000000..9bb737c36fd
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/cost_of_goods_sold/field.tsx
@@ -0,0 +1,89 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+import type { DataFormControlProps, Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import { validatePrice, toNumberOrNaN } from '../price/utils';
+import { formatCurrency, getCurrencyObject } from '../utils/currency';
+import { CurrencyControl } from '../components/currency-input';
+
+import type { ProductEntityRecord } from '../types';
+
+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,
+ onChange,
+ validity,
+}: DataFormControlProps< ProductEntityRecord > ) {
+ const costOfGoodsSold = data.cost_of_goods_sold ?? {};
+ const [ firstValue = {}, ...remainingValues ] =
+ costOfGoodsSold.values ?? [];
+
+ return (
+ <CurrencyControl
+ id={ `currency-input-${ field.id }` }
+ label={ field.label }
+ value={ getDefinedCostValue( data ) ?? '' }
+ onChange={ ( newValue: string ) => {
+ onChange( {
+ cost_of_goods_sold: {
+ ...costOfGoodsSold,
+ values:
+ newValue === ''
+ ? []
+ : [
+ {
+ ...firstValue,
+ defined_value: newValue,
+ },
+ ...remainingValues,
+ ],
+ },
+ } );
+ } }
+ customValidity={ validity?.custom }
+ />
+ );
+}
+
+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 '\u2014';
+ }
+
+ return formatCurrency( numberValue, getCurrencyObject().code );
+ },
+ isValid: {
+ custom: ( item ) => validatePrice( getDefinedCostValue( item ) ),
+ },
+ Edit: CostOfGoodsSoldInput,
+};
diff --git a/packages/js/experimental-products-app/src/fields/registry.tsx b/packages/js/experimental-products-app/src/fields/registry.tsx
index cd3782d0ad6..da831517110 100644
--- a/packages/js/experimental-products-app/src/fields/registry.tsx
+++ b/packages/js/experimental-products-app/src/fields/registry.tsx
@@ -10,6 +10,7 @@ import { fieldExtensions as brandsFieldExtensions } from './brands/field';
import { fieldExtensions as buttonTextFieldExtensions } from './button_text/field';
import { fieldExtensions as catalogVisibilityFieldExtensions } from './catalog_visibility/field';
import { fieldExtensions as categoriesFieldExtensions } from './categories/field';
+import { fieldExtensions as costOfGoodsSoldFieldExtensions } from './cost_of_goods_sold/field';
import { fieldExtensions as crossSellIdsFieldExtensions } from './cross_sell_ids/field';
import { fieldExtensions as dateFieldExtensions } from './date/field';
import { fieldExtensions as dateOnSaleFromFieldExtensions } from './date_on_sale_from/field';
@@ -69,6 +70,7 @@ export const PRODUCT_FIELD_IDS = [
'schedule_sale',
'date_on_sale_from',
'date_on_sale_to',
+ 'cost_of_goods_sold',
'price_summary',
'stock',
'stock_quantity',
@@ -119,6 +121,7 @@ const PRODUCT_FIELD_EXTENSIONS: Record<
schedule_sale: scheduleSaleFieldExtensions,
date_on_sale_from: dateOnSaleFromFieldExtensions,
date_on_sale_to: dateOnSaleToFieldExtensions,
+ cost_of_goods_sold: costOfGoodsSoldFieldExtensions,
on_sale: onSaleFieldExtensions,
price_summary: priceSummaryFieldExtensions,
stock: stockFieldExtensions,
diff --git a/packages/js/experimental-products-app/src/fields/types.ts b/packages/js/experimental-products-app/src/fields/types.ts
index 7dbdad1ad5b..2356e484226 100644
--- a/packages/js/experimental-products-app/src/fields/types.ts
+++ b/packages/js/experimental-products-app/src/fields/types.ts
@@ -7,6 +7,14 @@ import type {
} 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;
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 bb0cf414165..d0dc9219ffc 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
@@ -2,6 +2,7 @@
* External dependencies
*/
import type { Field } from '@wordpress/dataviews';
+import { getSetting } from '@woocommerce/settings';
/**
* Internal dependencies
@@ -41,9 +42,24 @@ jest.mock( '@woocommerce/settings', () => ( {
symbolPosition: 'left',
precision: 2,
},
+ getSetting: jest.fn(),
} ) );
describe( 'product edit utils', () => {
+ const getSettingMock = getSetting as jest.Mock;
+ const mockCostOfGoodsSoldFeatureEnabled = ( isEnabled: boolean ) => {
+ getSettingMock.mockImplementation( ( name, fallback ) =>
+ name === 'admin'
+ ? {
+ features: {
+ cost_of_goods_sold: {
+ is_enabled: isEnabled,
+ },
+ },
+ }
+ : fallback
+ );
+ };
const buildProduct = (
overrides: Partial< ProductEntityRecord > = {}
): ProductEntityRecord =>
@@ -60,6 +76,21 @@ describe( 'product edit utils', () => {
images: [],
...overrides,
} as unknown as ProductEntityRecord );
+ const buildCostOfGoodsSold = (
+ definedValue: number | string | null = 5
+ ): ProductEntityRecord[ 'cost_of_goods_sold' ] => ( {
+ values: [
+ {
+ defined_value: definedValue,
+ effective_value: definedValue,
+ },
+ ],
+ total_value: definedValue,
+ } );
+
+ beforeEach( () => {
+ mockCostOfGoodsSoldFeatureEnabled( true );
+ } );
it( 'returns the original values for a single selected product', () => {
const product = buildProduct( {
@@ -311,6 +342,7 @@ describe( 'product edit utils', () => {
'schedule_sale',
'date_on_sale_from',
'date_on_sale_to',
+ 'cost_of_goods_sold',
];
const basePriceFieldIds = [ 'regular_price', 'sale_price' ];
const managedStockFieldIds = [ 'manage_stock', 'stock_quantity' ];
@@ -340,6 +372,7 @@ describe( 'product edit utils', () => {
on_sale: true,
sale_price: '12',
date_on_sale_from: '2026-05-06T00:00:00',
+ cost_of_goods_sold: buildCostOfGoodsSold(),
} ),
] );
@@ -352,6 +385,7 @@ describe( 'product edit utils', () => {
'schedule_sale',
'date_on_sale_from',
'date_on_sale_to',
+ 'cost_of_goods_sold',
'images',
'sku',
'stock',
@@ -396,10 +430,41 @@ describe( 'product edit utils', () => {
on_sale: true,
sale_price: '12',
date_on_sale_from: '2026-05-06T00:00:00',
+ cost_of_goods_sold: buildCostOfGoodsSold(),
+ } ),
+ ] );
+
+ expectFieldOrder( fieldIds, [
+ 'regular_price',
+ 'sale_price',
+ 'schedule_sale',
+ 'date_on_sale_from',
+ 'date_on_sale_to',
+ 'cost_of_goods_sold',
+ ] );
+ } );
+
+ it( 'hides cost of goods when the API data is unavailable', () => {
+ const fieldIds = getVisibleFieldIds( [
+ buildProduct( {
+ type: 'simple',
+ } ),
+ ] );
+
+ expectFieldsHidden( fieldIds, [ 'cost_of_goods_sold' ] );
+ } );
+
+ it( 'hides cost of goods when the feature is disabled', () => {
+ mockCostOfGoodsSoldFeatureEnabled( false );
+
+ const fieldIds = getVisibleFieldIds( [
+ buildProduct( {
+ type: 'simple',
+ cost_of_goods_sold: buildCostOfGoodsSold(),
} ),
] );
- expectFieldOrder( fieldIds, [ 'regular_price', 'sale_price' ] );
+ expectFieldsHidden( fieldIds, [ 'cost_of_goods_sold' ] );
} );
it( 'hides shipping fields for virtual simple products', () => {
@@ -655,6 +720,7 @@ describe( 'product edit utils', () => {
on_sale: true,
sale_price: '12',
date_on_sale_from: '2026-05-06T00:00:00',
+ cost_of_goods_sold: buildCostOfGoodsSold(),
} ),
] );
@@ -666,6 +732,7 @@ describe( 'product edit utils', () => {
'schedule_sale',
'date_on_sale_from',
'date_on_sale_to',
+ 'cost_of_goods_sold',
'images',
'sku',
'manage_stock',
@@ -695,6 +762,7 @@ describe( 'product edit utils', () => {
on_sale: true,
sale_price: '12',
date_on_sale_from: '2026-05-06T00:00:00',
+ cost_of_goods_sold: buildCostOfGoodsSold(),
} ),
] );
@@ -707,6 +775,7 @@ describe( 'product edit utils', () => {
'schedule_sale',
'date_on_sale_from',
'date_on_sale_to',
+ 'cost_of_goods_sold',
'stock',
'manage_stock',
'product_status',
@@ -766,6 +835,7 @@ describe( 'product edit utils', () => {
on_sale: true,
sale_price: '12',
date_on_sale_from: '2026-05-06T00:00:00',
+ cost_of_goods_sold: buildCostOfGoodsSold(),
} ),
buildProduct( {
id: 34,
@@ -775,6 +845,7 @@ describe( 'product edit utils', () => {
on_sale: true,
sale_price: '12',
date_on_sale_from: '2026-05-06T00:00:00',
+ cost_of_goods_sold: buildCostOfGoodsSold(),
} ),
] );
@@ -785,6 +856,7 @@ describe( 'product edit utils', () => {
'schedule_sale',
'date_on_sale_from',
'date_on_sale_to',
+ 'cost_of_goods_sold',
...bulkSellableInstanceFieldIds,
] )
);
@@ -990,6 +1062,7 @@ describe( 'product edit utils', () => {
virtual: false,
downloadable: true,
manage_stock: true,
+ cost_of_goods_sold: buildCostOfGoodsSold(),
} );
expect( getFormFields( [ product ] ) ).toEqual( [
@@ -1009,6 +1082,7 @@ describe( 'product edit utils', () => {
'regular_price',
'sale_price',
'schedule_sale',
+ 'cost_of_goods_sold',
],
},
{
@@ -1056,6 +1130,7 @@ describe( 'product edit utils', () => {
const product = buildProduct( {
type: productType,
date_on_sale_from: '2026-05-06T00:00:00',
+ cost_of_goods_sold: buildCostOfGoodsSold(),
} );
const priceGroup = getFormFields( [ product ] ).find(
@@ -1079,6 +1154,7 @@ describe( 'product edit utils', () => {
'date_on_sale_to',
],
},
+ 'cost_of_goods_sold',
],
} );
}
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 8f2d9f2522d..129cff13a88 100644
--- a/packages/js/experimental-products-app/src/product-edit/utils.ts
+++ b/packages/js/experimental-products-app/src/product-edit/utils.ts
@@ -3,6 +3,7 @@
*/
import type { Field, FormField } from '@wordpress/dataviews';
import { __ } from '@wordpress/i18n';
+import { getSetting } from '@woocommerce/settings';
/**
* Internal dependencies
@@ -31,6 +32,12 @@ type ProductType = 'simple' | 'variation' | 'variable' | 'grouped' | 'external';
type ProductVariationEntityRecord = ProductEntityRecord & {
parent_id: number;
};
+type Feature = {
+ is_enabled?: boolean;
+};
+type AdminSettings = {
+ features?: Record< string, Feature >;
+};
const PRODUCT_EDIT_FIELD_IDS = [
'name',
@@ -47,6 +54,7 @@ const PRODUCT_EDIT_FIELD_IDS = [
'schedule_sale',
'date_on_sale_from',
'date_on_sale_to',
+ 'cost_of_goods_sold',
'price_summary',
'stock',
'stock_quantity',
@@ -124,6 +132,7 @@ const SIMPLE_PRODUCT_EDIT_FORM_FIELDS = [
layout: { type: 'row' as const },
children: [ 'date_on_sale_from', 'date_on_sale_to' ],
},
+ 'cost_of_goods_sold',
] ),
createProductEditFormGroup( 'image-fields', __( 'Images', 'woocommerce' ), [
'images',
@@ -161,6 +170,7 @@ const VARIATION_PRODUCT_EDIT_FORM_FIELDS = [
layout: { type: 'row' as const },
children: [ 'date_on_sale_from', 'date_on_sale_to' ],
},
+ 'cost_of_goods_sold',
] ),
createProductEditFormGroup( 'image-fields', __( 'Images', 'woocommerce' ), [
'images',
@@ -293,11 +303,17 @@ const SELLABLE_PRODUCT_EDIT_FIELD_ID_SET = new Set< ProductEditFieldId >( [
'schedule_sale',
'date_on_sale_from',
'date_on_sale_to',
+ 'cost_of_goods_sold',
] );
const BULK_UNSUPPORTED_PRODUCT_EDIT_FIELD_ID_SET =
new Set< ProductEditFieldId >( [ 'sku' ] );
+function isCostOfGoodsSoldFeatureEnabled() {
+ const adminSettings = getSetting< AdminSettings >( 'admin', {} );
+ return Boolean( adminSettings.features?.cost_of_goods_sold?.is_enabled );
+}
+
function normalizeValue( value: unknown ) {
if ( value === undefined ) {
return '__undefined__';
@@ -560,6 +576,13 @@ export function getVisibleProductEditFields(
return visibleFields;
}
+ if (
+ field.id === 'cost_of_goods_sold' &&
+ ! isCostOfGoodsSoldFeatureEnabled()
+ ) {
+ return visibleFields;
+ }
+
if (
isBulkEdit &&
BULK_UNSUPPORTED_PRODUCT_EDIT_FIELD_ID_SET.has(