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