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' ),