Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion packages/media-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@
"@wordpress/icons": "file:../icons",
"@wordpress/media-fields": "file:../media-fields",
"@wordpress/notices": "file:../notices",
"@wordpress/private-apis": "file:../private-apis"
"@wordpress/private-apis": "file:../private-apis",
"@wordpress/ui": "file:../ui",
"clsx": "^2.1.1"
},
"peerDependencies": {
"react": "^18.0.0"
Expand Down
236 changes: 130 additions & 106 deletions packages/media-utils/src/components/media-upload-modal/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
/**
* External dependencies
*/
import clsx from 'clsx';

/**
* WordPress dependencies
*/
Expand All @@ -6,6 +11,8 @@ import {
useState,
useCallback,
useMemo,
useRef,
useEffect,
} from '@wordpress/element';
import { __, sprintf, _n } from '@wordpress/i18n';
import {
Expand All @@ -17,6 +24,7 @@ import { Modal, DropZone, FormFileUpload, Button } from '@wordpress/components';
import { upload as uploadIcon } from '@wordpress/icons';
import { DataViewsPicker } from '@wordpress/dataviews';
import type { View, Field, ActionButton } from '@wordpress/dataviews';
import { Stack } from '@wordpress/ui';
import {
altTextField,
attachedToField,
Expand All @@ -32,7 +40,6 @@ import {
mimeTypeField,
} from '@wordpress/media-fields';
import { store as noticesStore, SnackbarNotices } from '@wordpress/notices';
import { isBlobURL } from '@wordpress/blob';

/**
* Internal dependencies
Expand All @@ -41,6 +48,8 @@ import type { Attachment, RestAttachment } from '../../utils/types';
import { transformAttachment } from '../../utils/transform-attachment';
import { uploadMedia } from '../../utils/upload-media';
import { unlock } from '../../lock-unlock';
import { UploadStatusPopover } from './upload-status-popover';
import { useUploadStatus } from './use-upload-status';

const { useEntityRecordsWithPermissions } = unlock( coreDataPrivateApis );

Expand Down Expand Up @@ -173,7 +182,7 @@ export function MediaUploadModal( {
: [ String( value ) ];
} );

const { createSuccessNotice, createErrorNotice, createInfoNotice } =
const { createSuccessNotice, removeAllNotices } =
useDispatch( noticesStore );
const { invalidateResolution } = useDispatch( coreStore );

Expand Down Expand Up @@ -244,6 +253,55 @@ export function MediaUploadModal( {
};
}, [ view, allowedTypes ] );

// Per-batch completion handler: auto-select uploaded items and refresh the grid.
const handleBatchComplete = useCallback(
( attachments: Partial< Attachment >[] ) => {
const uploadedIds = attachments
.map( ( attachment ) => String( attachment.id ) )
.filter( Boolean );

if ( multiple ) {
setSelection( ( prev ) => {
const existing = new Set( prev );
const newIds = uploadedIds.filter(
( id ) => ! existing.has( id )
);
return [ ...prev, ...newIds ];
} );
} else {
setSelection( uploadedIds.slice( 0, 1 ) );
}

// Invalidate immediately so newly uploaded files appear in the grid.
// The server has already returned 201 responses at this point.
invalidateResolution( 'getEntityRecords', [
'postType',
'attachment',
queryArgs,
] );
},
[ multiple, invalidateResolution, queryArgs ]
);

const {
uploadingFiles,
registerBatch,
dismissError,
clearCompleted,
allComplete,
} = useUploadStatus( { onBatchComplete: handleBatchComplete } );

const isPopoverOpenRef = useRef( false );
const handlePopoverOpenChange = useCallback(
( open: boolean ) => {
isPopoverOpenRef.current = open;
if ( ! open ) {
clearCompleted();
}
},
[ clearCompleted ]
);

// Fetch all media attachments using WordPress core data with permissions
const {
records: mediaRecords,
Expand Down Expand Up @@ -288,7 +346,7 @@ export function MediaUploadModal( {
() => [
{
id: 'select',
label: multiple ? __( 'Select' ) : __( 'Select' ),
label: __( 'Select' ),
isPrimary: true,
supportsBulk: multiple,
async callback() {
Expand Down Expand Up @@ -318,127 +376,73 @@ export function MediaUploadModal( {
? transformedPosts
: transformedPosts?.[ 0 ];

removeAllNotices( 'snackbar', NOTICES_CONTEXT );
onSelect( selectedItems );
},
},
],
[ multiple, onSelect, selection ]
[ multiple, onSelect, selection, removeAllNotices ]
);

const handleModalClose = useCallback( () => {
removeAllNotices( 'snackbar', NOTICES_CONTEXT );
onClose?.();
}, [ onClose ] );
}, [ removeAllNotices, onClose ] );

// Use onUpload if provided, otherwise fall back to uploadMedia
const handleUpload = onUpload || uploadMedia;

// Shared upload success handler
const handleUploadComplete = useCallback(
( attachments: Partial< Attachment >[] ) => {
// Check if all uploads are complete (no blob URLs)
const allComplete = attachments.every(
( attachment ) =>
attachment.id &&
attachment.url &&
! isBlobURL( attachment.url )
);

if ( allComplete && attachments.length > 0 ) {
// Show success notice (replaces progress notice via ID)
// Show success notice and auto-clear completed entries when all batches finish.
const prevAllCompleteRef = useRef( false );
useEffect( () => {
if ( allComplete && ! prevAllCompleteRef.current ) {
const completeCount = uploadingFiles.filter(
( file ) => file.status === 'uploaded'
).length;
if ( completeCount > 0 ) {
createSuccessNotice(
sprintf(
// translators: %s: number of files
_n(
'Uploaded %s file',
'Uploaded %s files',
attachments.length
completeCount
),
attachments.length.toLocaleString()
completeCount.toLocaleString()
),
{
type: 'snackbar',
context: NOTICES_CONTEXT,
id: NOTICE_ID_UPLOAD_PROGRESS,
}
);

// Auto-select the newly uploaded items
const uploadedIds = attachments
.map( ( attachment ) => String( attachment.id ) )
.filter( Boolean );

if ( multiple ) {
// In multiple mode, add to existing selection
setSelection( ( prev ) => [ ...prev, ...uploadedIds ] );
} else {
// In single mode, replace selection with the first uploaded item
setSelection( uploadedIds.slice( 0, 1 ) );
}

// Invalidate the entity records resolution to refresh the view
invalidateResolution( 'getEntityRecords', [
'postType',
'attachment',
queryArgs,
] );
}
},
[ createSuccessNotice, invalidateResolution, queryArgs, multiple ]
);

// Shared upload error handler
const handleUploadError = useCallback(
( error: Error ) => {
// Show error notice (replaces progress notice via ID)
createErrorNotice( error.message, {
type: 'snackbar',
context: NOTICES_CONTEXT,
id: NOTICE_ID_UPLOAD_PROGRESS,
} );
},
[ createErrorNotice ]
);
// Auto-clear completed entries, unless the popover is
// open — in that case, they'll be cleared on close.
if ( ! isPopoverOpenRef.current ) {
clearCompleted();
}
}
prevAllCompleteRef.current = allComplete;
}, [ allComplete, uploadingFiles, createSuccessNotice, clearCompleted ] );

const handleFileSelect = useCallback(
( event: React.ChangeEvent< HTMLInputElement > ) => {
const files = event.target.files;
if ( files && files.length > 0 ) {
const filesArray = Array.from( files );

// Show upload start notice
createInfoNotice(
sprintf(
// translators: %s: number of files
_n(
'Uploading %s file',
'Uploading %s files',
filesArray.length
),
filesArray.length.toLocaleString()
),
{
type: 'snackbar',
context: NOTICES_CONTEXT,
id: NOTICE_ID_UPLOAD_PROGRESS,
explicitDismiss: true,
}
);
const { onFileChange, onError } = registerBatch( filesArray );

handleUpload( {
allowedTypes,
filesList: filesArray,
onFileChange: handleUploadComplete,
onError: handleUploadError,
onFileChange,
onError,
} );
}
},
[
allowedTypes,
handleUpload,
createInfoNotice,
handleUploadComplete,
handleUploadError,
]
[ allowedTypes, handleUpload, registerBatch ]
);

const paginationInfo = useMemo(
Expand Down Expand Up @@ -525,30 +529,14 @@ export function MediaUploadModal( {
);
}
if ( filteredFiles.length > 0 ) {
// Show upload start notice
createInfoNotice(
sprintf(
// translators: %s: number of files
_n(
'Uploading %s file',
'Uploading %s files',
filteredFiles.length
),
filteredFiles.length.toLocaleString()
),
{
type: 'snackbar',
context: NOTICES_CONTEXT,
id: NOTICE_ID_UPLOAD_PROGRESS,
explicitDismiss: true,
}
);
const { onFileChange, onError } =
registerBatch( filteredFiles );

handleUpload( {
allowedTypes,
filesList: filteredFiles,
onFileChange: handleUploadComplete,
onError: handleUploadError,
onFileChange,
onError,
} );
}
} }
Expand All @@ -566,10 +554,46 @@ export function MediaUploadModal( {
paginationInfo={ paginationInfo }
defaultLayouts={ defaultLayouts }
getItemId={ ( item: RestAttachment ) => String( item.id ) }
search={ search }
searchLabel={ searchLabel }
itemListLabel={ __( 'Media items' ) }
/>
>
<Stack
direction="row"
align="top"
justify="space-between"
className="dataviews__view-actions"
gap="xs"
>
<Stack
direction="row"
gap="sm"
justify="start"
className="dataviews__search"
>
{ search && (
<DataViewsPicker.Search label={ searchLabel } />
) }
<DataViewsPicker.FiltersToggle />
</Stack>
<Stack direction="row" gap="xs" style={ { flexShrink: 0 } }>
<DataViewsPicker.LayoutSwitcher />
<DataViewsPicker.ViewConfig />
</Stack>
</Stack>
<DataViewsPicker.FiltersToggled className="dataviews-filters__container" />
<DataViewsPicker.Layout />
<div
className={ clsx( 'media-upload-modal__footer', {
'is-uploading': uploadingFiles.length > 0,
} ) }
>
<UploadStatusPopover
uploadingFiles={ uploadingFiles }
onDismissError={ dismissError }
onOpenChange={ handlePopoverOpenChange }
/>
<DataViewsPicker.BulkActionToolbar />
</div>
</DataViewsPicker>
{ createPortal(
<SnackbarNotices
className="media-upload-modal__snackbar"
Expand Down
Loading
Loading