packages/js/experimental-products-app/docs/bulk-editing.md
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.
The DataViews quick edit action supports bulk selection. When it runs, it writes the selected IDs and the drawer state into the URL:
/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.ProductEdit resolves selected products from two places:
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.
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:
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.name, categories, tags, and catalog_visibility, are hidden when the selection includes variations.The final field list is pruned into the product-type form layout by getProductTypeFormFields().
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():
status: "publish", the form gets status: "publish".null values stay null.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.
getBulkEnhancedProductEditFields() adjusts field definitions only when more than one product is selected.
For regular fields:
For numeric bulk fields:
dont_change.Mixed or the shared existing value.injectBulkNumericOperationFormFields() wraps those paired controls in a row so each numeric field is presented as:
Operation | Value
The operation control is created by createBulkNumericOperationField() in src/product-edit/bulk-numeric-control.tsx.
Single-product edits are simple: onChange() immediately calls editEntityRecord() with the field changes.
Bulk edits split changes into two groups:
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:
editEntityRecord( 'root', 'product', product.id, changes )._embedded.variations.The numeric bulk fields are:
regular_pricesale_pricecost_of_goods_soldstock_quantityAll numeric fields support:
dont_changesetincreasedecreaseMoney fields also support:
increase_percentdecrease_percentstock_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.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.
When the user clicks Save:
saveSelectedProducts() persists the selected records.saveSelectedProducts() treats products and variations differently:
saveEditedEntityRecord( 'root', 'product', productId ).PUT /wc/v3/products/{parentId}/variations/{variationId}.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.
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.
| 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. |