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;