Commit f97c0e232e9 for woocommerce

commit f97c0e232e9a986e830a2647e5f6a6550e794ab4
Author: Luigi Teschio <gigitux@gmail.com>
Date:   Mon May 18 13:46:37 2026 +0200

    Add downloadable files section to products app (#65124)

    * Add downloadable files section and upload handling

    * Remove duplicate Base UI dependency

    * Add changelog entry for downloadable files update

    * address feedback

diff --git a/packages/js/experimental-products-app/changelog/add-downloadable-files-section b/packages/js/experimental-products-app/changelog/add-downloadable-files-section
new file mode 100644
index 00000000000..f9c7a7f47db
--- /dev/null
+++ b/packages/js/experimental-products-app/changelog/add-downloadable-files-section
@@ -0,0 +1,4 @@
+Significance: patch
+Type: update
+
+Add a downloadable files section to the experimental products edit panel and upload selected files to the media library.
diff --git a/packages/js/experimental-products-app/package.json b/packages/js/experimental-products-app/package.json
index 9d42777f0ab..130d7786b41 100644
--- a/packages/js/experimental-products-app/package.json
+++ b/packages/js/experimental-products-app/package.json
@@ -60,7 +60,6 @@
 		"@wordpress/api-fetch": "7.44.0",
 		"@wordpress/url": "catalog:wp-min",
 		"@wordpress/base-styles": "6.20.0",
-		"@base-ui/react": "1.4.1",
 		"clsx": "2.1.x",
 		"react": "18.3.x",
 		"react-dom": "18.3.x"
diff --git a/packages/js/experimental-products-app/src/fields/components/list-item/index.tsx b/packages/js/experimental-products-app/src/fields/components/list-item/index.tsx
index ff68535b7ba..a21ce73ec11 100644
--- a/packages/js/experimental-products-app/src/fields/components/list-item/index.tsx
+++ b/packages/js/experimental-products-app/src/fields/components/list-item/index.tsx
@@ -35,6 +35,7 @@ export const ListItem = ( {
 					align="center"
 					justify="space-between"
 					className="woocommerce-list-item"
+					gap={ 'md' }
 				>
 					{ item.thumbnail && (
 						<div className="woocommerce-list-item__thumbnail">
diff --git a/packages/js/experimental-products-app/src/fields/downloadable/field.test.tsx b/packages/js/experimental-products-app/src/fields/downloadable/field.test.tsx
new file mode 100644
index 00000000000..62ab3391eca
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/downloadable/field.test.tsx
@@ -0,0 +1,232 @@
+/**
+ * External dependencies
+ */
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import type { DataFormControlProps } from '@wordpress/dataviews';
+import React from 'react';
+
+const mockUploadMedia = jest.fn();
+
+jest.mock( '@wordpress/components', () => {
+	const ReactActual = jest.requireActual( 'react' );
+
+	return {
+		FormFileUpload: ( {
+			children,
+			className,
+			onChange,
+		}: {
+			children?: React.ReactNode;
+			className?: string;
+			onChange?: React.ChangeEventHandler< HTMLInputElement >;
+		} ) =>
+			ReactActual.createElement(
+				'label',
+				null,
+				ReactActual.createElement( 'input', {
+					className,
+					onChange,
+					type: 'file',
+				} ),
+				children
+			),
+		Button: ( {
+			children,
+			onClick,
+			'aria-label': ariaLabel,
+		}: {
+			children?: React.ReactNode;
+			onClick?: React.MouseEventHandler< HTMLButtonElement >;
+			'aria-label'?: string;
+		} ) =>
+			ReactActual.createElement(
+				'button',
+				{
+					'aria-label': ariaLabel,
+					onClick,
+					type: 'button',
+				},
+				children
+			),
+	};
+} );
+
+jest.mock( '@wordpress/media-utils', () => ( {
+	uploadMedia: ( args: unknown ) => mockUploadMedia( args ),
+} ) );
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+import { fieldExtensions } from './field';
+
+describe( 'downloadable field', () => {
+	const buildProduct = (
+		overrides: Partial< ProductEntityRecord > = {}
+	): ProductEntityRecord =>
+		( {
+			id: 12,
+			name: 'Beanie',
+			downloadable: true,
+			downloads: [],
+			...overrides,
+		} as ProductEntityRecord );
+
+	const renderEdit = ( data: ProductEntityRecord, onChange = jest.fn() ) => {
+		if ( ! fieldExtensions.Edit ) {
+			throw new Error( 'downloadable edit not implemented' );
+		}
+
+		const Edit = fieldExtensions.Edit as React.ComponentType<
+			DataFormControlProps< ProductEntityRecord >
+		>;
+
+		const view = render(
+			<Edit
+				data={ data }
+				field={
+					{
+						...fieldExtensions,
+						id: 'downloadable',
+						label: 'Downloadable',
+					} as DataFormControlProps< ProductEntityRecord >[ 'field' ]
+				}
+				onChange={ onChange }
+			/>
+		);
+
+		return {
+			...view,
+			onChange,
+		};
+	};
+
+	beforeEach( () => {
+		jest.clearAllMocks();
+		Object.defineProperty( URL, 'createObjectURL', {
+			writable: true,
+			value: jest.fn( () => 'blob:download-file' ),
+		} );
+		Object.defineProperty( URL, 'revokeObjectURL', {
+			writable: true,
+			value: jest.fn(),
+		} );
+	} );
+
+	it( 'uploads selected files to the media library before saving them as downloads', () => {
+		const { container, onChange } = renderEdit( buildProduct() );
+		const file = new File( [ 'manual' ], 'manual.pdf', {
+			type: 'application/pdf',
+		} );
+		const fileInput = container.querySelector(
+			'input[type="file"]'
+		) as HTMLInputElement;
+
+		fireEvent.change( fileInput, {
+			target: {
+				files: [ file ],
+			},
+		} );
+
+		expect( screen.getByText( /manual\.pdf/ ) ).toHaveTextContent(
+			'manual.pdf - uploading…'
+		);
+		expect( onChange ).not.toHaveBeenCalled();
+		expect( mockUploadMedia ).toHaveBeenCalledWith(
+			expect.objectContaining( {
+				filesList: [ file ],
+				onFileChange: expect.any( Function ),
+				onError: expect.any( Function ),
+			} )
+		);
+
+		act( () => {
+			mockUploadMedia.mock.calls[ 0 ][ 0 ].onFileChange( [
+				{
+					id: 34,
+					url: 'https://example.com/wp-content/uploads/manual.pdf',
+					title: 'Product manual',
+				},
+			] );
+		} );
+
+		expect( URL.revokeObjectURL ).toHaveBeenCalledWith(
+			'blob:download-file'
+		);
+		expect( onChange ).toHaveBeenCalledTimes( 1 );
+		expect( onChange ).toHaveBeenCalledWith( {
+			downloads: [
+				{
+					id: '34',
+					file: 'https://example.com/wp-content/uploads/manual.pdf',
+					name: 'Product manual',
+				},
+			],
+		} );
+		expect( screen.getByText( 'Product manual' ) ).toBeInTheDocument();
+	} );
+
+	it( 'removes the temporary download when upload fails', () => {
+		const { container, onChange } = renderEdit( buildProduct() );
+		const file = new File( [ 'manual' ], 'manual.pdf', {
+			type: 'application/pdf',
+		} );
+		const fileInput = container.querySelector(
+			'input[type="file"]'
+		) as HTMLInputElement;
+
+		fireEvent.change( fileInput, {
+			target: {
+				files: [ file ],
+			},
+		} );
+
+		act( () => {
+			mockUploadMedia.mock.calls[ 0 ][ 0 ].onError();
+		} );
+
+		expect( URL.revokeObjectURL ).toHaveBeenCalledWith(
+			'blob:download-file'
+		);
+		expect( onChange ).not.toHaveBeenCalled();
+		expect( screen.queryByText( /manual\.pdf/ ) ).not.toBeInTheDocument();
+	} );
+
+	it.each( [
+		[ 'missing media ID', { url: 'https://example.com/manual.pdf' } ],
+		[ 'missing media URL', { id: 34 } ],
+	] )(
+		'removes the temporary download when uploaded attachment has a %s',
+		( _name, attachment ) => {
+			const { container, onChange } = renderEdit( buildProduct() );
+			const file = new File( [ 'manual' ], 'manual.pdf', {
+				type: 'application/pdf',
+			} );
+			const fileInput = container.querySelector(
+				'input[type="file"]'
+			) as HTMLInputElement;
+
+			fireEvent.change( fileInput, {
+				target: {
+					files: [ file ],
+				},
+			} );
+
+			act( () => {
+				mockUploadMedia.mock.calls[ 0 ][ 0 ].onFileChange( [
+					attachment,
+				] );
+			} );
+
+			expect( URL.revokeObjectURL ).toHaveBeenCalledWith(
+				'blob:download-file'
+			);
+			expect( onChange ).not.toHaveBeenCalled();
+			expect(
+				screen.queryByText( /manual\.pdf/ )
+			).not.toBeInTheDocument();
+		}
+	);
+} );
diff --git a/packages/js/experimental-products-app/src/fields/downloadable/field.tsx b/packages/js/experimental-products-app/src/fields/downloadable/field.tsx
index e5f9b2751dd..293ec1550c7 100644
--- a/packages/js/experimental-products-app/src/fields/downloadable/field.tsx
+++ b/packages/js/experimental-products-app/src/fields/downloadable/field.tsx
@@ -2,10 +2,17 @@
  * External dependencies
  */
 import { __ } from '@wordpress/i18n';
-
 import { FormFileUpload } from '@wordpress/components';
-
+import {
+	useCallback,
+	useEffect,
+	useMemo,
+	useRef,
+	useState,
+} from '@wordpress/element';
 import type { Field } from '@wordpress/dataviews';
+import { uploadMedia } from '@wordpress/media-utils';
+import type { Attachment } from '@wordpress/media-utils';

 /**
  * Internal dependencies
@@ -26,6 +33,32 @@ import {

 const uploadingLabel = __( 'uploading…', 'woocommerce' );

+const getAttachmentTextValue = ( value: unknown ) => {
+	return typeof value === 'string' ? value : '';
+};
+
+const toUploadedDownload = (
+	attachment: Attachment,
+	fallbackFile: File
+): UploadedItem | undefined => {
+	const file = getAttachmentTextValue( attachment.url );
+
+	if ( ! file ) {
+		return undefined;
+	}
+
+	return {
+		id: attachment.id ? String( attachment.id ) : undefined,
+		file,
+		name:
+			getAttachmentTextValue( attachment.title ) ||
+			getAttachmentTextValue( attachment.alt ) ||
+			getAttachmentTextValue( attachment.caption ) ||
+			fallbackFile.name ||
+			getLastPathFromUrl( file ),
+	};
+};
+
 const fieldDefinition = {
 	type: 'boolean',
 	label: __( 'Downloadable', 'woocommerce' ),
@@ -41,34 +74,124 @@ export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
 		item.downloadable === true && item.type !== 'variable',
 	getValue: ( { item } ) => item.downloadable,
 	Edit: ( { data, onChange } ) => {
-		const downloads = ( data.downloads ?? [] ) as Array<
-			UploadedItem | ItemToUpload
-		>;
+		const dataDownloads = useMemo(
+			() =>
+				( data.downloads ?? [] ) as Array<
+					UploadedItem | ItemToUpload
+				>,
+			[ data.downloads ]
+		);
+		const [ downloads, setDownloads ] = useState( dataDownloads );
+		const downloadsRef = useRef( dataDownloads );
+
+		useEffect( () => {
+			downloadsRef.current = dataDownloads;
+			setDownloads( dataDownloads );
+		}, [ dataDownloads ] );
+
+		const setVisibleDownloads = useCallback(
+			( nextDownloads: Array< UploadedItem | ItemToUpload > ) => {
+				downloadsRef.current = nextDownloads;
+				setDownloads( nextDownloads );
+			},
+			[]
+		);
+
+		const commitDownloads = useCallback(
+			( nextDownloads: Array< UploadedItem | ItemToUpload > ) => {
+				setVisibleDownloads( nextDownloads );
+				onChange( {
+					downloads: nextDownloads,
+				} );
+			},
+			[ onChange, setVisibleDownloads ]
+		);

 		const filesToUpload = downloads.filter( isItemToUpload );
 		const uploadedFiles = downloads.filter( isUploadedItem );

-		const handleRemoveDownload = ( fileId: string | number ) => {
-			onChange( {
-				downloads: downloads.filter(
-					( download ) => download.file !== fileId
-				),
-			} );
-		};
-
-		const handleAddDownload = ( file: File ) => {
-			const objectUrl = URL.createObjectURL( file );
-			onChange( {
-				downloads: [
-					...downloads,
-					{
-						file: objectUrl,
-						name: file.name,
-						type: file.type,
+		const handleRemoveDownload = useCallback(
+			( fileId: string | number ) => {
+				commitDownloads(
+					downloadsRef.current.filter(
+						( download ) => download.file !== fileId
+					)
+				);
+			},
+			[ commitDownloads ]
+		);
+
+		const handleAddDownload = useCallback(
+			( file: File ) => {
+				const objectUrl = URL.createObjectURL( file );
+				const placeholderDownload: ItemToUpload = {
+					file: objectUrl,
+					name: file.name,
+					type: file.type,
+				};
+
+				setVisibleDownloads( [
+					...downloadsRef.current,
+					placeholderDownload,
+				] );
+
+				uploadMedia( {
+					filesList: [ file ],
+					onFileChange( attachments ) {
+						const attachment = attachments[ 0 ] as
+							| Attachment
+							| undefined;
+
+						const uploadedDownload = attachment?.id
+							? toUploadedDownload( attachment, file )
+							: undefined;
+
+						if ( ! uploadedDownload ) {
+							URL.revokeObjectURL( objectUrl );
+							setVisibleDownloads(
+								downloadsRef.current.filter(
+									( download ) => download.file !== objectUrl
+								)
+							);
+							return;
+						}
+
+						const hasPlaceholder = downloadsRef.current.some(
+							( download ) => download.file === objectUrl
+						);
+
+						if ( ! hasPlaceholder ) {
+							URL.revokeObjectURL( objectUrl );
+							return;
+						}
+
+						const currentDownloads = downloadsRef.current.filter(
+							( download ) => download.file !== objectUrl
+						);
+						const isAlreadyAdded = currentDownloads.some(
+							( download ) =>
+								download.file === uploadedDownload.file
+						);
+
+						URL.revokeObjectURL( objectUrl );
+						commitDownloads(
+							isAlreadyAdded
+								? currentDownloads
+								: [ ...currentDownloads, uploadedDownload ]
+						);
+					},
+					onError() {
+						URL.revokeObjectURL( objectUrl );
+						setVisibleDownloads(
+							downloadsRef.current.filter(
+								( download ) => download.file !== objectUrl
+							)
+						);
 					},
-				],
-			} );
-		};
+				} );
+			},
+			[ commitDownloads, setVisibleDownloads ]
+		);

 		const items = [
 			...uploadedFiles.map( ( file ) => {
@@ -111,6 +234,7 @@ export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
 					/>
 				) }
 				<FormFileUpload
+					__next40pxDefaultSize
 					className="woocommerce-fields-field__downloadable-upload-button"
 					onChange={ ( event ) => {
 						const file = event?.currentTarget.files?.[ 0 ];
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 30fa0b192ef..bb0cf414165 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
@@ -732,7 +732,7 @@ describe( 'product edit utils', () => {
 				} ),
 			] );

-			expectFieldsHidden( fieldIds, [ 'downloadable' ] );
+			expect( fieldIds ).toContain( 'downloadable' );
 			expectFieldsHidden( fieldIds, shippingFieldIds );
 		} );

@@ -827,7 +827,7 @@ describe( 'product edit utils', () => {
 			] );
 		} );

-		it( 'hides downloadable fields for simple product and variation selections', () => {
+		it( 'shows downloadable fields for simple product and variation selections', () => {
 			const fieldIds = getVisibleFieldIds( [
 				buildProduct( {
 					id: 1,
@@ -842,7 +842,7 @@ describe( 'product edit utils', () => {
 				} ),
 			] );

-			expectFieldsHidden( fieldIds, [ 'downloadable' ] );
+			expect( fieldIds ).toContain( 'downloadable' );
 		} );

 		it( 'hides downloadable fields unless every bulk item supports downloads', () => {
@@ -1014,7 +1014,12 @@ describe( 'product edit utils', () => {
 				{
 					id: 'image-fields',
 					label: 'Images',
-					children: [ 'images', 'downloadable' ],
+					children: [ 'images' ],
+				},
+				{
+					id: 'downloadable-files-fields',
+					label: 'Downloadable files',
+					children: [ 'downloadable' ],
 				},
 				{
 					id: 'inventory-fields',
@@ -1237,6 +1242,11 @@ describe( 'product edit utils', () => {
 					label: 'Images',
 					children: [ 'images' ],
 				},
+				{
+					id: 'downloadable-files-fields',
+					label: 'Downloadable files',
+					children: [ 'downloadable' ],
+				},
 				{
 					id: 'inventory-fields',
 					label: 'Inventory',
diff --git a/packages/js/experimental-products-app/src/product-edit/utils.ts b/packages/js/experimental-products-app/src/product-edit/utils.ts
index e31f7005d2f..8f2d9f2522d 100644
--- a/packages/js/experimental-products-app/src/product-edit/utils.ts
+++ b/packages/js/experimental-products-app/src/product-edit/utils.ts
@@ -102,6 +102,13 @@ function createProductEditFormGroup(
 	};
 }

+const DOWNLOADABLE_FILES_FORM_FIELD: ProductEditFormField =
+	createProductEditFormGroup(
+		'downloadable-files-fields',
+		__( 'Downloadable files', 'woocommerce' ),
+		[ 'downloadable' ]
+	);
+
 const SIMPLE_PRODUCT_EDIT_FORM_FIELDS = [
 	createProductEditFormGroup(
 		'general-fields',
@@ -120,8 +127,8 @@ const SIMPLE_PRODUCT_EDIT_FORM_FIELDS = [
 	] ),
 	createProductEditFormGroup( 'image-fields', __( 'Images', 'woocommerce' ), [
 		'images',
-		'downloadable',
 	] ),
+	DOWNLOADABLE_FILES_FORM_FIELD,
 	createProductEditFormGroup(
 		'inventory-fields',
 		__( 'Inventory', 'woocommerce' ),
@@ -158,6 +165,7 @@ const VARIATION_PRODUCT_EDIT_FORM_FIELDS = [
 	createProductEditFormGroup( 'image-fields', __( 'Images', 'woocommerce' ), [
 		'images',
 	] ),
+	DOWNLOADABLE_FILES_FORM_FIELD,
 	createProductEditFormGroup(
 		'inventory-fields',
 		__( 'Inventory', 'woocommerce' ),