Commit 6a724070324 for woocommerce

commit 6a724070324b28dba4016b9a4c10becedd292d3f
Author: Luigi Teschio <gigitux@gmail.com>
Date:   Wed May 20 13:16:06 2026 +0200

    Prototype bulk product edit UI (#65143)

    * Prototype bulk product edit UI

    * Add changefile(s) from automation for the following project(s): @woocommerce/experimental-products-app

    * Move bulk edit helpers out of product edit utils

    * fix currency-input and % unit

    * fix bugs

    * add documentation bulk editing

    * fix ts error

    * Align chip-select labels with the rest of the form (uppercase 11px)

    * Use 8px gap between chip-select label and field

    ---------

    Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
    Co-authored-by: veronicafasulo <98944206+verofasulo@users.noreply.github.com>

diff --git a/packages/js/experimental-products-app/README.md b/packages/js/experimental-products-app/README.md
index b29b54e71ca..5efe3dfc697 100644
--- a/packages/js/experimental-products-app/README.md
+++ b/packages/js/experimental-products-app/README.md
@@ -9,7 +9,7 @@ Current areas of exploration:
 -   A more flexible table-based product view
 -   Better filtering, sorting, and scanning
 -   Inline handling of product variations
--   Faster quick edit and bulk edit flows
+-   Faster quick edit and [bulk edit flows](docs/bulk-editing.md)
 -   A clearer extension surface for integrations

 ## Try It Quickly
diff --git a/packages/js/experimental-products-app/changelog/65143-try-product-bulk-edit-ui-ux b/packages/js/experimental-products-app/changelog/65143-try-product-bulk-edit-ui-ux
new file mode 100644
index 00000000000..37098ddcf83
--- /dev/null
+++ b/packages/js/experimental-products-app/changelog/65143-try-product-bulk-edit-ui-ux
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Prototype bulk product editing controls in the experimental products app.
\ No newline at end of file
diff --git a/packages/js/experimental-products-app/docs/bulk-editing.md b/packages/js/experimental-products-app/docs/bulk-editing.md
new file mode 100644
index 00000000000..0903172956c
--- /dev/null
+++ b/packages/js/experimental-products-app/docs/bulk-editing.md
@@ -0,0 +1,177 @@
+# Bulk Editing
+
+Bulk editing in the experimental products app is built on top of the quick edit drawer. The same `ProductEdit` surface handles both single-product quick edits and multi-product bulk edits. When more than one product ID is selected, the form is made bulk-aware by merging product data, showing mixed field state, filtering fields to the shared editable set, and applying changes to every selected product.
+
+## Entry Point
+
+The DataViews quick edit action supports bulk selection. When it runs, it writes the selected IDs and the drawer state into the URL:
+
+```text
+/products?postId=12,34&quickEdit=true
+```
+
+The relevant pieces are:
+
+- `quickEditAction()` in `src/dataviews-actions/actions.tsx`, which sets `postId` and `quickEdit=true`.
+- `useLayoutAreas()` in `src/router.tsx`, which opens `ProductEdit` when `quickEdit` is true.
+- `ProductEdit` in `src/product-edit/index.tsx`, which reads `postId`, resolves the selected products, and renders the drawer.
+
+## Product Resolution
+
+`ProductEdit` resolves selected products from two places:
+
+- The current product list records, including embedded variations.
+- The WordPress core data store, when a selected product is not already in the list or has unsaved edits.
+
+For variations, the drawer keeps editing tied to the parent product record. A selected variation is read from the parent product's `_embedded.variations` when an edited parent record exists. This keeps variation edits in one place before save.
+
+## Field Selection
+
+Bulk edit uses the normal product field registry, then narrows it to fields that can safely apply to every selected product.
+
+`getProductEditFields()` removes fields that are display-only summaries or counts, such as `price_summary`, `inventory_summary`, and `images_count`.
+
+`getVisibleProductEditFields()` then applies the bulk rules:
+
+- Only fields supported by every selected product type are shown.
+- Fields hidden by per-field `isVisible()` logic are shown only when every selected product passes that visibility check.
+- `sku` is hidden during bulk editing because each product needs a unique value.
+- Parent-owned fields, such as `name`, `categories`, `tags`, and `catalog_visibility`, are hidden when the selection includes variations.
+- Sellable fields, such as prices and sale dates, are hidden when the selection includes a variable parent product.
+- Cost of goods is shown only when the feature is enabled and the selected products expose the data.
+
+The final field list is pruned into the product-type form layout by `getProductTypeFormFields()`.
+
+## Merged Form Data
+
+The form needs one data object even when many products are selected. `buildProductBulkEditData()` creates that object and also records per-field state.
+
+It starts with `buildMergedProductEditData()`:
+
+- Shared values are preserved. If every selected product has `status: "publish"`, the form gets `status: "publish"`.
+- Mixed string values fall back to an empty string.
+- Mixed arrays fall back to an empty array.
+- Mixed `null` values stay `null`.
+- Other mixed values fall back to `undefined`.
+
+Then `buildProductBulkEditData()` adds `fieldStates` for each visible field:
+
+- `isMixed` is true when selected products have different values.
+- `isEmpty` is true when all selected products share an empty value.
+- `value` contains the shared value when there is one.
+- `placeholder` is `Mixed` when the field has mixed values.
+
+This lets the UI show the current shared value when one exists, or a neutral mixed state when the selected products differ.
+
+## Bulk-Aware Controls
+
+`getBulkEnhancedProductEditFields()` adjusts field definitions only when more than one product is selected.
+
+For regular fields:
+
+- Mixed placeholders are passed through to the field.
+- The product name field is no longer required, so a mixed or empty name does not block bulk editing.
+- Mixed boolean fields use an indeterminate checkbox. Selecting it applies the chosen boolean value to every selected product.
+
+For numeric bulk fields:
+
+- The field is split into an operation control and a value control.
+- The value control is disabled while the operation is `dont_change`.
+- The value placeholder shows either `Mixed` or the shared existing value.
+
+`injectBulkNumericOperationFormFields()` wraps those paired controls in a row so each numeric field is presented as:
+
+```text
+Operation | Value
+```
+
+The operation control is created by `createBulkNumericOperationField()` in `src/product-edit/bulk-numeric-control.tsx`.
+
+## Change Handling
+
+Single-product edits are simple: `onChange()` immediately calls `editEntityRecord()` with the field changes.
+
+Bulk edits split changes into two groups:
+
+- Non-numeric changes are applied immediately to every selected product.
+- Numeric bulk operation changes are stored in local `bulkEditData` until save.
+
+The numeric fields are deferred because operations like "increase by 10%" depend on each product's original value. Applying the same raw form value immediately would lose that per-product calculation.
+
+`applySelectedProductChanges()` handles applying changes to the selected records:
+
+- Product changes call `editEntityRecord( 'root', 'product', product.id, changes )`.
+- Variation changes update the parent product's `_embedded.variations`.
+- Multiple variation edits for the same parent are grouped before the parent record is edited.
+
+## Numeric Operations
+
+The numeric bulk fields are:
+
+- `regular_price`
+- `sale_price`
+- `cost_of_goods_sold`
+- `stock_quantity`
+
+All numeric fields support:
+
+- `dont_change`
+- `set`
+- `increase`
+- `decrease`
+
+Money fields also support:
+
+- `increase_percent`
+- `decrease_percent`
+
+`stock_quantity` does not support percentage operations.
+
+On save, `getBulkNumericChangesForProduct()` calculates the final value for each selected product:
+
+- `set` uses the entered value.
+- `increase` and `decrease` add or subtract the entered amount from the product's current value.
+- Percentage operations apply the percentage to the product's current value.
+- Values are clamped to zero or higher.
+- Money values are rounded and formatted using the store currency precision.
+- Stock quantity is rounded to an integer.
+- Cost of goods updates the nested `cost_of_goods_sold.values[0].defined_value`.
+
+Before applying numeric edits, `validateBulkNumericEdits()` projects the calculated changes onto each selected product and validates prices. This catches cases such as a sale price becoming greater than or equal to the regular price.
+
+## Save Flow
+
+When the user clicks Save:
+
+1. Invalid numeric input blocks the save with a snackbar notice.
+2. Pending numeric operations are validated against every selected product.
+3. Valid numeric operations are converted into per-product edits and applied to the core data store.
+4. `saveSelectedProducts()` persists the selected records.
+5. The drawer shows a success or error notice based on how many products saved.
+6. On full success, the drawer closes and clears the quick edit URL state.
+
+`saveSelectedProducts()` treats products and variations differently:
+
+- Regular products are saved through `saveEditedEntityRecord( 'root', 'product', productId )`.
+- Variations are saved sequentially with `PUT /wc/v3/products/{parentId}/variations/{variationId}`.
+- After a variation saves, it is merged back into the parent product's embedded variations.
+- Parent products that own saved variations are saved after their variations.
+
+Variations are saved sequentially because each saved variation is merged into the current parent snapshot. Saving them concurrently could merge against stale parent data and overwrite another variation's update.
+
+## Cancel And Close
+
+Closing the drawer clears unsaved core data edits for the selected products or their parent products, resets local `bulkEditData`, removes `quickEdit` from the URL, and navigates back to the product list route.
+
+## Key Files
+
+| File | Role |
+| --- | --- |
+| `src/dataviews-actions/actions.tsx` | Opens quick edit or bulk edit by writing selected IDs to the URL. |
+| `src/router.tsx` | Mounts `ProductEdit` and controls whether the drawer is open. |
+| `src/product-edit/index.tsx` | Owns the drawer, form data, change handling, validation, notices, and save trigger. |
+| `src/product-edit/utils.ts` | Defines editable fields, product-type form layouts, field visibility rules, variation helpers, and merged data. |
+| `src/product-edit/bulk-edit.ts` | Builds bulk field state and calculates numeric bulk edits. |
+| `src/product-edit/bulk-numeric-control.tsx` | Defines the numeric operation select control. |
+| `src/product-edit/save.ts` | Persists products and variations. |
+| `src/product-edit/utils.test.ts` | Covers merged data, field visibility, variation helpers, and numeric bulk edit calculations. |
diff --git a/packages/js/experimental-products-app/src/fields/catalog_visibility/field.tsx b/packages/js/experimental-products-app/src/fields/catalog_visibility/field.tsx
index 4f9fe9c2f76..42abe82bb3b 100644
--- a/packages/js/experimental-products-app/src/fields/catalog_visibility/field.tsx
+++ b/packages/js/experimental-products-app/src/fields/catalog_visibility/field.tsx
@@ -43,14 +43,19 @@ export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
 	...fieldDefinition,
 	Edit: ( { data, onChange, field } ) => {
 		const options = field.elements ?? [];
-		const selectedOption = options.find(
-			( option ) =>
-				option.value === ( data.catalog_visibility ?? 'visible' )
-		);
+		const selectedOption =
+			field.placeholder && ! data.catalog_visibility
+				? undefined
+				: options.find(
+						( option ) =>
+							option.value ===
+							( data.catalog_visibility ?? 'visible' )
+				  );

 		return (
 			<SelectControl
 				label={ field.label }
+				placeholder={ field.placeholder }
 				value={ selectedOption }
 				items={ options }
 				onValueChange={ ( option ) => {
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 f35b8ae508c..fafab7c58ba 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
@@ -18,6 +18,8 @@ import { unlock } from '../../lock-unlock';
 import type { ProductEntityRecord } from '../types';

 import { getCurrencyObject } from '../utils/currency';
+import type { ProductBulkEditFormData } from '../../product-edit/bulk-edit';
+import { isBulkNumericPercentEdit } from '../../product-edit/bulk-edit';

 const { ValidatedInputControl } = unlock( privateApis );

@@ -37,6 +39,10 @@ type CurrencyControlProps = {
 	customValidity?: NonNullable<
 		DataFormControlProps< ProductEntityRecord >[ 'validity' ]
 	>[ 'custom' ];
+	disabled?: boolean;
+	placeholder?: string;
+	hideLabelFromVision?: boolean;
+	showPercentAdornment?: boolean;
 };

 export function CurrencyControl( {
@@ -45,32 +51,41 @@ export function CurrencyControl( {
 	value,
 	onChange,
 	customValidity,
+	disabled = false,
+	placeholder,
+	hideLabelFromVision,
+	showPercentAdornment = false,
 }: CurrencyControlProps ) {
+	const prefix =
+		! showPercentAdornment && isCurrencyLeft ? (
+			<InputControlPrefixWrapper>{ symbol }</InputControlPrefixWrapper>
+		) : undefined;
+	let suffix;
+
+	if ( showPercentAdornment ) {
+		suffix = <InputControlSuffixWrapper>%</InputControlSuffixWrapper>;
+	} else if ( ! isCurrencyLeft ) {
+		suffix = (
+			<InputControlSuffixWrapper>{ symbol }</InputControlSuffixWrapper>
+		);
+	}
+
 	return (
 		// eslint-disable-next-line @typescript-eslint/no-unsafe-call -- ValidatedInputControl is a private API
 		<ValidatedInputControl
 			id={ id }
 			label={ label }
+			hideLabelFromVision={ hideLabelFromVision }
 			value={ value }
 			onChange={ onChange }
+			placeholder={ placeholder }
 			type="number"
 			min={ 0 }
 			step={ step }
 			customValidity={ customValidity }
-			prefix={
-				isCurrencyLeft ? (
-					<InputControlPrefixWrapper>
-						{ symbol }
-					</InputControlPrefixWrapper>
-				) : undefined
-			}
-			suffix={
-				! isCurrencyLeft ? (
-					<InputControlSuffixWrapper>
-						{ symbol }
-					</InputControlSuffixWrapper>
-				) : undefined
-			}
+			disabled={ disabled }
+			prefix={ prefix }
+			suffix={ suffix }
 		/>
 	);
 }
@@ -79,29 +94,39 @@ export function CurrencyControl( {
  * 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.
+ * @param root0                     Props from DataForm.
+ * @param root0.data                Current product entity record.
+ * @param root0.field               Normalized field definition.
+ * @param root0.hideLabelFromVision Whether to visually hide the control label.
+ * @param root0.onChange            Callback to update entity values.
+ * @param root0.validity            Per-rule validation state from useFormValidity.
  */
 export function CurrencyInput( {
 	data,
 	field,
+	hideLabelFromVision,
 	onChange,
 	validity,
 }: DataFormControlProps< ProductEntityRecord > ) {
 	const fieldId = field.id as CurrencyField;
+	const disabled = field.isDisabled( { item: data, field } );

 	return (
 		<CurrencyControl
 			id={ `currency-input-${ fieldId }` }
 			label={ field.label }
+			hideLabelFromVision={ hideLabelFromVision }
 			value={ data[ fieldId ] ?? '' }
+			placeholder={ field.placeholder }
 			onChange={ ( newValue: string ) => {
 				onChange( { [ fieldId ]: newValue } );
 			} }
 			customValidity={ validity?.custom }
+			disabled={ disabled }
+			showPercentAdornment={ isBulkNumericPercentEdit(
+				data as ProductBulkEditFormData,
+				fieldId
+			) }
 		/>
 	);
 }
diff --git a/packages/js/experimental-products-app/src/fields/components/searchable-chip-select/searchable-chip-select.tsx b/packages/js/experimental-products-app/src/fields/components/searchable-chip-select/searchable-chip-select.tsx
index 181622df584..be1ea5e2b71 100644
--- a/packages/js/experimental-products-app/src/fields/components/searchable-chip-select/searchable-chip-select.tsx
+++ b/packages/js/experimental-products-app/src/fields/components/searchable-chip-select/searchable-chip-select.tsx
@@ -40,6 +40,7 @@ export const SearchableChipSelect = forwardRef<
 		emptyContent = __( 'No results found.', 'woocommerce' ),
 		items = DEFAULT_ITEMS,
 		chipsContent,
+		placeholderChip,
 		searchPlaceholder = __( 'Search', 'woocommerce' ),
 		showClearButton = true,
 		clearButtonLabel = __( 'Clear all', 'woocommerce' ),
@@ -78,11 +79,15 @@ export const SearchableChipSelect = forwardRef<
 				}
 			>
 				<BaseCombobox.Value>
-					{ ( value: Item[] ) =>
-						value.length > 0 ? (
+					{ ( value: Item[] ) => {
+						const hasValue = value.length > 0;
+						const showPlaceholderChip =
+							! hasValue && Boolean( placeholderChip );
+
+						return hasValue || showPlaceholderChip ? (
 							<div className="woocommerce-searchable-chip-select__chips-edit-area">
 								<div className="woocommerce-searchable-chip-select__chips-list">
-									{ chipsContent
+									{ hasValue && chipsContent
 										? chipsContent( value )
 										: value.map( ( item ) => (
 												<BaseCombobox.Chip
@@ -105,8 +110,15 @@ export const SearchableChipSelect = forwardRef<
 													</BaseCombobox.ChipRemove>
 												</BaseCombobox.Chip>
 										  ) ) }
+									{ showPlaceholderChip && (
+										<span className="woocommerce-searchable-chip-select__chip woocommerce-searchable-chip-select__chip--is-placeholder">
+											<span className="woocommerce-searchable-chip-select__chip-content">
+												{ placeholderChip }
+											</span>
+										</span>
+									) }
 								</div>
-								{ showClearButton && (
+								{ hasValue && showClearButton && (
 									<BaseCombobox.Clear
 										className="woocommerce-searchable-chip-select__clear"
 										aria-label={ clearButtonLabel }
@@ -115,8 +127,8 @@ export const SearchableChipSelect = forwardRef<
 									</BaseCombobox.Clear>
 								) }
 							</div>
-						) : null
-					}
+						) : null;
+					} }
 				</BaseCombobox.Value>

 				<BaseCombobox.Input
diff --git a/packages/js/experimental-products-app/src/fields/components/searchable-chip-select/style.scss b/packages/js/experimental-products-app/src/fields/components/searchable-chip-select/style.scss
index 7b8be7b7b6e..e0ea16157d5 100644
--- a/packages/js/experimental-products-app/src/fields/components/searchable-chip-select/style.scss
+++ b/packages/js/experimental-products-app/src/fields/components/searchable-chip-select/style.scss
@@ -48,6 +48,11 @@
 		color: #1e1e1e;
 	}

+	&__chip--is-placeholder {
+		background: #fff;
+		color: #757575;
+	}
+
 	&__chip-prefix {
 		display: inline-flex;
 		align-items: center;
@@ -151,8 +156,15 @@
 	}

 	&__label {
-		margin-bottom: 4px;
-		font-weight: 500;
+		margin-bottom: 8px;
+		// Match the uppercase 11px treatment used by `@wordpress/ui`'s
+		// Field.Label primitive so the chip-select's label aligns with the
+		// rest of the DataForm fields (Name, Status, Catalog visibility, …).
+		font-size: var(--wpds-typography-font-size-xs, 11px);
+		font-weight: var(--wpds-typography-font-weight-medium, 499);
+		line-height: var(--wpds-typography-line-height-xs, 16px);
+		text-transform: uppercase;
+		color: var(--wpds-color-fg-content-neutral, #1e1e1e);
 	}

 	&__description {
diff --git a/packages/js/experimental-products-app/src/fields/components/searchable-chip-select/test/index.test.tsx b/packages/js/experimental-products-app/src/fields/components/searchable-chip-select/test/index.test.tsx
index b070e3f4dbf..d3b638c907c 100644
--- a/packages/js/experimental-products-app/src/fields/components/searchable-chip-select/test/index.test.tsx
+++ b/packages/js/experimental-products-app/src/fields/components/searchable-chip-select/test/index.test.tsx
@@ -76,6 +76,21 @@ describe( 'SearchableChipSelect', () => {
 		).toBeVisible();
 	} );

+	it( 'renders a placeholder chip when there are no selected values', () => {
+		render(
+			<SearchableChipSelectControl
+				label="Fruits"
+				items={ mockItems }
+				placeholderChip="Mixed (2)"
+			/>
+		);
+
+		expect( screen.getByText( 'Mixed (2)' ) ).toBeVisible();
+		expect(
+			screen.queryByLabelText( 'Clear all' )
+		).not.toBeInTheDocument();
+	} );
+
 	it( 'renders custom empty content', async () => {
 		render(
 			<SearchableChipSelectControl
diff --git a/packages/js/experimental-products-app/src/fields/components/searchable-chip-select/types.ts b/packages/js/experimental-products-app/src/fields/components/searchable-chip-select/types.ts
index 461c4637402..4e4b8e7d94d 100644
--- a/packages/js/experimental-products-app/src/fields/components/searchable-chip-select/types.ts
+++ b/packages/js/experimental-products-app/src/fields/components/searchable-chip-select/types.ts
@@ -45,6 +45,10 @@ export type SearchableChipSelectProps = Omit<
 		 * A render function for custom rendering the selected chips.
 		 */
 		chipsContent?: ( value: SearchableChipSelectItem[] ) => ReactNode;
+		/**
+		 * Chip content to show when there are no selected values.
+		 */
+		placeholderChip?: ReactNode;
 		/**
 		 * The custom content to use instead of the default empty state.
 		 */
diff --git a/packages/js/experimental-products-app/src/fields/components/taxonomy-edit/index.tsx b/packages/js/experimental-products-app/src/fields/components/taxonomy-edit/index.tsx
index 5179da82631..8c9685bba93 100644
--- a/packages/js/experimental-products-app/src/fields/components/taxonomy-edit/index.tsx
+++ b/packages/js/experimental-products-app/src/fields/components/taxonomy-edit/index.tsx
@@ -337,6 +337,9 @@ export function TaxonomyEdit< T extends Record< string, unknown > >( {
 			inputValue={ inputValue }
 			onInputValueChange={ setInputValue }
 			creatableItem={ isLoading ? undefined : creatableItem }
+			placeholderChip={
+				value.length === 0 ? field.placeholder : undefined
+			}
 			searchPlaceholder={
 				searchPlaceholder ?? __( 'Search', 'woocommerce' )
 			}
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
index 9bb737c36fd..dde7d5a4341 100644
--- 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
@@ -13,6 +13,8 @@ import { formatCurrency, getCurrencyObject } from '../utils/currency';
 import { CurrencyControl } from '../components/currency-input';

 import type { ProductEntityRecord } from '../types';
+import type { ProductBulkEditFormData } from '../../product-edit/bulk-edit';
+import { isBulkNumericPercentEdit } from '../../product-edit/bulk-edit';

 const fieldDefinition = {
 	type: 'text',
@@ -31,10 +33,12 @@ function getDefinedCostValue( item: ProductEntityRecord ) {
 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 ?? [];

@@ -42,7 +46,9 @@ function CostOfGoodsSoldInput( {
 		<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: {
@@ -61,6 +67,11 @@ function CostOfGoodsSoldInput( {
 				} );
 			} }
 			customValidity={ validity?.custom }
+			disabled={ disabled }
+			showPercentAdornment={ isBulkNumericPercentEdit(
+				data as ProductBulkEditFormData,
+				field.id
+			) }
 		/>
 	);
 }
diff --git a/packages/js/experimental-products-app/src/fields/price/field.tsx b/packages/js/experimental-products-app/src/fields/price/field.tsx
index 74c4d6ef096..b1d4938cc7f 100644
--- a/packages/js/experimental-products-app/src/fields/price/field.tsx
+++ b/packages/js/experimental-products-app/src/fields/price/field.tsx
@@ -175,7 +175,8 @@ export const fieldExtensions: Partial< Field< PriceFilterData > > = {

 		return (
 			<InputControl
-				label={ hideLabelFromVision ? '' : field.label }
+				label={ field.label }
+				hideLabelFromVision={ hideLabelFromVision }
 				type="number"
 				step={ step }
 				value={ singleValue }
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 e449a717379..a2ac124684c 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
@@ -46,13 +46,15 @@ export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
 				( element: { label: string; value: string } ) =>
 					element.value !== 'trash'
 			) ?? [];
-		const selectedOption = options.find(
-			( option ) => option.value === data.status
-		);
+		const selectedOption =
+			field.placeholder && ! data.status
+				? undefined
+				: options.find( ( option ) => option.value === data.status );

 		return (
 			<SelectControl
 				label={ field.label }
+				placeholder={ field.placeholder }
 				value={ selectedOption }
 				items={ options }
 				onValueChange={ ( option ) => {
diff --git a/packages/js/experimental-products-app/src/fields/shipping_class/field.tsx b/packages/js/experimental-products-app/src/fields/shipping_class/field.tsx
index 9646ac62571..1c5bfcf18e6 100644
--- a/packages/js/experimental-products-app/src/fields/shipping_class/field.tsx
+++ b/packages/js/experimental-products-app/src/fields/shipping_class/field.tsx
@@ -52,7 +52,7 @@ export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
 			};
 		}, [] );

-		const options = [
+		const shippingClassOptions = [
 			{
 				label: __( 'No shipping class', 'woocommerce' ),
 				value: '',
@@ -64,15 +64,20 @@ export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
 				  } ) )
 				: [] ),
 		];
-		const selectedOption = options.find(
-			( option ) => option.value === ( data.shipping_class ?? '' )
-		);
+		const selectedOption =
+			field.placeholder && ! data.shipping_class
+				? undefined
+				: shippingClassOptions.find(
+						( option ) =>
+							option.value === ( data.shipping_class ?? '' )
+				  );

 		return (
 			<SelectControl
 				label={ field.label }
+				placeholder={ field.placeholder }
 				value={ selectedOption }
-				items={ options }
+				items={ shippingClassOptions }
 				onValueChange={ ( option ) =>
 					onChange( {
 						shipping_class: option?.value ?? '',
diff --git a/packages/js/experimental-products-app/src/fields/stock/field.tsx b/packages/js/experimental-products-app/src/fields/stock/field.tsx
index 4fb79a76cbd..8d180793348 100644
--- a/packages/js/experimental-products-app/src/fields/stock/field.tsx
+++ b/packages/js/experimental-products-app/src/fields/stock/field.tsx
@@ -71,13 +71,17 @@ export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
 	},
 	Edit: ( { data, onChange, field } ) => {
 		const options = field?.elements ?? [];
-		const selectedOption = options.find(
-			( option ) => option.value === data.stock_status
-		);
+		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 ) => {
diff --git a/packages/js/experimental-products-app/src/fields/stock_quantity/field.tsx b/packages/js/experimental-products-app/src/fields/stock_quantity/field.tsx
index ebd40217699..9e554df6f43 100644
--- a/packages/js/experimental-products-app/src/fields/stock_quantity/field.tsx
+++ b/packages/js/experimental-products-app/src/fields/stock_quantity/field.tsx
@@ -9,13 +9,14 @@ import type { DataFormControlProps, Field } from '@wordpress/dataviews';
  * Internal dependencies
  */
 import type { ProductEntityRecord } from '../types';
+import { getBulkNumericOperationFieldId } from '../../product-edit/bulk-edit';

 type StockQuantityRange = [ number | string, number | string ];
 type StockQuantityFilterRecord = Omit<
 	ProductEntityRecord,
 	'stock_quantity'
 > & {
-	stock_quantity?: number | null | StockQuantityRange;
+	stock_quantity?: number | string | null | StockQuantityRange;
 };

 const castValueToString = (
@@ -29,6 +30,9 @@ const castValueToString = (
 	return '';
 };

+const isBulkStockQuantityEdit = ( data: ProductEntityRecord ) =>
+	getBulkNumericOperationFieldId( 'stock_quantity' ) in data;
+
 const fieldDefinition = {
 	type: 'integer',
 	label: __( 'Stock quantity', 'woocommerce' ),
@@ -61,7 +65,11 @@ export const fieldExtensions: Partial< Field< 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 )
@@ -103,17 +111,27 @@ export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
 		}

 		const value = castValueToString( raw );
+		const isBulkEdit = isBulkStockQuantityEdit( data );
+
 		return (
 			<InputControl
-				label={ hideLabelFromVision ? '' : field.label }
+				label={ field.label }
+				hideLabelFromVision={ hideLabelFromVision }
 				type="number"
 				step={ 1 }
 				value={ value }
+				placeholder={ field.placeholder }
+				disabled={ disabled }
 				onChange={ ( event ) => {
 					const next = event.target.value;
-					onChange( {
-						stock_quantity: next === '' ? null : Number( next ),
-					} );
+					let stockQuantity: StockQuantityFilterRecord[ 'stock_quantity' ] =
+						next === '' ? null : Number( next );
+
+					if ( isBulkEdit ) {
+						stockQuantity = next;
+					}
+
+					onChangeStockQuantity( { stock_quantity: stockQuantity } );
 				} }
 			/>
 		);
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
new file mode 100644
index 00000000000..725fff49cb1
--- /dev/null
+++ b/packages/js/experimental-products-app/src/product-edit/bulk-edit.ts
@@ -0,0 +1,444 @@
+/**
+ * External dependencies
+ */
+import type { Field } from '@wordpress/dataviews';
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../fields/types';
+import { getCurrencyObject } from '../fields/utils/currency';
+import { validatePrice } from '../fields/price/utils';
+import { validateSalePrice } from '../fields/sale_price/validation';
+import { buildMergedProductEditData } from './utils';
+
+type ProductField = Field< ProductEntityRecord >;
+
+export type ProductBulkEditFieldState = {
+	isEmpty: boolean;
+	isMixed: boolean;
+	value: unknown;
+	placeholder?: string;
+};
+export type ProductBulkEditData = {
+	data: ProductEntityRecord;
+	fieldStates: Record< string, ProductBulkEditFieldState >;
+};
+export type BulkNumericFieldId =
+	| 'regular_price'
+	| 'sale_price'
+	| 'cost_of_goods_sold'
+	| 'stock_quantity';
+export type BulkNumericOperation =
+	| 'dont_change'
+	| 'set'
+	| 'increase'
+	| 'decrease'
+	| 'increase_percent'
+	| 'decrease_percent';
+export type BulkNumericEdit = {
+	operation: BulkNumericOperation;
+	value: string;
+};
+export type ProductBulkEditFormData = ProductEntityRecord &
+	Record< string, unknown >;
+
+export const BULK_EDIT_MIXED_LABEL = __( 'Mixed', 'woocommerce' );
+
+export const DEFAULT_BULK_NUMERIC_EDIT: BulkNumericEdit = {
+	operation: 'dont_change',
+	value: '',
+};
+
+const BULK_NUMERIC_OPERATION_FIELD_SUFFIX = '__bulk_operation';
+
+const BULK_NUMERIC_FIELD_ID_SET = new Set< BulkNumericFieldId >( [
+	'regular_price',
+	'sale_price',
+	'cost_of_goods_sold',
+	'stock_quantity',
+] );
+
+const BULK_MONEY_FIELD_ID_SET = new Set< BulkNumericFieldId >( [
+	'regular_price',
+	'sale_price',
+	'cost_of_goods_sold',
+] );
+
+const FIELD_DATA_KEYS: Record< string, string > = {
+	product_status: 'status',
+	stock: 'stock_status',
+};
+
+export function getBulkNumericOperationFieldId( fieldId: BulkNumericFieldId ) {
+	return `${ fieldId }${ BULK_NUMERIC_OPERATION_FIELD_SUFFIX }`;
+}
+
+export function isBulkNumericOperationFieldId( fieldId: string ) {
+	return fieldId.endsWith( BULK_NUMERIC_OPERATION_FIELD_SUFFIX );
+}
+
+function normalizeValue( value: unknown ) {
+	if ( value === undefined ) {
+		return '__undefined__';
+	}
+
+	return JSON.stringify( value );
+}
+
+function getFieldDataKey( fieldId: string ) {
+	return FIELD_DATA_KEYS[ fieldId ] ?? fieldId;
+}
+
+function getDefinedCostValue( product: ProductEntityRecord ) {
+	return product.cost_of_goods_sold?.values?.[ 0 ]?.defined_value;
+}
+
+function getProductFieldValue(
+	product: ProductEntityRecord,
+	field: ProductField
+) {
+	if ( field.id === 'cost_of_goods_sold' ) {
+		return getDefinedCostValue( product );
+	}
+
+	const dataKey = getFieldDataKey( field.id );
+
+	return product[ dataKey as keyof ProductEntityRecord ];
+}
+
+function isEmptyBulkValue( value: unknown ) {
+	if ( value === undefined || value === null || value === '' ) {
+		return true;
+	}
+
+	if ( Array.isArray( value ) ) {
+		return value.length === 0;
+	}
+
+	return false;
+}
+
+function getBulkNumericValue(
+	product: ProductEntityRecord,
+	fieldId: BulkNumericFieldId
+) {
+	if ( fieldId === 'cost_of_goods_sold' ) {
+		return getDefinedCostValue( product );
+	}
+
+	return product[ fieldId ];
+}
+
+function toFiniteNumber( value: unknown ) {
+	if ( value === '' || value === null || value === undefined ) {
+		return undefined;
+	}
+
+	const numberValue = Number( value );
+
+	return Number.isFinite( numberValue ) ? numberValue : undefined;
+}
+
+function getPrecisionMultiplier() {
+	return Math.pow( 10, getCurrencyObject().precision );
+}
+
+function roundMoneyValue( value: number ) {
+	const multiplier = getPrecisionMultiplier();
+
+	return Math.round( value * multiplier ) / multiplier;
+}
+
+function formatMoneyValue( value: number ) {
+	return roundMoneyValue( value ).toFixed( getCurrencyObject().precision );
+}
+
+function formatStockQuantityValue( value: number ) {
+	return Math.round( value );
+}
+
+function clampBulkNumericValue( value: number ) {
+	return Math.max( 0, value );
+}
+
+export function buildProductBulkEditData(
+	products: ProductEntityRecord[],
+	fields: ProductField[]
+): ProductBulkEditData {
+	const data = buildMergedProductEditData( products );
+	const fieldStates = fields.reduce<
+		Record< string, ProductBulkEditFieldState >
+	>( ( states, field ) => {
+		const values = products.map( ( product ) =>
+			getProductFieldValue( product, field )
+		);
+		const firstValue = values[ 0 ];
+		const areValuesEqual = values.every(
+			( value ) =>
+				normalizeValue( value ) === normalizeValue( firstValue )
+		);
+		const isEmpty = areValuesEqual && isEmptyBulkValue( firstValue );
+
+		states[ field.id ] = {
+			isEmpty,
+			isMixed: ! areValuesEqual,
+			value: areValuesEqual ? firstValue : undefined,
+			placeholder: ! areValuesEqual ? BULK_EDIT_MIXED_LABEL : undefined,
+		};
+
+		return states;
+	}, {} );
+
+	return {
+		data,
+		fieldStates,
+	};
+}
+
+export function isBulkNumericFieldId(
+	fieldId: string
+): fieldId is BulkNumericFieldId {
+	return BULK_NUMERIC_FIELD_ID_SET.has( fieldId as BulkNumericFieldId );
+}
+
+export function isBulkNumericEditPending( edit?: BulkNumericEdit ) {
+	return Boolean( edit && edit.operation !== 'dont_change' );
+}
+
+export function getBulkNumericOperations(
+	fieldId: BulkNumericFieldId
+): BulkNumericOperation[] {
+	const baseOperations: BulkNumericOperation[] = [
+		'dont_change',
+		'set',
+		'increase',
+		'decrease',
+	];
+
+	if ( fieldId === 'stock_quantity' ) {
+		return baseOperations;
+	}
+
+	return [ ...baseOperations, 'increase_percent', 'decrease_percent' ];
+}
+
+export function isBulkNumericEditValid(
+	fieldId: BulkNumericFieldId,
+	edit?: BulkNumericEdit
+) {
+	if ( ! isBulkNumericEditPending( edit ) ) {
+		return true;
+	}
+
+	const numberValue = toFiniteNumber( edit?.value );
+
+	if ( numberValue === undefined || numberValue < 0 ) {
+		return false;
+	}
+
+	if ( fieldId === 'stock_quantity' && ! Number.isInteger( numberValue ) ) {
+		return false;
+	}
+
+	return true;
+}
+
+function getBulkNumericOperationFromData(
+	data: ProductBulkEditFormData,
+	fieldId: BulkNumericFieldId
+): BulkNumericOperation {
+	const operation =
+		data[ getBulkNumericOperationFieldId( fieldId ) ] ??
+		DEFAULT_BULK_NUMERIC_EDIT.operation;
+
+	return typeof operation === 'string' &&
+		getBulkNumericOperations( fieldId ).includes(
+			operation as BulkNumericOperation
+		)
+		? ( operation as BulkNumericOperation )
+		: DEFAULT_BULK_NUMERIC_EDIT.operation;
+}
+
+export function getBulkNumericEditFromData(
+	data: ProductBulkEditFormData,
+	fieldId: BulkNumericFieldId
+): BulkNumericEdit {
+	const value =
+		fieldId === 'cost_of_goods_sold'
+			? getDefinedCostValue( data )
+			: data[ fieldId ];
+
+	return {
+		operation: getBulkNumericOperationFromData( data, fieldId ),
+		value: value === undefined || value === null ? '' : String( value ),
+	};
+}
+
+export function isBulkNumericPercentOperation( operation: unknown ) {
+	return operation === 'increase_percent' || operation === 'decrease_percent';
+}
+
+export function isBulkNumericPercentEdit(
+	data: ProductBulkEditFormData,
+	fieldId: string
+) {
+	if ( ! isBulkNumericFieldId( fieldId ) ) {
+		return false;
+	}
+
+	return isBulkNumericPercentOperation(
+		getBulkNumericEditFromData( data, fieldId ).operation
+	);
+}
+
+export function getBulkNumericEditsFromData(
+	data: ProductBulkEditFormData
+): Partial< Record< BulkNumericFieldId, BulkNumericEdit > > {
+	return Array.from( BULK_NUMERIC_FIELD_ID_SET ).reduce<
+		Partial< Record< BulkNumericFieldId, BulkNumericEdit > >
+	>( ( edits, fieldId ) => {
+		edits[ fieldId ] = getBulkNumericEditFromData( data, fieldId );
+		return edits;
+	}, {} );
+}
+
+function calculateBulkNumericValue(
+	currentValue: unknown,
+	edit: BulkNumericEdit
+) {
+	const editValue = toFiniteNumber( edit.value );
+
+	if ( editValue === undefined ) {
+		return undefined;
+	}
+
+	if ( edit.operation === 'set' ) {
+		return editValue;
+	}
+
+	const currentNumber = toFiniteNumber( currentValue );
+
+	if ( currentNumber === undefined ) {
+		return undefined;
+	}
+
+	switch ( edit.operation ) {
+		case 'increase':
+			return currentNumber + editValue;
+		case 'decrease':
+			return currentNumber - editValue;
+		case 'increase_percent':
+			return currentNumber + currentNumber * ( editValue / 100 );
+		case 'decrease_percent':
+			return currentNumber - currentNumber * ( editValue / 100 );
+		default:
+			return undefined;
+	}
+}
+
+function getUpdatedCostOfGoodsSold(
+	product: ProductEntityRecord,
+	value: string
+): ProductEntityRecord[ 'cost_of_goods_sold' ] {
+	const costOfGoodsSold = product.cost_of_goods_sold ?? {};
+	const [ firstValue = {}, ...remainingValues ] =
+		costOfGoodsSold.values ?? [];
+
+	return {
+		...costOfGoodsSold,
+		values: [
+			{
+				...firstValue,
+				defined_value: value,
+			},
+			...remainingValues,
+		],
+	};
+}
+
+export function getBulkNumericChangesForProduct(
+	product: ProductEntityRecord,
+	edits: Partial< Record< BulkNumericFieldId, BulkNumericEdit > >
+): Partial< ProductEntityRecord > {
+	const changes: Partial< ProductEntityRecord > = {};
+
+	Object.entries( edits ).forEach( ( [ fieldId, edit ] ) => {
+		if (
+			! edit ||
+			! isBulkNumericFieldId( fieldId ) ||
+			! isBulkNumericEditPending( edit ) ||
+			! isBulkNumericEditValid( fieldId, edit )
+		) {
+			return;
+		}
+
+		const calculatedValue = calculateBulkNumericValue(
+			getBulkNumericValue( product, fieldId ),
+			edit
+		);
+
+		if ( calculatedValue === undefined ) {
+			return;
+		}
+
+		const clampedValue = clampBulkNumericValue( calculatedValue );
+
+		if ( fieldId === 'stock_quantity' ) {
+			changes.stock_quantity = formatStockQuantityValue( clampedValue );
+			return;
+		}
+
+		const nextValue = BULK_MONEY_FIELD_ID_SET.has( fieldId )
+			? formatMoneyValue( clampedValue )
+			: String( clampedValue );
+
+		if ( fieldId === 'cost_of_goods_sold' ) {
+			changes.cost_of_goods_sold = getUpdatedCostOfGoodsSold(
+				product,
+				nextValue
+			);
+			return;
+		}
+
+		changes[ fieldId ] = nextValue;
+	} );
+
+	return changes;
+}
+
+export function validateBulkNumericEdits(
+	products: ProductEntityRecord[],
+	edits: Partial< Record< BulkNumericFieldId, BulkNumericEdit > >
+) {
+	for ( const product of products ) {
+		const projectedProduct = {
+			...product,
+			...getBulkNumericChangesForProduct( product, edits ),
+		};
+		const regularPriceError = validatePrice(
+			projectedProduct.regular_price
+		);
+
+		if ( regularPriceError ) {
+			return regularPriceError;
+		}
+
+		const salePriceError = validateSalePrice( projectedProduct );
+
+		if ( salePriceError ) {
+			return salePriceError;
+		}
+
+		const costOfGoodsSoldError = validatePrice(
+			getDefinedCostValue( projectedProduct )
+		);
+
+		if ( costOfGoodsSoldError ) {
+			return costOfGoodsSoldError;
+		}
+	}
+
+	return null;
+}
diff --git a/packages/js/experimental-products-app/src/product-edit/bulk-numeric-control.tsx b/packages/js/experimental-products-app/src/product-edit/bulk-numeric-control.tsx
new file mode 100644
index 00000000000..2337777e90d
--- /dev/null
+++ b/packages/js/experimental-products-app/src/product-edit/bulk-numeric-control.tsx
@@ -0,0 +1,77 @@
+/**
+ * External dependencies
+ */
+import { SelectControl } from '@wordpress/ui';
+import type { DataFormControlProps, Field } from '@wordpress/dataviews';
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../fields/types';
+import type {
+	BulkNumericFieldId,
+	BulkNumericOperation,
+	ProductBulkEditFormData,
+} from './bulk-edit';
+import {
+	DEFAULT_BULK_NUMERIC_EDIT,
+	getBulkNumericOperationFieldId,
+	getBulkNumericOperations,
+} from './bulk-edit';
+
+const OPERATION_LABELS: Record< BulkNumericOperation, string > = {
+	dont_change: __( 'Don’t change', 'woocommerce' ),
+	set: __( 'Set to', 'woocommerce' ),
+	increase: __( 'Increase by amount', 'woocommerce' ),
+	decrease: __( 'Decrease by amount', 'woocommerce' ),
+	increase_percent: __( 'Increase by %', 'woocommerce' ),
+	decrease_percent: __( 'Decrease by %', 'woocommerce' ),
+};
+
+function BulkNumericOperationControl( {
+	data,
+	field,
+	onChange,
+}: DataFormControlProps< ProductEntityRecord > ) {
+	const items = field.elements ?? [];
+	const value =
+		( data as ProductBulkEditFormData )[ field.id ] ??
+		DEFAULT_BULK_NUMERIC_EDIT.operation;
+	const selectedOption =
+		items.find( ( option ) => option.value === value ) ?? items[ 0 ];
+
+	return (
+		<SelectControl
+			label={ field.label }
+			hideLabelFromVision
+			value={ selectedOption }
+			items={ items }
+			onValueChange={ ( option ) => {
+				onChange( {
+					[ field.id ]:
+						option?.value ?? DEFAULT_BULK_NUMERIC_EDIT.operation,
+				} as Partial< ProductEntityRecord > );
+			} }
+		/>
+	);
+}
+
+export function createBulkNumericOperationField(
+	field: Field< ProductEntityRecord >,
+	fieldId: BulkNumericFieldId
+): Field< ProductEntityRecord > {
+	return {
+		id: getBulkNumericOperationFieldId( fieldId ),
+		label: field.label,
+		type: 'text',
+		enableHiding: false,
+		enableSorting: false,
+		filterBy: false,
+		elements: getBulkNumericOperations( fieldId ).map( ( operation ) => ( {
+			label: OPERATION_LABELS[ operation ],
+			value: operation,
+		} ) ),
+		Edit: BulkNumericOperationControl,
+	};
+}
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 7debd7b265a..7653f531e3e 100644
--- a/packages/js/experimental-products-app/src/product-edit/index.tsx
+++ b/packages/js/experimental-products-app/src/product-edit/index.tsx
@@ -1,11 +1,15 @@
 /**
  * External dependencies
  */
-import { Button, Spinner } from '@wordpress/components';
+import { Button, CheckboxControl, Spinner } from '@wordpress/components';
 import { store as coreStore } from '@wordpress/core-data';
 import { select as wpSelect, useDispatch, useSelect } from '@wordpress/data';
-import { DataForm } from '@wordpress/dataviews';
-import { useCallback, useState } from '@wordpress/element';
+import {
+	DataForm,
+	type DataFormControlProps,
+	type FormField,
+} from '@wordpress/dataviews';
+import { useCallback, useEffect, useMemo, useState } from '@wordpress/element';
 import { __, sprintf } from '@wordpress/i18n';
 import { store as noticesStore } from '@wordpress/notices';
 import { privateApis as routerPrivateApis } from '@wordpress/router';
@@ -22,7 +26,6 @@ import {
 import type { ProductEntityRecord } from '../fields/types';
 import { unlock } from '../lock-unlock';
 import {
-	buildMergedProductEditData,
 	findProductInList,
 	getProductEditRecord,
 	getProductWithUpdatedVariation,
@@ -31,11 +34,32 @@ import {
 	getVisibleProductEditFields,
 	isProductVariation,
 } from './utils';
+import {
+	buildProductBulkEditData,
+	DEFAULT_BULK_NUMERIC_EDIT,
+	getBulkNumericEditFromData,
+	getBulkNumericEditsFromData,
+	getBulkNumericChangesForProduct,
+	getBulkNumericOperationFieldId,
+	isBulkNumericEditPending,
+	isBulkNumericEditValid,
+	isBulkNumericFieldId,
+	isBulkNumericOperationFieldId,
+	validateBulkNumericEdits,
+} from './bulk-edit';
+import type {
+	BulkNumericEdit,
+	BulkNumericFieldId,
+	ProductBulkEditFormData,
+	ProductBulkEditFieldState,
+} from './bulk-edit';
 import { saveSelectedProducts } from './save';
+import { createBulkNumericOperationField } from './bulk-numeric-control';

 const { useHistory, useLocation } = unlock( routerPrivateApis );

 type ProductEditFormProps = {
+	bulkEditData: ProductBulkEditFormData;
 	editableFields: ReturnType< typeof getProductEditFields >;
 	onChange: ( changes: Partial< ProductEntityRecord > ) => void;
 	selectedProducts: ProductEntityRecord[];
@@ -46,6 +70,219 @@ type ProductEditProps = {
 	isOpen: boolean;
 };

+type ProductEditField = ReturnType< typeof getProductEditFields >[ number ];
+
+function BulkBooleanControl( {
+	data,
+	field,
+	onChange,
+}: DataFormControlProps< ProductEntityRecord > ) {
+	return (
+		<CheckboxControl
+			label={ field.label }
+			checked={ false }
+			indeterminate
+			onChange={ ( value ) => {
+				onChange(
+					field.setValue( {
+						item: data,
+						value,
+					} )
+				);
+			} }
+		/>
+	);
+}
+
+function getBulkNumericPlaceholder(
+	fieldState: ProductBulkEditFieldState | undefined
+) {
+	if ( fieldState?.placeholder ) {
+		return fieldState.placeholder;
+	}
+
+	if ( ! fieldState || fieldState.isEmpty ) {
+		return undefined;
+	}
+
+	return String( fieldState.value ?? '' );
+}
+
+function getCostOfGoodsSoldDataWithValue(
+	data: ProductEntityRecord,
+	value: string
+) {
+	const costOfGoodsSold = data.cost_of_goods_sold ?? {};
+	const [ firstValue = {}, ...remainingValues ] =
+		costOfGoodsSold.values ?? [];
+
+	return {
+		...costOfGoodsSold,
+		values: [
+			{
+				...firstValue,
+				defined_value: value,
+			},
+			...remainingValues,
+		],
+	};
+}
+
+function getBulkEditFormData(
+	mergedData: ProductEntityRecord,
+	bulkEditData: ProductBulkEditFormData,
+	fieldStates: Record< string, ProductBulkEditFieldState >
+): ProductBulkEditFormData {
+	const data: ProductBulkEditFormData = {
+		...mergedData,
+		...bulkEditData,
+	};
+
+	Object.keys( fieldStates ).forEach( ( fieldId ) => {
+		if ( fieldId === 'stock' && fieldStates[ fieldId ].isMixed ) {
+			( data as Record< string, unknown > ).stock_status = '';
+			return;
+		}
+
+		if ( ! isBulkNumericFieldId( fieldId ) ) {
+			return;
+		}
+
+		const operationFieldId = getBulkNumericOperationFieldId( fieldId );
+		data[ operationFieldId ] =
+			bulkEditData[ operationFieldId ] ??
+			DEFAULT_BULK_NUMERIC_EDIT.operation;
+
+		if ( fieldId === 'cost_of_goods_sold' ) {
+			const editValue = getBulkNumericEditFromData(
+				bulkEditData,
+				fieldId
+			).value;
+
+			data.cost_of_goods_sold = getCostOfGoodsSoldDataWithValue(
+				mergedData,
+				editValue
+			);
+			return;
+		}
+
+		( data as Record< string, unknown > )[ fieldId ] =
+			bulkEditData[ fieldId ] ?? DEFAULT_BULK_NUMERIC_EDIT.value;
+	} );
+
+	return data;
+}
+
+function getBulkEnhancedProductEditFields( {
+	fieldStates,
+	isBulkEdit,
+	visibleFields,
+}: {
+	fieldStates: Record< string, ProductBulkEditFieldState >;
+	isBulkEdit: boolean;
+	visibleFields: ProductEditField[];
+} ) {
+	if ( ! isBulkEdit ) {
+		return visibleFields;
+	}
+
+	return visibleFields
+		.map( ( field ) => {
+			const fieldState = fieldStates[ field.id ];
+			const enhancedField = {
+				...field,
+				placeholder: fieldState?.placeholder ?? field.placeholder,
+				...( field.id === 'name'
+					? {
+							isValid: {
+								...field.isValid,
+								required: false,
+							},
+					  }
+					: {} ),
+			};
+
+			if ( isBulkNumericFieldId( field.id ) ) {
+				const fieldId = field.id;
+
+				return [
+					createBulkNumericOperationField( enhancedField, fieldId ),
+					{
+						...enhancedField,
+						placeholder: getBulkNumericPlaceholder( fieldState ),
+						isDisabled: ( {
+							item,
+						}: {
+							item: ProductEntityRecord;
+						} ) =>
+							getBulkNumericEditFromData(
+								item as ProductBulkEditFormData,
+								fieldId
+							).operation === DEFAULT_BULK_NUMERIC_EDIT.operation,
+					},
+				];
+			}
+
+			if (
+				fieldState?.isMixed &&
+				( field.type === 'boolean' || field.Edit === 'toggle' )
+			) {
+				return {
+					...enhancedField,
+					Edit: BulkBooleanControl,
+				};
+			}
+
+			return [ enhancedField ];
+		} )
+		.flat();
+}
+
+function injectBulkNumericOperationFormFields(
+	formFields: Array< FormField | string >,
+	fieldLabels: Map< string, string | undefined >
+): Array< FormField | string > {
+	return formFields.map( ( formField ) => {
+		if ( typeof formField === 'string' ) {
+			if ( ! isBulkNumericFieldId( formField ) ) {
+				return formField;
+			}
+
+			return {
+				id: `${ formField }-bulk-edit-fields`,
+				label: fieldLabels.get( formField ),
+				layout: { type: 'row' as const },
+				children: [
+					{
+						id: getBulkNumericOperationFieldId( formField ),
+						layout: {
+							type: 'regular' as const,
+							labelPosition: 'none' as const,
+						},
+					},
+					{
+						id: formField,
+						layout: {
+							type: 'regular' as const,
+							labelPosition: 'none' as const,
+						},
+					},
+				],
+			};
+		}
+
+		return {
+			...formField,
+			children: formField.children
+				? injectBulkNumericOperationFormFields(
+						formField.children as Array< FormField | string >,
+						fieldLabels
+				  )
+				: formField.children,
+		};
+	} );
+}
+
 function getSaveNoticeMessage( successCount: number, failedCount: number ) {
 	if ( failedCount === 0 ) {
 		if ( successCount === 1 ) {
@@ -83,27 +320,53 @@ function getSaveNoticeMessage( successCount: number, failedCount: number ) {
 }

 function ProductEditForm( {
+	bulkEditData,
 	editableFields,
 	onChange,
 	selectedProducts,
 }: ProductEditFormProps ) {
-	const mergedData = buildMergedProductEditData( selectedProducts );
 	const visibleFields = getVisibleProductEditFields(
 		editableFields,
 		selectedProducts
 	);
+	const { data: mergedData, fieldStates } = buildProductBulkEditData(
+		selectedProducts,
+		visibleFields
+	);
+	const enhancedFields = getBulkEnhancedProductEditFields( {
+		fieldStates,
+		isBulkEdit: selectedProducts.length > 1,
+		visibleFields,
+	} );
+	const formFields = getProductTypeFormFields(
+		selectedProducts,
+		enhancedFields
+	);
+	const fieldLabels = new Map(
+		visibleFields.map( ( field ) => [ field.id, field.label ] )
+	);
+	const data =
+		selectedProducts.length > 1
+			? getBulkEditFormData( mergedData, bulkEditData, fieldStates )
+			: mergedData;

 	const form = {
 		type: 'regular' as const,
 		labelPosition: 'top' as const,
-		fields: getProductTypeFormFields( selectedProducts, visibleFields ),
+		fields:
+			selectedProducts.length > 1
+				? injectBulkNumericOperationFormFields(
+						formFields,
+						fieldLabels
+				  )
+				: formFields,
 	};

 	return (
 		<div className="woocommerce-product-edit__form">
 			<DataForm
-				data={ mergedData }
-				fields={ visibleFields }
+				data={ data }
+				fields={ enhancedFields }
 				form={ form }
 				onChange={ onChange }
 			/>
@@ -120,8 +383,11 @@ export default function ProductEdit( { products, isOpen }: ProductEditProps ) {
 	const requestedProductIds = Array.from(
 		new Set( requestedProductIdsFromRoute )
 	);
+	const requestedProductIdsKey = requestedProductIds.join( ',' );

 	const [ isSaving, setIsSaving ] = useState( false );
+	const [ bulkEditData, setBulkEditData ] =
+		useState< ProductBulkEditFormData >( {} as ProductBulkEditFormData );

 	const editableFields = getProductEditFields( productFields );
 	const {
@@ -248,6 +514,35 @@ export default function ProductEdit( { products, isOpen }: ProductEditProps ) {
 	const { createSuccessNotice, createErrorNotice } =
 		useDispatch( noticesStore );

+	useEffect( () => {
+		setBulkEditData( {} as ProductBulkEditFormData );
+	}, [ requestedProductIdsKey ] );
+
+	const activeBulkNumericEdits = useMemo(
+		() =>
+			Object.fromEntries(
+				Object.entries(
+					getBulkNumericEditsFromData( bulkEditData )
+				).filter(
+					( [ fieldId, edit ] ) =>
+						isBulkNumericFieldId( fieldId ) &&
+						isBulkNumericEditPending( edit ) &&
+						isBulkNumericEditValid( fieldId, edit )
+				)
+			) as Partial< Record< BulkNumericFieldId, BulkNumericEdit > >,
+		[ bulkEditData ]
+	);
+	const hasValidBulkNumericEdits =
+		Object.keys( activeBulkNumericEdits ).length > 0;
+	const hasInvalidBulkNumericEdits = Object.entries(
+		getBulkNumericEditsFromData( bulkEditData )
+	).some(
+		( [ fieldId, edit ] ) =>
+			isBulkNumericFieldId( fieldId ) &&
+			isBulkNumericEditPending( edit ) &&
+			! isBulkNumericEditValid( fieldId, edit )
+	);
+
 	const hasNoRequestedProducts = requestedProductIds.length === 0;
 	const isReady =
 		hasResolved &&
@@ -270,14 +565,24 @@ export default function ProductEdit( { products, isOpen }: ProductEditProps ) {
 		}
 	}

-	const onChange = useCallback(
-		( changes: Partial< ProductEntityRecord > ) => {
+	const applySelectedProductChanges = useCallback(
+		(
+			getChangesForProduct: (
+				product: ProductEntityRecord
+			) => Partial< ProductEntityRecord >
+		) => {
 			const updatedParentProductsById = new Map<
 				number,
 				ProductEntityRecord
 			>();

 			selectedProducts.forEach( ( product ) => {
+				const changes = getChangesForProduct( product );
+
+				if ( Object.keys( changes ).length === 0 ) {
+					return;
+				}
+
 				if ( ! isProductVariation( product ) ) {
 					editEntityRecord( 'root', 'product', product.id, changes );
 					return;
@@ -317,6 +622,43 @@ export default function ProductEdit( { products, isOpen }: ProductEditProps ) {
 		[ editEntityRecord, selectedProducts ]
 	);

+	const onChange = useCallback(
+		( changes: Partial< ProductEntityRecord > ) => {
+			if ( selectedProducts.length <= 1 ) {
+				applySelectedProductChanges( () => changes );
+				return;
+			}
+
+			const bulkChanges: Record< string, unknown > = {};
+			const productChanges: Partial< ProductEntityRecord > = {};
+
+			Object.entries( changes ).forEach( ( [ fieldId, value ] ) => {
+				if (
+					isBulkNumericOperationFieldId( fieldId ) ||
+					isBulkNumericFieldId( fieldId )
+				) {
+					bulkChanges[ fieldId ] = value;
+					return;
+				}
+
+				productChanges[ fieldId as keyof ProductEntityRecord ] =
+					value as never;
+			} );
+
+			if ( Object.keys( bulkChanges ).length > 0 ) {
+				setBulkEditData( ( currentData ) => ( {
+					...currentData,
+					...bulkChanges,
+				} ) );
+			}
+
+			if ( Object.keys( productChanges ).length > 0 ) {
+				applySelectedProductChanges( () => productChanges );
+			}
+		},
+		[ applySelectedProductChanges, selectedProducts.length ]
+	);
+
 	const closeDrawer = useCallback( () => {
 		const editedProductIds = new Set(
 			selectedProducts.map( ( product ) =>
@@ -333,6 +675,8 @@ export default function ProductEdit( { products, isOpen }: ProductEditProps ) {
 			clearEntityRecordEdits( 'root', 'product', productId );
 		} );

+		setBulkEditData( {} as ProductBulkEditFormData );
+
 		delete nextQuery.quickEdit;

 		navigate( getProductListNavigationPath( path, nextQuery ) );
@@ -343,6 +687,38 @@ export default function ProductEdit( { products, isOpen }: ProductEditProps ) {
 			return;
 		}

+		if ( hasInvalidBulkNumericEdits ) {
+			createErrorNotice(
+				__( 'Please enter a valid value.', 'woocommerce' ),
+				{
+					type: 'snackbar',
+				}
+			);
+			return;
+		}
+
+		const bulkNumericValidationError = validateBulkNumericEdits(
+			selectedProducts,
+			activeBulkNumericEdits
+		);
+
+		if ( bulkNumericValidationError ) {
+			createErrorNotice( bulkNumericValidationError, {
+				type: 'snackbar',
+			} );
+			return;
+		}
+
+		if ( hasValidBulkNumericEdits ) {
+			applySelectedProductChanges( ( product ) =>
+				getBulkNumericChangesForProduct(
+					product,
+					activeBulkNumericEdits
+				)
+			);
+			setBulkEditData( {} as ProductBulkEditFormData );
+		}
+
 		setIsSaving( true );

 		try {
@@ -380,9 +756,13 @@ export default function ProductEdit( { products, isOpen }: ProductEditProps ) {
 		}
 	}, [
 		closeDrawer,
+		activeBulkNumericEdits,
+		applySelectedProductChanges,
 		createErrorNotice,
 		createSuccessNotice,
 		editEntityRecord,
+		hasInvalidBulkNumericEdits,
+		hasValidBulkNumericEdits,
 		isSaving,
 		saveEditedEntityRecord,
 		selectedProducts,
@@ -440,6 +820,7 @@ export default function ProductEdit( { products, isOpen }: ProductEditProps ) {

 					{ isReady && (
 						<ProductEditForm
+							bulkEditData={ bulkEditData }
 							editableFields={ editableFields }
 							onChange={ onChange }
 							selectedProducts={ selectedProducts }
@@ -460,7 +841,11 @@ export default function ProductEdit( { products, isOpen }: ProductEditProps ) {
 							variant="primary"
 							onClick={ onSave }
 							isBusy={ isSaving }
-							disabled={ isSaving || ! hasEdits }
+							disabled={
+								isSaving ||
+								hasInvalidBulkNumericEdits ||
+								( ! hasEdits && ! hasValidBulkNumericEdits )
+							}
 						>
 							{ __( 'Save', 'woocommerce' ) }
 						</Button>
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 0c7d44fd5c0..4bdfd736377 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
@@ -20,6 +20,13 @@ import {
 	getVisibleProductEditFields,
 	isProductVariation,
 } from './utils';
+import {
+	buildProductBulkEditData,
+	getBulkNumericEditsFromData,
+	getBulkNumericOperationFieldId,
+	getBulkNumericChangesForProduct,
+	validateBulkNumericEdits,
+} from './bulk-edit';

 jest.mock( '@dnd-kit/react', () => ( {
 	DragDropProvider: ( { children }: { children: React.ReactNode } ) =>
@@ -150,6 +157,223 @@ describe( 'product edit utils', () => {
 		);
 	} );

+	it( 'returns bulk field state for mixed values', () => {
+		const products = [
+			buildProduct( {
+				id: 1,
+				name: 'Beanie',
+				status: 'publish',
+			} ),
+			buildProduct( {
+				id: 2,
+				name: 'Hoodie',
+				status: 'draft',
+			} ),
+		];
+
+		const bulkData = buildProductBulkEditData(
+			products,
+			getProductEditFields( productFields )
+		);
+
+		expect( bulkData.data.name ).toBe( '' );
+		expect( bulkData.fieldStates.name ).toEqual( {
+			isEmpty: false,
+			isMixed: true,
+			placeholder: 'Mixed',
+			value: undefined,
+		} );
+		expect( bulkData.fieldStates.product_status ).toEqual( {
+			isEmpty: false,
+			isMixed: true,
+			placeholder: 'Mixed',
+			value: undefined,
+		} );
+	} );
+
+	it( 'returns bulk field state for shared and empty values', () => {
+		const products = [
+			buildProduct( {
+				id: 1,
+				name: 'Beanie',
+				regular_price: '',
+			} ),
+			buildProduct( {
+				id: 2,
+				name: 'Beanie',
+				regular_price: '',
+			} ),
+		];
+
+		const bulkData = buildProductBulkEditData(
+			products,
+			getProductEditFields( productFields )
+		);
+
+		expect( bulkData.fieldStates.name ).toEqual( {
+			isEmpty: false,
+			isMixed: false,
+			placeholder: undefined,
+			value: 'Beanie',
+		} );
+		expect( bulkData.fieldStates.regular_price ).toEqual( {
+			isEmpty: true,
+			isMixed: false,
+			placeholder: undefined,
+			value: '',
+		} );
+	} );
+
+	describe( 'getBulkNumericChangesForProduct', () => {
+		it( 'returns no edits for the don’t change operation', () => {
+			expect(
+				getBulkNumericChangesForProduct(
+					buildProduct( { regular_price: '10' } ),
+					{
+						regular_price: {
+							operation: 'dont_change',
+							value: '',
+						},
+					}
+				)
+			).toEqual( {} );
+		} );
+
+		it( 'reads numeric edits from injected bulk operation fields', () => {
+			expect(
+				getBulkNumericEditsFromData( {
+					[ getBulkNumericOperationFieldId( 'regular_price' ) ]:
+						'increase',
+					regular_price: '5',
+					[ getBulkNumericOperationFieldId( 'stock_quantity' ) ]:
+						'set',
+					stock_quantity: 12,
+					cost_of_goods_sold: buildCostOfGoodsSold( '7' ),
+				} as unknown as ProductEntityRecord )
+			).toEqual(
+				expect.objectContaining( {
+					regular_price: {
+						operation: 'increase',
+						value: '5',
+					},
+					stock_quantity: {
+						operation: 'set',
+						value: '12',
+					},
+					cost_of_goods_sold: {
+						operation: 'dont_change',
+						value: '7',
+					},
+				} )
+			);
+		} );
+
+		it( 'sets, increases, and decreases money values', () => {
+			const product = buildProduct( { regular_price: '10' } );
+
+			expect(
+				getBulkNumericChangesForProduct( product, {
+					regular_price: { operation: 'set', value: '12' },
+				} )
+			).toEqual( { regular_price: '12.00' } );
+			expect(
+				getBulkNumericChangesForProduct( product, {
+					regular_price: { operation: 'increase', value: '5' },
+				} )
+			).toEqual( { regular_price: '15.00' } );
+			expect(
+				getBulkNumericChangesForProduct( product, {
+					regular_price: { operation: 'decrease', value: '20' },
+				} )
+			).toEqual( { regular_price: '0.00' } );
+		} );
+
+		it( 'applies percentage operations to money values', () => {
+			const product = buildProduct( { sale_price: '20' } );
+
+			expect(
+				getBulkNumericChangesForProduct( product, {
+					sale_price: {
+						operation: 'increase_percent',
+						value: '10',
+					},
+				} )
+			).toEqual( { sale_price: '22.00' } );
+			expect(
+				getBulkNumericChangesForProduct( product, {
+					sale_price: {
+						operation: 'decrease_percent',
+						value: '25',
+					},
+				} )
+			).toEqual( { sale_price: '15.00' } );
+		} );
+
+		it( 'sets, increases, and decreases stock quantity as integers', () => {
+			const product = buildProduct( { stock_quantity: 10 } );
+
+			expect(
+				getBulkNumericChangesForProduct( product, {
+					stock_quantity: { operation: 'set', value: '7' },
+				} )
+			).toEqual( { stock_quantity: 7 } );
+			expect(
+				getBulkNumericChangesForProduct( product, {
+					stock_quantity: { operation: 'increase', value: '3' },
+				} )
+			).toEqual( { stock_quantity: 13 } );
+			expect(
+				getBulkNumericChangesForProduct( product, {
+					stock_quantity: { operation: 'decrease', value: '20' },
+				} )
+			).toEqual( { stock_quantity: 0 } );
+		} );
+
+		it( 'updates the nested cost of goods value', () => {
+			const product = buildProduct( {
+				cost_of_goods_sold: buildCostOfGoodsSold( '5' ),
+			} );
+
+			expect(
+				getBulkNumericChangesForProduct( product, {
+					cost_of_goods_sold: {
+						operation: 'increase',
+						value: '2',
+					},
+				} )
+			).toEqual( {
+				cost_of_goods_sold: {
+					values: [
+						{
+							defined_value: '7.00',
+							effective_value: '5',
+						},
+					],
+					total_value: '5',
+				},
+			} );
+		} );
+
+		it( 'validates projected sale prices before save', () => {
+			expect(
+				validateBulkNumericEdits(
+					[
+						buildProduct( {
+							regular_price: '10',
+							sale_price: '9',
+						} ),
+					],
+					{
+						regular_price: {
+							operation: 'decrease',
+							value: '2',
+						},
+					}
+				)
+			).toBe( 'Sale price must be lower than the regular price.' );
+		} );
+	} );
+
 	it( 'excludes summary and count fields from the edit field list', () => {
 		const editFieldIds = getProductEditFields( [
 			{ id: 'name' },
diff --git a/packages/js/experimental-products-app/src/style.scss b/packages/js/experimental-products-app/src/style.scss
index 2304b94d054..a11c4e320f1 100644
--- a/packages/js/experimental-products-app/src/style.scss
+++ b/packages/js/experimental-products-app/src/style.scss
@@ -169,6 +169,21 @@
 		font-size: 15px !important;
 	}

+	.dataforms-layouts-row__header {
+		gap: 0 !important;
+		margin-bottom: 8px;
+
+		:is(h1, h2, h3, h4, h5, h6) {
+			margin: 0 !important;
+			color: var(--wpds-color-fg-content-neutral, #1e1e1e);
+			font-family: var(--wpds-typography-font-family-body);
+			font-size: 11px !important;
+			font-weight: 500;
+			line-height: 1.4;
+			text-transform: uppercase;
+		}
+	}
+
 	.dataforms-layouts-regular__header + .dataforms-layouts__wrapper {
 		gap: 16px !important;
 		margin-top: 16px;