Commit 12bb887a854 for woocommerce
commit 12bb887a854c600725ed79ac15bc494cd7d58a51
Author: Luigi Teschio <gigitux@gmail.com>
Date: Thu May 21 14:38:34 2026 +0200
Fix products app dimension quick edit fields (#65231)
* Fix product shipping fields setting reads
* Add changelog entry for product shipping settings fix
* Fix dimension quick edit empty values
* Add changelog entry for dimension quick edit fix
* Fix mixed shipping quick edit placeholders
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* fix logic
---------
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
diff --git a/packages/js/experimental-products-app/changelog/fix-products-dimension-quick-edit b/packages/js/experimental-products-app/changelog/fix-products-dimension-quick-edit
new file mode 100644
index 00000000000..95e89414594
--- /dev/null
+++ b/packages/js/experimental-products-app/changelog/fix-products-dimension-quick-edit
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix dimension inputs in the experimental products app quick edit when product dimensions are empty or mixed.
diff --git a/packages/js/experimental-products-app/src/fields/components/dimension.test.tsx b/packages/js/experimental-products-app/src/fields/components/dimension.test.tsx
new file mode 100644
index 00000000000..4fb4f350a9d
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/components/dimension.test.tsx
@@ -0,0 +1,166 @@
+/**
+ * External dependencies
+ */
+import { fireEvent, render, screen } from '@testing-library/react';
+import type { DataFormControlProps } from '@wordpress/dataviews';
+import React from 'react';
+
+jest.mock( '@wordpress/core-data', () => ( {
+ useEntityRecord: jest.fn( () => ( {
+ record: {
+ values: {
+ woocommerce_dimension_unit: 'cm',
+ },
+ },
+ isResolving: false,
+ } ) ),
+} ) );
+
+jest.mock( '@wordpress/ui', () => {
+ const ReactActual = jest.requireActual( 'react' );
+
+ return {
+ InputControl: ( {
+ label,
+ onChange,
+ placeholder,
+ suffix,
+ value,
+ }: {
+ label: string;
+ onChange: React.ChangeEventHandler< HTMLInputElement >;
+ placeholder?: string;
+ suffix?: React.ReactNode;
+ value?: string;
+ } ) =>
+ ReactActual.createElement(
+ 'label',
+ null,
+ label,
+ ReactActual.createElement( 'input', {
+ 'aria-label': label,
+ onChange,
+ placeholder,
+ value: value ?? '',
+ } ),
+ suffix
+ ),
+ InputLayout: {
+ Slot: ( { children }: { children?: React.ReactNode } ) =>
+ ReactActual.createElement( 'span', null, children ),
+ },
+ };
+} );
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+import { createDimensionField } from './dimension';
+
+describe( 'dimension field', () => {
+ const renderEdit = (
+ data: ProductEntityRecord,
+ onChange = jest.fn(),
+ placeholder?: string
+ ) => {
+ const fieldExtensions = createDimensionField( 'length' );
+
+ if ( ! fieldExtensions.Edit ) {
+ throw new Error( 'dimension edit not implemented' );
+ }
+
+ const Edit = fieldExtensions.Edit as React.ComponentType<
+ DataFormControlProps< ProductEntityRecord >
+ >;
+
+ const view = render(
+ <Edit
+ data={ data }
+ field={
+ {
+ ...fieldExtensions,
+ id: 'length',
+ label: 'Length',
+ placeholder,
+ } as DataFormControlProps< ProductEntityRecord >[ 'field' ]
+ }
+ onChange={ onChange }
+ />
+ );
+
+ return {
+ ...view,
+ onChange,
+ };
+ };
+
+ it( 'renders an empty input when dimensions are missing', () => {
+ const { onChange } = renderEdit( {
+ id: 12,
+ name: 'Beanie',
+ } as ProductEntityRecord );
+ const input = screen.getByLabelText( 'Length' );
+
+ expect( input ).toHaveValue( '' );
+
+ fireEvent.change( input, {
+ target: {
+ value: '12',
+ },
+ } );
+
+ expect( onChange ).toHaveBeenCalledWith( {
+ dimensions: {
+ length: '12',
+ },
+ } );
+ } );
+
+ it( 'emits only the edited dimension', () => {
+ const { onChange } = renderEdit( {
+ id: 12,
+ name: 'Beanie',
+ dimensions: {
+ length: '12',
+ width: '',
+ height: '',
+ },
+ } as ProductEntityRecord );
+ const input = screen.getByLabelText( 'Length' );
+
+ fireEvent.change( input, {
+ target: {
+ value: '15',
+ },
+ } );
+
+ expect( onChange ).toHaveBeenCalledWith( {
+ dimensions: {
+ length: '15',
+ },
+ } );
+ } );
+
+ it( 'renders the mixed placeholder when provided', () => {
+ renderEdit(
+ {
+ id: 12,
+ name: 'Beanie',
+ dimensions: {
+ length: '',
+ width: '',
+ height: '',
+ },
+ } as ProductEntityRecord,
+ jest.fn(),
+ 'Mixed'
+ );
+
+ expect( screen.getByLabelText( 'Length' ) ).toHaveAttribute(
+ 'placeholder',
+ 'Mixed'
+ );
+ } );
+} );
diff --git a/packages/js/experimental-products-app/src/fields/components/dimension.tsx b/packages/js/experimental-products-app/src/fields/components/dimension.tsx
index abeb39add05..0ea8d7507d3 100644
--- a/packages/js/experimental-products-app/src/fields/components/dimension.tsx
+++ b/packages/js/experimental-products-app/src/fields/components/dimension.tsx
@@ -29,6 +29,8 @@ export const createDimensionField = (
return {
isVisible: isDimensionVisible,
Edit: ( { data, onChange, field } ) => {
+ const dimensions: Partial< ProductEntityRecord[ 'dimensions' ] > =
+ data.dimensions ?? {};
const {
record: storeProductsSettings,
isResolving: storeProductsSettingsResolving,
@@ -48,13 +50,13 @@ export const createDimensionField = (
return (
<InputControl
label={ field.label }
- value={ data.dimensions[ key ] }
+ placeholder={ field.placeholder }
+ value={ dimensions[ key ] ?? '' }
onChange={ ( event ) => {
onChange( {
dimensions: {
- ...data.dimensions,
[ key ]: event.target.value,
- },
+ } as ProductEntityRecord[ 'dimensions' ],
} );
} }
type="number"
diff --git a/packages/js/experimental-products-app/src/fields/weight/field.tsx b/packages/js/experimental-products-app/src/fields/weight/field.tsx
index 5343de54179..b63c88a5a10 100644
--- a/packages/js/experimental-products-app/src/fields/weight/field.tsx
+++ b/packages/js/experimental-products-app/src/fields/weight/field.tsx
@@ -45,6 +45,7 @@ export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
return (
<InputControl
label={ field.label }
+ placeholder={ field.placeholder }
value={ data.weight }
onChange={ ( event ) =>
onChange( { weight: event.target.value } )
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 725fff49cb1..d99835f79e9 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
@@ -14,6 +14,7 @@ import { validateSalePrice } from '../fields/sale_price/validation';
import { buildMergedProductEditData } from './utils';
type ProductField = Field< ProductEntityRecord >;
+type DimensionFieldId = keyof ProductEntityRecord[ 'dimensions' ];
export type ProductBulkEditFieldState = {
isEmpty: boolean;
@@ -71,6 +72,12 @@ const FIELD_DATA_KEYS: Record< string, string > = {
stock: 'stock_status',
};
+const DIMENSION_FIELD_ID_SET = new Set< DimensionFieldId >( [
+ 'length',
+ 'width',
+ 'height',
+] );
+
export function getBulkNumericOperationFieldId( fieldId: BulkNumericFieldId ) {
return `${ fieldId }${ BULK_NUMERIC_OPERATION_FIELD_SUFFIX }`;
}
@@ -95,6 +102,10 @@ function getDefinedCostValue( product: ProductEntityRecord ) {
return product.cost_of_goods_sold?.values?.[ 0 ]?.defined_value;
}
+function isDimensionFieldId( fieldId: string ): fieldId is DimensionFieldId {
+ return DIMENSION_FIELD_ID_SET.has( fieldId as DimensionFieldId );
+}
+
function getProductFieldValue(
product: ProductEntityRecord,
field: ProductField
@@ -103,6 +114,10 @@ function getProductFieldValue(
return getDefinedCostValue( product );
}
+ if ( isDimensionFieldId( field.id ) ) {
+ return product.dimensions?.[ field.id ] ?? '';
+ }
+
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 87cb5bbabe4..d765ef2b834 100644
--- a/packages/js/experimental-products-app/src/product-edit/index.tsx
+++ b/packages/js/experimental-products-app/src/product-edit/index.tsx
@@ -128,6 +128,35 @@ function getCostOfGoodsSoldDataWithValue(
};
}
+function isDimensionChanges(
+ value: unknown
+): value is Partial< ProductEntityRecord[ 'dimensions' ] > {
+ return (
+ Boolean( value ) &&
+ typeof value === 'object' &&
+ ! Array.isArray( value )
+ );
+}
+
+function mergeProductChangesForProduct(
+ product: ProductEntityRecord,
+ changes: Partial< ProductEntityRecord >
+): Partial< ProductEntityRecord > {
+ const { dimensions, ...remainingChanges } = changes;
+
+ if ( ! isDimensionChanges( dimensions ) ) {
+ return changes;
+ }
+
+ return {
+ ...remainingChanges,
+ dimensions: {
+ ...product.dimensions,
+ ...dimensions,
+ } as ProductEntityRecord[ 'dimensions' ],
+ };
+}
+
function getBulkEditFormData(
mergedData: ProductEntityRecord,
bulkEditData: ProductBulkEditFormData,
@@ -138,6 +167,15 @@ function getBulkEditFormData(
...bulkEditData,
};
+ if ( isDimensionChanges( bulkEditData.dimensions ) ) {
+ data.dimensions = {
+ ...( isDimensionChanges( mergedData.dimensions )
+ ? mergedData.dimensions
+ : {} ),
+ ...bulkEditData.dimensions,
+ } as ProductEntityRecord[ 'dimensions' ];
+ }
+
Object.keys( fieldStates ).forEach( ( fieldId ) => {
if ( fieldId === 'stock' && fieldStates[ fieldId ].isMixed ) {
( data as Record< string, unknown > ).stock_status = '';
@@ -625,7 +663,9 @@ export default function ProductEdit( { products, isOpen }: ProductEditProps ) {
const onChange = useCallback(
( changes: Partial< ProductEntityRecord > ) => {
if ( selectedProducts.length <= 1 ) {
- applySelectedProductChanges( () => changes );
+ applySelectedProductChanges( ( product ) =>
+ mergeProductChangesForProduct( product, changes )
+ );
return;
}
@@ -641,6 +681,10 @@ export default function ProductEdit( { products, isOpen }: ProductEditProps ) {
return;
}
+ if ( fieldId === 'dimensions' && isDimensionChanges( value ) ) {
+ bulkChanges[ fieldId ] = value;
+ }
+
productChanges[ fieldId as keyof ProductEntityRecord ] =
value as never;
} );
@@ -649,11 +693,25 @@ export default function ProductEdit( { products, isOpen }: ProductEditProps ) {
setBulkEditData( ( currentData ) => ( {
...currentData,
...bulkChanges,
+ ...( isDimensionChanges( bulkChanges.dimensions )
+ ? {
+ dimensions: {
+ ...( isDimensionChanges(
+ currentData.dimensions
+ )
+ ? currentData.dimensions
+ : {} ),
+ ...bulkChanges.dimensions,
+ } as ProductEntityRecord[ 'dimensions' ],
+ }
+ : {} ),
} ) );
}
if ( Object.keys( productChanges ).length > 0 ) {
- applySelectedProductChanges( () => productChanges );
+ applySelectedProductChanges( ( product ) =>
+ mergeProductChangesForProduct( product, productChanges )
+ );
}
},
[ applySelectedProductChanges, selectedProducts.length ]
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 cd70129e0ab..fa213bb1eca 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
@@ -157,17 +157,72 @@ describe( 'product edit utils', () => {
);
} );
+ it( 'merges bulk dimensions per dimension field', () => {
+ const products = [
+ buildProduct( {
+ id: 1,
+ dimensions: {
+ length: '12',
+ width: '4',
+ height: '3',
+ },
+ } ),
+ buildProduct( {
+ id: 2,
+ dimensions: {
+ length: '12',
+ width: '7',
+ height: '3',
+ },
+ } ),
+ ];
+ const bulkData = buildProductBulkEditData(
+ products,
+ getProductEditFields( productFields )
+ );
+
+ expect( buildMergedProductEditData( products ) ).toEqual(
+ expect.objectContaining( {
+ dimensions: {
+ length: '12',
+ width: '',
+ height: '3',
+ },
+ } )
+ );
+ expect( bulkData.fieldStates.length ).toEqual( {
+ isEmpty: false,
+ isMixed: false,
+ placeholder: undefined,
+ value: '12',
+ } );
+ expect( bulkData.fieldStates.width ).toEqual( {
+ isEmpty: false,
+ isMixed: true,
+ placeholder: 'Mixed',
+ value: undefined,
+ } );
+ expect( bulkData.fieldStates.height ).toEqual( {
+ isEmpty: false,
+ isMixed: false,
+ placeholder: undefined,
+ value: '3',
+ } );
+ } );
+
it( 'returns bulk field state for mixed values', () => {
const products = [
buildProduct( {
id: 1,
name: 'Beanie',
status: 'publish',
+ weight: '1',
} ),
buildProduct( {
id: 2,
name: 'Hoodie',
status: 'draft',
+ weight: '2',
} ),
];
@@ -189,6 +244,12 @@ describe( 'product edit utils', () => {
placeholder: 'Mixed',
value: undefined,
} );
+ expect( bulkData.fieldStates.weight ).toEqual( {
+ isEmpty: false,
+ isMixed: true,
+ placeholder: 'Mixed',
+ value: undefined,
+ } );
} );
it( 'returns a mixed bulk field state for different grouped products', () => {
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 404db201bc4..419e8872ab2 100644
--- a/packages/js/experimental-products-app/src/product-edit/utils.ts
+++ b/packages/js/experimental-products-app/src/product-edit/utils.ts
@@ -32,6 +32,7 @@ type ProductType = 'simple' | 'variation' | 'variable' | 'grouped' | 'external';
type ProductVariationEntityRecord = ProductEntityRecord & {
parent_id: number;
};
+type DimensionKey = keyof ProductEntityRecord[ 'dimensions' ];
type Feature = {
is_enabled?: boolean;
};
@@ -85,6 +86,8 @@ const PRODUCT_EDIT_FIELD_IDS = [
'linked_products_count',
] as const;
+const DIMENSION_KEYS: DimensionKey[] = [ 'length', 'width', 'height' ];
+
const DIMENSIONS_FORM_FIELD: ProductEditFormField = {
id: 'dimensions',
layout: { type: 'row' as const },
@@ -317,6 +320,48 @@ function getMixedValueFallback( sample: unknown ) {
return undefined;
}
+function isRecord( value: unknown ): value is Record< string, unknown > {
+ return (
+ Boolean( value ) &&
+ typeof value === 'object' &&
+ ! Array.isArray( value )
+ );
+}
+
+function buildMergedDimensions(
+ values: unknown[]
+): ProductEntityRecord[ 'dimensions' ] | undefined {
+ const hasDimensionValue = values.some( isRecord );
+
+ if ( ! hasDimensionValue ) {
+ return undefined;
+ }
+
+ const dimensions: Partial< ProductEntityRecord[ 'dimensions' ] > = {};
+
+ DIMENSION_KEYS.forEach( ( dimensionKey ) => {
+ const dimensionValues = values.map( ( value ) =>
+ isRecord( value ) ? value[ dimensionKey ] : undefined
+ );
+ const firstDefinedValue = dimensionValues.find(
+ ( value ) => value !== undefined
+ );
+ const areValuesEqual = dimensionValues.every(
+ ( value ) =>
+ normalizeValue( value ) ===
+ normalizeValue( dimensionValues[ 0 ] )
+ );
+
+ dimensions[ dimensionKey ] = (
+ areValuesEqual
+ ? dimensionValues[ 0 ]
+ : getMixedValueFallback( firstDefinedValue )
+ ) as string | undefined;
+ } );
+
+ return dimensions as ProductEntityRecord[ 'dimensions' ];
+}
+
function isVariableProductParent( product: ProductEntityRecord ) {
return product.type === 'variable' && ! product.parent_id;
}
@@ -520,6 +565,12 @@ export function buildMergedProductEditData(
const values = products.map(
( product ) => product[ key as keyof ProductEntityRecord ]
);
+
+ if ( key === 'dimensions' ) {
+ mergedData[ key ] = buildMergedDimensions( values );
+ return;
+ }
+
const firstDefinedValue = values.find(
( value ) => value !== undefined
);