Commit 1d3bfacead1 for woocommerce
commit 1d3bfacead1d78e340a9bd097808467529757b45
Author: Luigi Teschio <gigitux@gmail.com>
Date: Mon May 4 12:14:03 2026 +0200
[DataViews - All Products] Add DataForm quick edit panel (#64481)
* Refactor product actions and improve layout handling in the experimental products app
* lint code
* Update packages/js/experimental-products-app/typings/index.d.ts
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
* fix hook
* lint code
* Implement filters
* Add changefile(s) from automation for the following project(s): @woocommerce/experimental-products-app
* [DataViews - All Products] Implement bulk editing
* clean up code
* fix logic
* fix unit test
* improve test
* update configuration
* implement status tab
* fix import
* add link to the title
* clean up code
* lint code
* fix code
* address commit
* fix build
* improve code quality
* update logic
* [DataViews - All Products] Add Dataform component for editing experience
* Add changefile(s) from automation for the following project(s): @woocommerce/experimental-products-app
* improve style
* clean up code
* lint code
* remove import
* improve title
* improve logic
* add changelog
* Add changefile(s) from automation for the following project(s): @woocommerce/experimental-products-app, woocommerce
---------
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
diff --git a/packages/js/experimental-products-app/changelog/64481-add-dataform-component b/packages/js/experimental-products-app/changelog/64481-add-dataform-component
new file mode 100644
index 00000000000..e409db96fdf
--- /dev/null
+++ b/packages/js/experimental-products-app/changelog/64481-add-dataform-component
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add quick edit and bulk product actions to the experimental products app.
\ No newline at end of file
diff --git a/packages/js/experimental-products-app/src/dataviews-actions/actions.test.tsx b/packages/js/experimental-products-app/src/dataviews-actions/actions.test.tsx
index 55d861ea405..f5cb1604a0d 100644
--- a/packages/js/experimental-products-app/src/dataviews-actions/actions.test.tsx
+++ b/packages/js/experimental-products-app/src/dataviews-actions/actions.test.tsx
@@ -4,11 +4,16 @@
import apiFetch from '@wordpress/api-fetch';
import { dispatch } from '@wordpress/data';
import type { Action } from '@wordpress/dataviews';
+import { renderHook } from '@testing-library/react';
/**
* Internal dependencies
*/
-import { duplicateProductAction, moveToTrashAction } from './actions';
+import {
+ duplicateProductAction,
+ moveToTrashAction,
+ useProductActions,
+} from './actions';
import type { ProductEntityRecord } from '../fields/types';
jest.mock( '@wordpress/api-fetch', () => jest.fn() );
@@ -25,6 +30,20 @@ jest.mock( '@wordpress/notices', () => ( {
store: 'mock-notices-store',
} ) );
+jest.mock( '../lock-unlock', () => {
+ const useHistory = jest.fn();
+ const useLocation = jest.fn();
+
+ return {
+ unlock: jest.fn( () => ( {
+ useHistory,
+ useLocation,
+ } ) ),
+ __mockUseHistory: useHistory,
+ __mockUseLocation: useLocation,
+ };
+} );
+
jest.mock( '@wordpress/i18n', () => ( {
__: jest.fn( ( message ) => message ),
_x: jest.fn( ( message ) => message ),
@@ -44,6 +63,11 @@ jest.mock( '@woocommerce/settings', () => ( {
getAdminLink: jest.fn( ( path ) => path ),
} ) );
+const { __mockUseHistory: mockUseHistory, __mockUseLocation: mockUseLocation } =
+ jest.requireMock( '../lock-unlock' ) as {
+ __mockUseHistory: jest.Mock;
+ __mockUseLocation: jest.Mock;
+ };
const mockedApiFetch = jest.mocked( apiFetch );
function getCallbackAction( action: Action< ProductEntityRecord > ) {
@@ -70,9 +94,19 @@ describe( 'product list actions', () => {
const createSuccessNotice = jest.fn();
const createErrorNotice = jest.fn();
const onActionPerformed = jest.fn();
+ const navigate = jest.fn();
beforeEach( () => {
jest.clearAllMocks();
+ mockUseHistory.mockReturnValue( {
+ navigate,
+ } );
+ mockUseLocation.mockReturnValue( {
+ path: '/products',
+ query: {
+ activeView: 'draft',
+ },
+ } );
( dispatch as jest.Mock ).mockImplementation( ( storeName ) => {
if ( storeName === 'mock-core-store' ) {
@@ -94,6 +128,24 @@ describe( 'product list actions', () => {
} );
} );
+ it( 'opens quick edit when the Edit action is triggered', () => {
+ const { result } = renderHook( () => useProductActions() );
+ const editProductAction = result.current.find(
+ ( action ) => action.id === 'edit-product'
+ );
+
+ expect( editProductAction ).toBeDefined();
+
+ getCallbackAction( editProductAction! ).callback( [ product ], {
+ onActionPerformed,
+ } );
+
+ expect( navigate ).toHaveBeenCalledWith(
+ '/products?activeView=draft&postId=12&quickEdit=true'
+ );
+ expect( onActionPerformed ).toHaveBeenCalledWith( [ product ] );
+ } );
+
it( 'duplicates products through the WooCommerce duplicate endpoint', async () => {
const duplicatedProduct = {
...product,
diff --git a/packages/js/experimental-products-app/src/dataviews-actions/actions.tsx b/packages/js/experimental-products-app/src/dataviews-actions/actions.tsx
index 5386755f552..ab604d7e020 100644
--- a/packages/js/experimental-products-app/src/dataviews-actions/actions.tsx
+++ b/packages/js/experimental-products-app/src/dataviews-actions/actions.tsx
@@ -7,6 +7,7 @@ import { dispatch } from '@wordpress/data';
import { edit, external, trash } from '@wordpress/icons';
import { __, _n, _x, sprintf } from '@wordpress/i18n';
import { store as noticesStore } from '@wordpress/notices';
+import { privateApis as routerPrivateApis } from '@wordpress/router';
import { addQueryArgs } from '@wordpress/url';
import { getAdminLink } from '@woocommerce/settings';
import type { Action } from '@wordpress/dataviews';
@@ -16,6 +17,39 @@ import { useMemo } from '@wordpress/element';
* Internal dependencies
*/
import type { ProductEntityRecord } from '../fields/types';
+import { unlock } from '../lock-unlock';
+import { getProductListNavigationPath } from '../product-list/utils';
+
+const { useHistory, useLocation } = unlock( routerPrivateApis );
+
+type EditActionOptions = {
+ navigate: ( path: string ) => void;
+ path?: string;
+ query?: Record< string, string | undefined >;
+};
+
+function getQuickEditPath(
+ path: string,
+ query: Record< string, string | undefined >,
+ productId: number
+) {
+ const nextQuery = Object.entries( query ).reduce(
+ ( acc, [ key, value ] ) => {
+ if ( typeof value === 'string' ) {
+ acc[ key ] = value;
+ }
+
+ return acc;
+ },
+ {} as Record< string, string >
+ );
+
+ return getProductListNavigationPath( path, {
+ ...nextQuery,
+ postId: String( productId ),
+ quickEdit: 'true',
+ } );
+}
function getErrorMessage( error: unknown ): string {
if ( error instanceof Error ) {
@@ -84,7 +118,11 @@ function getNoticeFromSettledResults( {
};
}
-export const editAction = (): Action< ProductEntityRecord > => ( {
+export const editAction = ( {
+ navigate,
+ path = '/',
+ query = {},
+}: EditActionOptions ): Action< ProductEntityRecord > => ( {
id: 'edit-product',
label: __( 'Edit', 'woocommerce' ),
isPrimary: true,
@@ -96,12 +134,7 @@ export const editAction = (): Action< ProductEntityRecord > => ( {
const product = items[ 0 ];
if ( product ) {
- window.location.href = getAdminLink(
- addQueryArgs( 'post.php', {
- post: product.id,
- action: 'edit',
- } )
- );
+ navigate( getQuickEditPath( path, query, product.id ) );
}
if ( onActionPerformed ) {
@@ -311,13 +344,20 @@ export const moveToTrashAction = (): Action< ProductEntityRecord > => ( {
} );
export const useProductActions = () => {
+ const { navigate } = useHistory();
+ const { path, query = {} } = useLocation();
+
return useMemo(
() => [
- editAction(),
+ editAction( {
+ navigate,
+ path,
+ query,
+ } ),
viewAction(),
duplicateProductAction(),
moveToTrashAction(),
],
- []
+ [ navigate, path, query ]
);
};
diff --git a/packages/js/experimental-products-app/src/layout.tsx b/packages/js/experimental-products-app/src/layout.tsx
index 2cf00839e85..8a9236ea81d 100644
--- a/packages/js/experimental-products-app/src/layout.tsx
+++ b/packages/js/experimental-products-app/src/layout.tsx
@@ -92,12 +92,14 @@ export function Layout( { route, showNewNavigation = false }: LayoutProps ) {
{ ! isMobileViewport && areas.edit && (
<div
- className="edit-site-layout__area"
+ className="edit-site-layout__area woocommerce-product-edit-layout__region"
style={ {
maxWidth: widths?.edit,
} }
>
- { areas.edit }
+ <div className="woocommerce-product-edit-layout__panel">
+ { areas.edit }
+ </div>
</div>
) }
</div>
diff --git a/packages/js/experimental-products-app/src/product-edit/index.tsx b/packages/js/experimental-products-app/src/product-edit/index.tsx
new file mode 100644
index 00000000000..dcf147e13e3
--- /dev/null
+++ b/packages/js/experimental-products-app/src/product-edit/index.tsx
@@ -0,0 +1,368 @@
+/**
+ * External dependencies
+ */
+import { Button, Spinner } from '@wordpress/components';
+import { store as coreStore } from '@wordpress/core-data';
+import { useDispatch, useSelect } from '@wordpress/data';
+import { DataForm } from '@wordpress/dataviews';
+import { useCallback, useState } from '@wordpress/element';
+import { __, sprintf } from '@wordpress/i18n';
+import { closeSmall } from '@wordpress/icons';
+import { store as noticesStore } from '@wordpress/notices';
+import { privateApis as routerPrivateApis } from '@wordpress/router';
+
+/**
+ * Internal dependencies
+ */
+import { productFields } from '../product-list/fields';
+import {
+ getProductListNavigationPath,
+ getSelectionFromPostId,
+} from '../product-list/utils';
+import type { ProductEntityRecord } from '../fields/types';
+import { unlock } from '../lock-unlock';
+import {
+ buildMergedProductEditData,
+ getProductEditFields,
+ getVisibleProductEditFields,
+} from './utils';
+
+const { useHistory, useLocation } = unlock( routerPrivateApis );
+
+type ProductEditFormProps = {
+ editableFields: ReturnType< typeof getProductEditFields >;
+ hasEdits: boolean;
+ isSaving: boolean;
+ onChange: ( changes: Partial< ProductEntityRecord > ) => void;
+ onClose: () => void;
+ onSave: () => void;
+ selectedProducts: ProductEntityRecord[];
+};
+
+function getSaveNoticeMessage( successCount: number, failedCount: number ) {
+ if ( failedCount === 0 ) {
+ if ( successCount === 1 ) {
+ return __( 'Product saved.', 'woocommerce' );
+ }
+
+ return sprintf(
+ /* translators: %d number of saved products. */
+ __( '%d products saved.', 'woocommerce' ),
+ successCount
+ );
+ }
+
+ if ( successCount === 0 ) {
+ if ( failedCount === 1 ) {
+ return __( 'Failed to save product.', 'woocommerce' );
+ }
+
+ return sprintf(
+ /* translators: %d number of products that could not be saved. */
+ __( 'Failed to save %d products.', 'woocommerce' ),
+ failedCount
+ );
+ }
+
+ return sprintf(
+ /* translators: 1: successful products count, 2: failed products count. */
+ __(
+ 'Saved %1$d products. %2$d products could not be saved.',
+ 'woocommerce'
+ ),
+ successCount,
+ failedCount
+ );
+}
+
+function ProductEditForm( {
+ editableFields,
+ hasEdits,
+ isSaving,
+ onChange,
+ onClose,
+ onSave,
+ selectedProducts,
+}: ProductEditFormProps ) {
+ const mergedData = buildMergedProductEditData( selectedProducts );
+ const visibleFields = getVisibleProductEditFields(
+ editableFields,
+ mergedData
+ );
+
+ const form = {
+ type: 'regular' as const,
+ labelPosition: 'top' as const,
+ fields: visibleFields.map( ( field ) => field.id ),
+ };
+
+ return (
+ <>
+ <div className="woocommerce-product-edit__form">
+ <DataForm
+ data={ mergedData }
+ fields={ visibleFields }
+ form={ form }
+ onChange={ onChange }
+ />
+ </div>
+ <div className="woocommerce-product-edit__footer">
+ <Button
+ variant="tertiary"
+ onClick={ onClose }
+ disabled={ isSaving }
+ >
+ { __( 'Cancel', 'woocommerce' ) }
+ </Button>
+ <Button
+ variant="primary"
+ onClick={ onSave }
+ isBusy={ isSaving }
+ disabled={ isSaving || ! hasEdits }
+ >
+ { __( 'Save', 'woocommerce' ) }
+ </Button>
+ </div>
+ </>
+ );
+}
+
+export default function ProductEdit() {
+ const { navigate } = useHistory();
+ const { path, query = {} } = useLocation();
+ const requestedProductIdsFromRoute = getSelectionFromPostId( query.postId )
+ .map( ( postId ) => Number( postId ) )
+ .filter( ( postId ) => Number.isSafeInteger( postId ) && postId > 0 );
+ const requestedProductIds = Array.from(
+ new Set( requestedProductIdsFromRoute )
+ );
+
+ const [ isSaving, setIsSaving ] = useState( false );
+ const editableFields = getProductEditFields( productFields );
+ const {
+ selectedProducts,
+ selectedProductIds,
+ isResolving,
+ hasResolved,
+ hasMissingProducts,
+ hasEdits,
+ } = useSelect(
+ ( select ) => {
+ if ( requestedProductIds.length === 0 ) {
+ return {
+ selectedProducts: [],
+ selectedProductIds: [],
+ isResolving: false,
+ hasResolved: true,
+ hasMissingProducts: false,
+ hasEdits: false,
+ };
+ }
+
+ const coreSelect = select( coreStore );
+ const productResults = requestedProductIds.map( ( productId ) => {
+ const resolutionArgs = [ 'root', 'product', productId ];
+
+ return {
+ productId,
+ record: coreSelect.getEditedEntityRecord(
+ 'root',
+ 'product',
+ productId
+ ) as unknown as ProductEntityRecord | false | undefined,
+ isResolving: coreSelect.isResolving(
+ 'getEditedEntityRecord',
+ resolutionArgs
+ ),
+ hasFinishedResolution: coreSelect.hasFinishedResolution(
+ 'getEditedEntityRecord',
+ resolutionArgs
+ ),
+ };
+ } );
+ const products = productResults
+ .map( ( { record } ) => record )
+ .filter(
+ ( product ): product is ProductEntityRecord =>
+ product !== undefined && product !== false
+ );
+ const validSelectedProductIds = products.map(
+ ( product ) => product.id
+ );
+
+ return {
+ selectedProducts: products,
+ selectedProductIds: validSelectedProductIds,
+ isResolving: productResults.some(
+ ( result ) =>
+ result.isResolving || ! result.hasFinishedResolution
+ ),
+ hasResolved: productResults.every(
+ ( result ) => result.hasFinishedResolution
+ ),
+ hasMissingProducts: productResults.some(
+ ( result ) =>
+ result.hasFinishedResolution && result.record === false
+ ),
+ hasEdits: validSelectedProductIds.some( ( productId ) =>
+ coreSelect.hasEditsForEntityRecord(
+ 'root',
+ 'product',
+ productId
+ )
+ ),
+ };
+ },
+ [ requestedProductIds ]
+ );
+
+ const { editEntityRecord, saveEditedEntityRecord } =
+ useDispatch( coreStore );
+
+ const { createSuccessNotice, createErrorNotice } =
+ useDispatch( noticesStore );
+
+ const hasNoRequestedProducts = requestedProductIds.length === 0;
+ const isReady =
+ hasResolved &&
+ ! isResolving &&
+ ! hasMissingProducts &&
+ selectedProducts.length === requestedProductIds.length &&
+ selectedProducts.length > 0;
+
+ let title = __( 'Quick edit', 'woocommerce' );
+
+ if ( isReady ) {
+ if ( selectedProducts.length === 1 ) {
+ title = selectedProducts[ 0 ]?.name || title;
+ } else {
+ title = sprintf(
+ /* translators: %d number of selected products. */
+ __( 'Edit %d products', 'woocommerce' ),
+ selectedProducts.length
+ );
+ }
+ }
+
+ const onChange = useCallback(
+ ( changes: Partial< ProductEntityRecord > ) => {
+ selectedProductIds.forEach( ( productId ) => {
+ editEntityRecord( 'root', 'product', productId, changes );
+ } );
+ },
+ [ editEntityRecord, selectedProductIds ]
+ );
+
+ const onClose = useCallback( () => {
+ const nextQuery = {
+ ...query,
+ } as Record< string, string >;
+
+ delete nextQuery.quickEdit;
+
+ navigate( getProductListNavigationPath( path, nextQuery ) );
+ }, [ navigate, path, query ] );
+
+ const onSave = useCallback( async () => {
+ if ( selectedProductIds.length === 0 || isSaving ) {
+ return;
+ }
+
+ setIsSaving( true );
+
+ try {
+ const results = await Promise.allSettled(
+ selectedProductIds.map( ( productId ) =>
+ saveEditedEntityRecord( 'root', 'product', productId, {
+ throwOnError: true,
+ } )
+ )
+ );
+ const successfulCount = results.filter(
+ ( result ) => result.status === 'fulfilled'
+ ).length;
+ const failedCount = results.length - successfulCount;
+ const message = getSaveNoticeMessage(
+ successfulCount,
+ failedCount
+ );
+
+ if ( failedCount > 0 ) {
+ createErrorNotice( message, {
+ type: 'snackbar',
+ } );
+ return;
+ }
+
+ if ( successfulCount > 0 ) {
+ createSuccessNotice( message, {
+ type: 'snackbar',
+ } );
+ }
+ } finally {
+ setIsSaving( false );
+ }
+ }, [
+ createErrorNotice,
+ createSuccessNotice,
+ isSaving,
+ saveEditedEntityRecord,
+ selectedProductIds,
+ ] );
+
+ return (
+ <div className="woocommerce-product-edit">
+ <div className="woocommerce-product-edit__header">
+ <h2 className="woocommerce-product-edit__title">{ title }</h2>
+ <Button
+ className="woocommerce-product-edit__close"
+ icon={ closeSmall }
+ label={ __( 'Close quick edit', 'woocommerce' ) }
+ onClick={ onClose }
+ />
+ </div>
+
+ { hasNoRequestedProducts && (
+ <div className="woocommerce-product-edit__empty-state">
+ <p>
+ { __(
+ 'Select one or more products to edit them here.',
+ 'woocommerce'
+ ) }
+ </p>
+ </div>
+ ) }
+
+ { ! hasNoRequestedProducts && isResolving && (
+ <div className="woocommerce-product-edit__loading">
+ <Spinner />
+ </div>
+ ) }
+
+ { ! hasNoRequestedProducts &&
+ ! isResolving &&
+ hasMissingProducts && (
+ <div className="woocommerce-product-edit__empty-state">
+ <p>
+ { __(
+ 'Select one or more products to edit them here.',
+ 'woocommerce'
+ ) }
+ </p>
+ </div>
+ ) }
+
+ { isReady && (
+ <ProductEditForm
+ editableFields={ editableFields }
+ hasEdits={ hasEdits }
+ isSaving={ isSaving }
+ onChange={ onChange }
+ onClose={ onClose }
+ onSave={ onSave }
+ selectedProducts={ selectedProducts }
+ />
+ ) }
+ </div>
+ );
+}
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
new file mode 100644
index 00000000000..a8c2287d7f9
--- /dev/null
+++ b/packages/js/experimental-products-app/src/product-edit/utils.test.ts
@@ -0,0 +1,106 @@
+/**
+ * External dependencies
+ */
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../fields/types';
+import { buildMergedProductEditData, getProductEditFields } from './utils';
+
+describe( 'product edit utils', () => {
+ const buildProduct = (
+ overrides: Partial< ProductEntityRecord > = {}
+ ): ProductEntityRecord =>
+ ( {
+ id: 12,
+ name: 'Beanie',
+ status: 'draft',
+ categories: [],
+ tags: [],
+ images: [],
+ ...overrides,
+ } as unknown as ProductEntityRecord );
+
+ it( 'returns the original values for a single selected product', () => {
+ const product = buildProduct( {
+ name: 'Hoodie',
+ categories: [ { id: 15, name: 'Clothing' } ],
+ } );
+
+ expect( buildMergedProductEditData( [ product ] ) ).toEqual(
+ expect.objectContaining( {
+ name: 'Hoodie',
+ categories: [ { id: 15, name: 'Clothing' } ],
+ } )
+ );
+ } );
+
+ it( 'preserves shared values in a bulk selection', () => {
+ const products = [
+ buildProduct( {
+ id: 1,
+ name: 'Beanie',
+ status: 'publish',
+ } ),
+ buildProduct( {
+ id: 2,
+ name: 'Beanie',
+ status: 'publish',
+ } ),
+ ];
+
+ expect( buildMergedProductEditData( products ) ).toEqual(
+ expect.objectContaining( {
+ name: 'Beanie',
+ status: 'publish',
+ } )
+ );
+ } );
+
+ it( 'uses neutral empty values for mixed bulk field values', () => {
+ const products = [
+ buildProduct( {
+ id: 1,
+ name: 'Beanie',
+ categories: [ { id: 15, name: 'Clothing' } ],
+ } ),
+ buildProduct( {
+ id: 2,
+ name: 'Hoodie',
+ categories: [ { id: 22, name: 'Accessories' } ],
+ } ),
+ ];
+
+ expect( buildMergedProductEditData( products ) ).toEqual(
+ expect.objectContaining( {
+ name: '',
+ categories: [],
+ } )
+ );
+ } );
+
+ it( 'excludes summary and count fields from the edit field list', () => {
+ const editFieldIds = getProductEditFields( [
+ { id: 'name' },
+ { id: 'images_count' },
+ { id: 'price_summary' },
+ { id: 'linked_products_count' },
+ { id: 'sku' },
+ ] as Field< ProductEntityRecord >[] ).map( ( field ) => field.id );
+
+ expect( editFieldIds ).not.toEqual(
+ expect.arrayContaining( [
+ 'images_count',
+ 'price_summary',
+ 'inventory_summary',
+ 'organization_summary',
+ 'visibility_summary',
+ 'downloadable_count',
+ 'shipping_summary',
+ 'linked_products_count',
+ ] )
+ );
+ } );
+} );
diff --git a/packages/js/experimental-products-app/src/product-edit/utils.ts b/packages/js/experimental-products-app/src/product-edit/utils.ts
new file mode 100644
index 00000000000..13f33abfcff
--- /dev/null
+++ b/packages/js/experimental-products-app/src/product-edit/utils.ts
@@ -0,0 +1,139 @@
+/**
+ * External dependencies
+ */
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../fields/types';
+
+export const EXCLUDED_PRODUCT_EDIT_FIELD_IDS = [
+ 'images_count',
+ 'price_summary',
+ 'inventory_summary',
+ 'organization_summary',
+ 'visibility_summary',
+ 'downloadable_count',
+ 'shipping_summary',
+ 'linked_products_count',
+] as const;
+
+const EXCLUDED_PRODUCT_EDIT_FIELD_ID_SET = new Set(
+ EXCLUDED_PRODUCT_EDIT_FIELD_IDS
+);
+
+type ProductField = Field< ProductEntityRecord >;
+
+function normalizeValue( value: unknown ) {
+ if ( value === undefined ) {
+ return '__undefined__';
+ }
+
+ return JSON.stringify( value );
+}
+
+function getMixedValueFallback( sample: unknown ) {
+ if ( Array.isArray( sample ) ) {
+ return [];
+ }
+
+ if ( sample === null ) {
+ return null;
+ }
+
+ if ( typeof sample === 'string' ) {
+ return '';
+ }
+
+ return undefined;
+}
+
+function getFieldValue( field: ProductField, item: ProductEntityRecord ) {
+ if ( typeof field.getValue === 'function' ) {
+ return field.getValue( {
+ item,
+ } );
+ }
+
+ return item[ field.id as keyof ProductEntityRecord ];
+}
+
+export function getProductEditFields( fields: ProductField[] ): ProductField[] {
+ return fields.filter(
+ ( field ) =>
+ ! EXCLUDED_PRODUCT_EDIT_FIELD_ID_SET.has(
+ field.id as ( typeof EXCLUDED_PRODUCT_EDIT_FIELD_IDS )[ number ]
+ )
+ );
+}
+
+export function buildMergedProductEditData(
+ products: ProductEntityRecord[]
+): ProductEntityRecord {
+ if ( products.length === 0 ) {
+ return {} as ProductEntityRecord;
+ }
+
+ const mergedData: Record< string, unknown > = {};
+ const keys = Array.from(
+ new Set( products.flatMap( ( product ) => Object.keys( product ) ) )
+ );
+
+ keys.forEach( ( key ) => {
+ const values = products.map(
+ ( product ) => product[ key as keyof ProductEntityRecord ]
+ );
+ const firstDefinedValue = values.find(
+ ( value ) => value !== undefined
+ );
+ const areValuesEqual = values.every(
+ ( value ) =>
+ normalizeValue( value ) === normalizeValue( values[ 0 ] )
+ );
+
+ mergedData[ key ] = areValuesEqual
+ ? values[ 0 ]
+ : getMixedValueFallback( firstDefinedValue );
+ } );
+
+ return mergedData as ProductEntityRecord;
+}
+
+export function getMixedProductEditFieldIds(
+ fields: ProductField[],
+ products: ProductEntityRecord[]
+) {
+ if ( products.length <= 1 ) {
+ return [];
+ }
+
+ return fields.reduce< string[] >( ( mixedFields, field ) => {
+ const values = products.map( ( product ) =>
+ getFieldValue( field, product )
+ );
+ const isMixed = values.some(
+ ( value ) =>
+ normalizeValue( value ) !== normalizeValue( values[ 0 ] )
+ );
+
+ if ( isMixed ) {
+ mixedFields.push( field.id );
+ }
+
+ return mixedFields;
+ }, [] );
+}
+
+export function getVisibleProductEditFields(
+ fields: ProductField[],
+ data: ProductEntityRecord
+) {
+ return fields.filter( ( field ) => {
+ if ( typeof field.isVisible !== 'function' ) {
+ return true;
+ }
+
+ return field.isVisible( data );
+ } );
+}
diff --git a/packages/js/experimental-products-app/src/router.tsx b/packages/js/experimental-products-app/src/router.tsx
index fe995bb0f2e..7cd6ee5217e 100644
--- a/packages/js/experimental-products-app/src/router.tsx
+++ b/packages/js/experimental-products-app/src/router.tsx
@@ -8,6 +8,7 @@ import { privateApis as routerPrivateApis } from '@wordpress/router';
*/
import { unlock } from './lock-unlock';
import ProductList from './product-list';
+import ProductEdit from './product-edit';
const { useLocation } = unlock( routerPrivateApis );
@@ -28,14 +29,21 @@ export type Route = {
};
export default function useLayoutAreas() {
- const { params = {} } = useLocation();
- const { postType = 'product', canvas, quickEdit: showQuickEdit } = params;
+ const { params = {}, query = {} } = useLocation();
+ const postType = params.postType ?? query.postType ?? 'product';
+ const canvas = params.canvas ?? query.canvas;
+ const showQuickEdit =
+ params.quickEdit === 'true' ||
+ query.quickEdit === 'true' ||
+ params.quickEdit === true ||
+ query.quickEdit === true;
// Products list.
if ( [ 'product' ].includes( postType ) ) {
return {
key: 'products-list',
areas: {
content: <ProductList />,
+ edit: showQuickEdit ? <ProductEdit /> : undefined,
preview: false,
mobile: <ProductList postType={ postType } />,
},
diff --git a/packages/js/experimental-products-app/src/style.scss b/packages/js/experimental-products-app/src/style.scss
index 6c4dff50ba0..943196696c1 100644
--- a/packages/js/experimental-products-app/src/style.scss
+++ b/packages/js/experimental-products-app/src/style.scss
@@ -17,6 +17,17 @@
@include snackbar-container;
}
+.product_page_woocommerce-products-dashboard .edit-site-layout__content {
+ display: flex;
+ align-items: stretch;
+}
+
+.product_page_woocommerce-products-dashboard
+ .edit-site-layout__area:not(.woocommerce-product-edit-layout__region) {
+ flex: 1 1 auto;
+ min-width: 0;
+}
+
.woocommerce-product-list {
height: 85vh;
}
@@ -28,3 +39,59 @@
.woocommerce-product-list .dataviews-wrapper {
height: 100%;
}
+
+.woocommerce-product-edit-layout__region {
+ flex: 0 0 380px;
+ height: 85vh;
+ min-width: 380px;
+ max-width: 380px !important;
+}
+
+.woocommerce-product-edit-layout__panel {
+ height: 100%;
+ background: #fcfcfc;
+ border-left: 1px solid var(--wp-components-color-gray-300, #ddd);
+ box-shadow: -1px 0 0 rgba(0, 0, 0, 0.02);
+}
+
+.woocommerce-product-edit {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ background: #fcfcfc;
+}
+
+.woocommerce-product-edit__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 12px 16px;
+ border-bottom: 1px solid var(--wp-components-color-gray-300, #ddd);
+}
+
+.woocommerce-product-edit__title {
+ margin: 0;
+ font-size: 15px;
+ font-weight: 500;
+ line-height: 20px;
+ color: var(--wp-components-color-foreground, #1e1e1e);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.woocommerce-product-edit__loading,
+.woocommerce-product-edit__empty-state {
+ display: flex;
+ flex: 1;
+ align-items: center;
+ justify-content: center;
+ padding: var(--wpds-dimension-padding-xl, 20px);
+}
+
+.woocommerce-product-edit__form {
+ flex: 1;
+ overflow: auto;
+ padding: 16px;
+}
diff --git a/plugins/woocommerce/changelog/64481-add-dataform-component b/plugins/woocommerce/changelog/64481-add-dataform-component
new file mode 100644
index 00000000000..e409db96fdf
--- /dev/null
+++ b/plugins/woocommerce/changelog/64481-add-dataform-component
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add quick edit and bulk product actions to the experimental products app.
\ No newline at end of file
diff --git a/plugins/woocommerce/changelog/add-dataform-component b/plugins/woocommerce/changelog/add-dataform-component
new file mode 100644
index 00000000000..a75b2634bfb
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-dataform-component
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+DataViews - All Products: add quick edit panel