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
 		);