@wordpress/media-utils 5.41.1-next.v.202603161435.0 → 5.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/build/components/media-upload-modal/index.cjs +133 -87
  3. package/build/components/media-upload-modal/index.cjs.map +3 -3
  4. package/build/components/media-upload-modal/upload-status-popover.cjs +156 -0
  5. package/build/components/media-upload-modal/upload-status-popover.cjs.map +7 -0
  6. package/build/components/media-upload-modal/use-invalidate-attachment-resolutions.cjs +45 -0
  7. package/build/components/media-upload-modal/use-invalidate-attachment-resolutions.cjs.map +7 -0
  8. package/build/components/media-upload-modal/use-upload-status.cjs +127 -0
  9. package/build/components/media-upload-modal/use-upload-status.cjs.map +7 -0
  10. package/build-module/components/media-upload-modal/index.mjs +126 -88
  11. package/build-module/components/media-upload-modal/index.mjs.map +2 -2
  12. package/build-module/components/media-upload-modal/upload-status-popover.mjs +131 -0
  13. package/build-module/components/media-upload-modal/upload-status-popover.mjs.map +7 -0
  14. package/build-module/components/media-upload-modal/use-invalidate-attachment-resolutions.mjs +20 -0
  15. package/build-module/components/media-upload-modal/use-invalidate-attachment-resolutions.mjs.map +7 -0
  16. package/build-module/components/media-upload-modal/use-upload-status.mjs +102 -0
  17. package/build-module/components/media-upload-modal/use-upload-status.mjs.map +7 -0
  18. package/build-style/style-rtl.css +73 -3
  19. package/build-style/style.css +73 -3
  20. package/build-types/components/media-upload-modal/index.d.ts.map +1 -1
  21. package/build-types/components/media-upload-modal/upload-status-popover.d.ts +15 -0
  22. package/build-types/components/media-upload-modal/upload-status-popover.d.ts.map +1 -0
  23. package/build-types/components/media-upload-modal/use-invalidate-attachment-resolutions.d.ts +22 -0
  24. package/build-types/components/media-upload-modal/use-invalidate-attachment-resolutions.d.ts.map +1 -0
  25. package/build-types/components/media-upload-modal/use-upload-status.d.ts +41 -0
  26. package/build-types/components/media-upload-modal/use-upload-status.d.ts.map +1 -0
  27. package/package.json +17 -15
  28. package/src/components/media-upload-modal/index.tsx +129 -107
  29. package/src/components/media-upload-modal/style.scss +88 -3
  30. package/src/components/media-upload-modal/test/use-upload-status.test.ts +501 -0
  31. package/src/components/media-upload-modal/upload-status-popover.tsx +155 -0
  32. package/src/components/media-upload-modal/use-invalidate-attachment-resolutions.ts +50 -0
  33. package/src/components/media-upload-modal/use-upload-status.ts +203 -0
@@ -0,0 +1,501 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ /**
6
+ * External dependencies
7
+ */
8
+ import { renderHook, act } from '@testing-library/react';
9
+
10
+ /**
11
+ * Internal dependencies
12
+ */
13
+ import { useUploadStatus } from '../use-upload-status';
14
+ import { UploadError } from '../../../utils/upload-error';
15
+
16
+ function createFile( name: string ): File {
17
+ return new File( [ 'content' ], name, { type: 'image/png' } );
18
+ }
19
+
20
+ function createAttachment( id: number, name: string ) {
21
+ return { id, url: `https://example.com/${ name }` };
22
+ }
23
+
24
+ function createBlobAttachment( name: string ) {
25
+ return { url: `blob:https://example.com/${ name }` };
26
+ }
27
+
28
+ function createUploadError( name: string, message = 'Upload failed' ) {
29
+ return new UploadError( {
30
+ code: 'GENERAL',
31
+ message,
32
+ file: createFile( name ),
33
+ } );
34
+ }
35
+
36
+ function statuses( result: ReturnType< typeof useUploadStatus > ): string[] {
37
+ return result.uploadingFiles.map( ( item ) => item.status );
38
+ }
39
+
40
+ type BatchCallbacks = ReturnType<
41
+ ReturnType< typeof useUploadStatus >[ 'registerBatch' ]
42
+ >;
43
+
44
+ // isBlobURL from @wordpress/blob checks for the "blob:" prefix.
45
+ jest.mock( '@wordpress/blob', () => ( {
46
+ isBlobURL: ( url: string ) => url.startsWith( 'blob:' ),
47
+ } ) );
48
+
49
+ describe( 'useUploadStatus', () => {
50
+ it( 'should start with empty state', () => {
51
+ const { result } = renderHook( () => useUploadStatus() );
52
+
53
+ expect( result.current.uploadingFiles ).toEqual( [] );
54
+ expect( result.current.allComplete ).toBe( false );
55
+ } );
56
+
57
+ describe( 'registerBatch', () => {
58
+ it( 'should add files with uploading status', () => {
59
+ const { result } = renderHook( () => useUploadStatus() );
60
+
61
+ act( () => {
62
+ result.current.registerBatch( [
63
+ createFile( 'a.png' ),
64
+ createFile( 'b.png' ),
65
+ ] );
66
+ } );
67
+
68
+ expect( result.current.uploadingFiles ).toHaveLength( 2 );
69
+ expect( result.current.uploadingFiles[ 0 ].name ).toBe( 'a.png' );
70
+ expect( statuses( result.current ) ).toEqual( [
71
+ 'uploading',
72
+ 'uploading',
73
+ ] );
74
+ expect( result.current.allComplete ).toBe( false );
75
+ } );
76
+
77
+ it( 'should assign the same batchId to files in a batch', () => {
78
+ const { result } = renderHook( () => useUploadStatus() );
79
+
80
+ act( () => {
81
+ result.current.registerBatch( [
82
+ createFile( 'a.png' ),
83
+ createFile( 'b.png' ),
84
+ ] );
85
+ } );
86
+
87
+ const { uploadingFiles } = result.current;
88
+ expect( uploadingFiles[ 0 ].batchId ).toBeTruthy();
89
+ expect( uploadingFiles[ 0 ].batchId ).toBe(
90
+ uploadingFiles[ 1 ].batchId
91
+ );
92
+ } );
93
+
94
+ it( 'should assign different batchIds to separate batches', () => {
95
+ const { result } = renderHook( () => useUploadStatus() );
96
+
97
+ act( () => {
98
+ result.current.registerBatch( [ createFile( 'a.png' ) ] );
99
+ result.current.registerBatch( [ createFile( 'b.png' ) ] );
100
+ } );
101
+
102
+ expect( result.current.uploadingFiles[ 0 ].batchId ).not.toBe(
103
+ result.current.uploadingFiles[ 1 ].batchId
104
+ );
105
+ } );
106
+ } );
107
+
108
+ describe( 'onFileChange (batch completion)', () => {
109
+ it( 'should ignore calls with blob URLs', () => {
110
+ const { result } = renderHook( () => useUploadStatus() );
111
+ let onFileChange!: BatchCallbacks[ 'onFileChange' ];
112
+
113
+ act( () => {
114
+ ( { onFileChange } = result.current.registerBatch( [
115
+ createFile( 'a.png' ),
116
+ ] ) );
117
+ } );
118
+
119
+ act( () => onFileChange( [ createBlobAttachment( 'a.png' ) ] ) );
120
+
121
+ expect( statuses( result.current ) ).toEqual( [ 'uploading' ] );
122
+ expect( result.current.allComplete ).toBe( false );
123
+ } );
124
+
125
+ it( 'should mark batch as uploaded when all attachments have real URLs', () => {
126
+ const { result } = renderHook( () => useUploadStatus() );
127
+ let onFileChange!: BatchCallbacks[ 'onFileChange' ];
128
+
129
+ act( () => {
130
+ ( { onFileChange } = result.current.registerBatch( [
131
+ createFile( 'a.png' ),
132
+ createFile( 'b.png' ),
133
+ ] ) );
134
+ } );
135
+
136
+ act( () =>
137
+ onFileChange( [
138
+ createAttachment( 1, 'a.png' ),
139
+ createAttachment( 2, 'b.png' ),
140
+ ] )
141
+ );
142
+
143
+ expect( statuses( result.current ) ).toEqual( [
144
+ 'uploaded',
145
+ 'uploaded',
146
+ ] );
147
+ expect( result.current.allComplete ).toBe( true );
148
+ } );
149
+
150
+ it( 'should only mark its own batch as uploaded, not other batches', () => {
151
+ const { result } = renderHook( () => useUploadStatus() );
152
+ let onFileChangeA!: BatchCallbacks[ 'onFileChange' ];
153
+ let onFileChangeB!: BatchCallbacks[ 'onFileChange' ];
154
+
155
+ act( () => {
156
+ ( { onFileChange: onFileChangeA } =
157
+ result.current.registerBatch( [ createFile( 'a.png' ) ] ) );
158
+ ( { onFileChange: onFileChangeB } =
159
+ result.current.registerBatch( [ createFile( 'b.png' ) ] ) );
160
+ } );
161
+
162
+ act( () => onFileChangeA( [ createAttachment( 1, 'a.png' ) ] ) );
163
+
164
+ expect( statuses( result.current ) ).toEqual( [
165
+ 'uploaded',
166
+ 'uploading',
167
+ ] );
168
+ expect( result.current.allComplete ).toBe( false );
169
+
170
+ act( () => onFileChangeB( [ createAttachment( 2, 'b.png' ) ] ) );
171
+
172
+ expect( result.current.allComplete ).toBe( true );
173
+ } );
174
+
175
+ it( 'should call onBatchComplete exactly once even if onFileChange fires multiple times', () => {
176
+ const onBatchComplete = jest.fn();
177
+ const { result } = renderHook( () =>
178
+ useUploadStatus( { onBatchComplete } )
179
+ );
180
+ let onFileChange!: BatchCallbacks[ 'onFileChange' ];
181
+
182
+ act( () => {
183
+ ( { onFileChange } = result.current.registerBatch( [
184
+ createFile( 'a.png' ),
185
+ ] ) );
186
+ } );
187
+
188
+ const attachment = createAttachment( 1, 'a.png' );
189
+
190
+ act( () => {
191
+ onFileChange( [ attachment ] );
192
+ onFileChange( [ attachment ] );
193
+ onFileChange( [ attachment ] );
194
+ } );
195
+
196
+ expect( onBatchComplete ).toHaveBeenCalledTimes( 1 );
197
+ expect( onBatchComplete ).toHaveBeenCalledWith( [ attachment ] );
198
+ } );
199
+
200
+ it( 'should handle onFileChange with growing arrays (no blob URLs)', () => {
201
+ // When __clientSideMediaProcessing is true, blob URLs are not
202
+ // created. onFileChange is called with a growing array as each
203
+ // file completes: [att1], [att1, att2], [att1, att2, att3].
204
+ const onBatchComplete = jest.fn();
205
+ const { result } = renderHook( () =>
206
+ useUploadStatus( { onBatchComplete } )
207
+ );
208
+ let onFileChange!: BatchCallbacks[ 'onFileChange' ];
209
+
210
+ act( () => {
211
+ ( { onFileChange } = result.current.registerBatch( [
212
+ createFile( 'a.png' ),
213
+ createFile( 'b.png' ),
214
+ createFile( 'c.png' ),
215
+ ] ) );
216
+ } );
217
+
218
+ const att1 = createAttachment( 1, 'a.png' );
219
+ const att2 = createAttachment( 2, 'b.png' );
220
+ const att3 = createAttachment( 3, 'c.png' );
221
+
222
+ act( () => onFileChange( [ att1 ] ) );
223
+ expect( onBatchComplete ).not.toHaveBeenCalled();
224
+
225
+ act( () => onFileChange( [ att1, att2 ] ) );
226
+ expect( onBatchComplete ).not.toHaveBeenCalled();
227
+
228
+ act( () => onFileChange( [ att1, att2, att3 ] ) );
229
+ expect( onBatchComplete ).toHaveBeenCalledTimes( 1 );
230
+ expect( onBatchComplete ).toHaveBeenCalledWith( [
231
+ att1,
232
+ att2,
233
+ att3,
234
+ ] );
235
+ expect( result.current.allComplete ).toBe( true );
236
+ } );
237
+ } );
238
+
239
+ describe( 'onError', () => {
240
+ it( 'should mark the matching file in the batch as errored', () => {
241
+ const { result } = renderHook( () => useUploadStatus() );
242
+ let onError!: BatchCallbacks[ 'onError' ];
243
+
244
+ act( () => {
245
+ ( { onError } = result.current.registerBatch( [
246
+ createFile( 'a.png' ),
247
+ createFile( 'b.png' ),
248
+ ] ) );
249
+ } );
250
+
251
+ act( () => onError( createUploadError( 'a.png' ) ) );
252
+
253
+ expect( result.current.uploadingFiles[ 0 ].status ).toBe( 'error' );
254
+ expect( result.current.uploadingFiles[ 0 ].error ).toBe(
255
+ 'Upload failed'
256
+ );
257
+ expect( result.current.uploadingFiles[ 1 ].status ).toBe(
258
+ 'uploading'
259
+ );
260
+ } );
261
+
262
+ it( 'should only mark one file per error even with duplicate names', () => {
263
+ const { result } = renderHook( () => useUploadStatus() );
264
+ let onError!: BatchCallbacks[ 'onError' ];
265
+
266
+ act( () => {
267
+ ( { onError } = result.current.registerBatch( [
268
+ createFile( 'a.png' ),
269
+ createFile( 'a.png' ),
270
+ ] ) );
271
+ } );
272
+
273
+ act( () => onError( createUploadError( 'a.png' ) ) );
274
+
275
+ expect( statuses( result.current ) ).toEqual( [
276
+ 'error',
277
+ 'uploading',
278
+ ] );
279
+ } );
280
+
281
+ it( 'should not affect files in a different batch', () => {
282
+ const { result } = renderHook( () => useUploadStatus() );
283
+ let onErrorB!: BatchCallbacks[ 'onError' ];
284
+
285
+ act( () => {
286
+ result.current.registerBatch( [ createFile( 'a.png' ) ] );
287
+ ( { onError: onErrorB } = result.current.registerBatch( [
288
+ createFile( 'a.png' ),
289
+ ] ) );
290
+ } );
291
+
292
+ act( () => onErrorB( createUploadError( 'a.png' ) ) );
293
+
294
+ expect( statuses( result.current ) ).toEqual( [
295
+ 'uploading',
296
+ 'error',
297
+ ] );
298
+ } );
299
+ } );
300
+
301
+ describe( 'allComplete', () => {
302
+ it( 'should be false when there are no files', () => {
303
+ const { result } = renderHook( () => useUploadStatus() );
304
+ expect( result.current.allComplete ).toBe( false );
305
+ } );
306
+
307
+ it( 'should be false when some files are still uploading', () => {
308
+ const { result } = renderHook( () => useUploadStatus() );
309
+ let onFileChangeA!: BatchCallbacks[ 'onFileChange' ];
310
+
311
+ act( () => {
312
+ ( { onFileChange: onFileChangeA } =
313
+ result.current.registerBatch( [ createFile( 'a.png' ) ] ) );
314
+ result.current.registerBatch( [ createFile( 'b.png' ) ] );
315
+ } );
316
+
317
+ act( () => onFileChangeA( [ createAttachment( 1, 'a.png' ) ] ) );
318
+
319
+ expect( result.current.allComplete ).toBe( false );
320
+ } );
321
+
322
+ it( 'should be true when all files are uploaded or errored', () => {
323
+ const { result } = renderHook( () => useUploadStatus() );
324
+ let onFileChange!: BatchCallbacks[ 'onFileChange' ];
325
+ let onError!: BatchCallbacks[ 'onError' ];
326
+
327
+ act( () => {
328
+ ( { onFileChange, onError } = result.current.registerBatch( [
329
+ createFile( 'a.png' ),
330
+ createFile( 'b.png' ),
331
+ ] ) );
332
+ } );
333
+
334
+ act( () => onError( createUploadError( 'a.png', 'fail' ) ) );
335
+ expect( result.current.allComplete ).toBe( false );
336
+
337
+ // Complete the remaining file — onFileChange fires with just
338
+ // the successful attachment.
339
+ act( () => onFileChange( [ createAttachment( 2, 'b.png' ) ] ) );
340
+ expect( result.current.allComplete ).toBe( true );
341
+ } );
342
+ } );
343
+
344
+ describe( 'mixed success and error (uploadMedia race condition)', () => {
345
+ it( 'should handle onFileChange firing before onError for a failed file', () => {
346
+ // Simulates the uploadMedia flow for 3 files where c.png fails:
347
+ // 1. Blob URLs created for all 3 (ignored by hook)
348
+ // 2-3. Partial completions with blobs (ignored)
349
+ // 4. c.png fails → onFileChange([a, b]), successCount = 2
350
+ // 5. onError(c.png) → errorCount = 1, total = 3 = batchSize
351
+ const onBatchComplete = jest.fn();
352
+ const { result } = renderHook( () =>
353
+ useUploadStatus( { onBatchComplete } )
354
+ );
355
+ let onFileChange!: BatchCallbacks[ 'onFileChange' ];
356
+ let onError!: BatchCallbacks[ 'onError' ];
357
+
358
+ act( () => {
359
+ ( { onFileChange, onError } = result.current.registerBatch( [
360
+ createFile( 'a.png' ),
361
+ createFile( 'b.png' ),
362
+ createFile( 'c.png' ),
363
+ ] ) );
364
+ } );
365
+
366
+ // Blob URL calls and partial completions (all ignored).
367
+ act( () => {
368
+ onFileChange( [
369
+ createBlobAttachment( 'a' ),
370
+ createBlobAttachment( 'b' ),
371
+ createBlobAttachment( 'c' ),
372
+ ] );
373
+ onFileChange( [
374
+ createAttachment( 1, 'a.png' ),
375
+ createBlobAttachment( 'b' ),
376
+ createBlobAttachment( 'c' ),
377
+ ] );
378
+ onFileChange( [
379
+ createAttachment( 1, 'a.png' ),
380
+ createAttachment( 2, 'b.png' ),
381
+ createBlobAttachment( 'c' ),
382
+ ] );
383
+ } );
384
+ expect( onBatchComplete ).not.toHaveBeenCalled();
385
+
386
+ // c.png fails — onFileChange with only successful attachments.
387
+ act( () =>
388
+ onFileChange( [
389
+ createAttachment( 1, 'a.png' ),
390
+ createAttachment( 2, 'b.png' ),
391
+ ] )
392
+ );
393
+ expect( onBatchComplete ).not.toHaveBeenCalled();
394
+
395
+ // onError fires for c.png — batch now complete.
396
+ act( () => onError( createUploadError( 'c.png' ) ) );
397
+
398
+ expect( onBatchComplete ).toHaveBeenCalledTimes( 1 );
399
+ expect( onBatchComplete ).toHaveBeenCalledWith( [
400
+ createAttachment( 1, 'a.png' ),
401
+ createAttachment( 2, 'b.png' ),
402
+ ] );
403
+
404
+ const fileStatuses = result.current.uploadingFiles.map(
405
+ ( item ) => [ item.name, item.status ]
406
+ );
407
+ expect( fileStatuses ).toEqual( [
408
+ [ 'a.png', 'uploaded' ],
409
+ [ 'b.png', 'uploaded' ],
410
+ [ 'c.png', 'error' ],
411
+ ] );
412
+ expect( result.current.allComplete ).toBe( true );
413
+ } );
414
+
415
+ it( 'should handle all files erroring', () => {
416
+ const onBatchComplete = jest.fn();
417
+ const { result } = renderHook( () =>
418
+ useUploadStatus( { onBatchComplete } )
419
+ );
420
+ let onFileChange!: BatchCallbacks[ 'onFileChange' ];
421
+ let onError!: BatchCallbacks[ 'onError' ];
422
+
423
+ act( () => {
424
+ ( { onFileChange, onError } = result.current.registerBatch( [
425
+ createFile( 'a.png' ),
426
+ createFile( 'b.png' ),
427
+ ] ) );
428
+ } );
429
+
430
+ act( () => {
431
+ onFileChange( [] );
432
+ onError( createUploadError( 'a.png', 'fail a' ) );
433
+ onFileChange( [] );
434
+ onError( createUploadError( 'b.png', 'fail b' ) );
435
+ } );
436
+
437
+ expect( onBatchComplete ).toHaveBeenCalledTimes( 1 );
438
+ expect( onBatchComplete ).toHaveBeenCalledWith( [] );
439
+ expect( statuses( result.current ) ).toEqual( [
440
+ 'error',
441
+ 'error',
442
+ ] );
443
+ expect( result.current.allComplete ).toBe( true );
444
+ } );
445
+ } );
446
+
447
+ describe( 'dismissError', () => {
448
+ it( 'should remove the errored file from the list', () => {
449
+ const { result } = renderHook( () => useUploadStatus() );
450
+ let onError!: BatchCallbacks[ 'onError' ];
451
+
452
+ act( () => {
453
+ ( { onError } = result.current.registerBatch( [
454
+ createFile( 'a.png' ),
455
+ createFile( 'b.png' ),
456
+ ] ) );
457
+ } );
458
+
459
+ act( () => onError( createUploadError( 'a.png', 'fail' ) ) );
460
+
461
+ const erroredFile = result.current.uploadingFiles.find(
462
+ ( item ) => item.status === 'error'
463
+ )!;
464
+
465
+ act( () => result.current.dismissError( erroredFile.id ) );
466
+
467
+ expect( result.current.uploadingFiles ).toHaveLength( 1 );
468
+ expect( result.current.uploadingFiles[ 0 ].name ).toBe( 'b.png' );
469
+ } );
470
+ } );
471
+
472
+ describe( 'clearCompleted', () => {
473
+ it( 'should remove uploaded entries but keep uploading and errored ones', () => {
474
+ const { result } = renderHook( () => useUploadStatus() );
475
+ let onFileChangeA!: BatchCallbacks[ 'onFileChange' ];
476
+ let onErrorB!: BatchCallbacks[ 'onError' ];
477
+
478
+ act( () => {
479
+ ( { onFileChange: onFileChangeA } =
480
+ result.current.registerBatch( [ createFile( 'a.png' ) ] ) );
481
+ ( { onError: onErrorB } = result.current.registerBatch( [
482
+ createFile( 'b.png' ),
483
+ ] ) );
484
+ result.current.registerBatch( [ createFile( 'c.png' ) ] );
485
+ } );
486
+
487
+ // Complete batch A, error batch B, leave batch C uploading.
488
+ act( () => {
489
+ onFileChangeA( [ createAttachment( 1, 'a.png' ) ] );
490
+ onErrorB( createUploadError( 'b.png', 'fail' ) );
491
+ } );
492
+
493
+ act( () => result.current.clearCompleted() );
494
+
495
+ expect( statuses( result.current ) ).toEqual( [
496
+ 'error',
497
+ 'uploading',
498
+ ] );
499
+ } );
500
+ } );
501
+ } );
@@ -0,0 +1,155 @@
1
+ /**
2
+ * WordPress dependencies
3
+ */
4
+ import { useState, useEffect, useCallback, useRef } from '@wordpress/element';
5
+ import { __, sprintf, _n } from '@wordpress/i18n';
6
+ import { Button, Icon, Notice, Popover, Spinner } from '@wordpress/components';
7
+ import { check, chevronDown } from '@wordpress/icons';
8
+
9
+ export interface UploadingFile {
10
+ id: string;
11
+ batchId: string;
12
+ name: string;
13
+ status: 'uploading' | 'uploaded' | 'error';
14
+ error?: string;
15
+ }
16
+
17
+ interface UploadStatusPopoverProps {
18
+ uploadingFiles: UploadingFile[];
19
+ onDismissError?: ( fileId: string ) => void;
20
+ onOpenChange?: ( open: boolean ) => void;
21
+ }
22
+
23
+ export function UploadStatusPopover( {
24
+ uploadingFiles,
25
+ onDismissError,
26
+ onOpenChange,
27
+ }: UploadStatusPopoverProps ) {
28
+ const [ isOpen, setIsOpen ] = useState( false );
29
+ const [ prevHadErrors, setPrevHadErrors ] = useState( false );
30
+ const triggerRef = useRef< HTMLButtonElement >( null );
31
+
32
+ const updateIsOpen = useCallback(
33
+ ( open: boolean ) => {
34
+ setIsOpen( open );
35
+ onOpenChange?.( open );
36
+ },
37
+ [ onOpenChange ]
38
+ );
39
+
40
+ const activeFiles = uploadingFiles.filter(
41
+ ( file ) => file.status === 'uploading'
42
+ );
43
+ const errorFiles = uploadingFiles.filter(
44
+ ( file ) => file.status === 'error'
45
+ );
46
+ const hasErrors = errorFiles.length > 0;
47
+ const isUploading = activeFiles.length > 0;
48
+
49
+ // Auto-expand when an error occurs.
50
+ useEffect( () => {
51
+ if ( hasErrors && ! prevHadErrors ) {
52
+ updateIsOpen( true );
53
+ }
54
+ setPrevHadErrors( hasErrors );
55
+ }, [ hasErrors, prevHadErrors, updateIsOpen ] );
56
+
57
+ if ( uploadingFiles.length === 0 ) {
58
+ return null;
59
+ }
60
+
61
+ let buttonLabel, popoverHeading: string;
62
+ if ( isUploading ) {
63
+ buttonLabel = sprintf(
64
+ // translators: %s: number of files being uploaded
65
+ _n( 'Uploading %s file', 'Uploading %s files', activeFiles.length ),
66
+ activeFiles.length.toLocaleString()
67
+ );
68
+ popoverHeading = __( 'Uploading' );
69
+ } else if ( hasErrors ) {
70
+ buttonLabel = sprintf(
71
+ // translators: %s: number of upload errors
72
+ _n( '%s upload error', '%s upload errors', errorFiles.length ),
73
+ errorFiles.length.toLocaleString()
74
+ );
75
+ popoverHeading = __( 'Upload errors' );
76
+ } else {
77
+ buttonLabel = __( 'Upload complete' );
78
+ popoverHeading = __( 'Upload complete' );
79
+ }
80
+
81
+ return (
82
+ <div className="media-upload-modal__upload-status">
83
+ { isUploading && <Spinner /> }
84
+ <Button
85
+ className="media-upload-modal__upload-status__trigger"
86
+ size="compact"
87
+ icon={ chevronDown }
88
+ iconPosition="right"
89
+ onClick={ () => updateIsOpen( ! isOpen ) }
90
+ aria-expanded={ isOpen }
91
+ ref={ triggerRef }
92
+ >
93
+ { buttonLabel }
94
+ </Button>
95
+ { isOpen && (
96
+ <Popover
97
+ className="media-upload-modal__upload-status__popover"
98
+ placement="top-start"
99
+ offset={ 8 }
100
+ anchor={ triggerRef.current }
101
+ focusOnMount
102
+ onClose={ () => {
103
+ // Let the button's onClick handle toggling when
104
+ // the close was triggered by clicking the trigger.
105
+ if (
106
+ triggerRef.current?.contains(
107
+ triggerRef.current.ownerDocument.activeElement
108
+ )
109
+ ) {
110
+ return;
111
+ }
112
+ updateIsOpen( false );
113
+ } }
114
+ >
115
+ <div className="media-upload-modal__upload-status__header">
116
+ <h3>{ popoverHeading }</h3>
117
+ </div>
118
+ <ul className="media-upload-modal__upload-status__list">
119
+ { uploadingFiles.map( ( file ) => (
120
+ <li
121
+ key={ file.id }
122
+ className="media-upload-modal__upload-status__item"
123
+ >
124
+ { file.status === 'uploading' && <Spinner /> }
125
+ { file.status === 'uploaded' && (
126
+ <Icon icon={ check } size={ 16 } />
127
+ ) }
128
+ { ( file.status === 'uploading' ||
129
+ file.status === 'uploaded' ) && (
130
+ <span
131
+ className="media-upload-modal__upload-status__filename"
132
+ title={ file.name }
133
+ >
134
+ { file.name }
135
+ </span>
136
+ ) }
137
+ { file.status === 'error' && (
138
+ <Notice
139
+ status="error"
140
+ isDismissible={ !! onDismissError }
141
+ onRemove={ () =>
142
+ onDismissError?.( file.id )
143
+ }
144
+ >
145
+ { file.name }: { file.error }
146
+ </Notice>
147
+ ) }
148
+ </li>
149
+ ) ) }
150
+ </ul>
151
+ </Popover>
152
+ ) }
153
+ </div>
154
+ );
155
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Hook for invalidating all cached `getEntityRecords` resolutions for the
3
+ * attachment post type.
4
+ *
5
+ * After a file upload completes the media grid needs to refresh, but
6
+ * `invalidateResolution` only clears the exact query that is passed to it.
7
+ * If the user is on page 2, page 1 (where the new upload would appear) stays
8
+ * stale. Using `invalidateResolutionForStoreSelector` would work but is too
9
+ * broad — it clears every `getEntityRecords` resolution, potentially
10
+ * triggering unnecessary refetches for unrelated entity types.
11
+ *
12
+ * This hook provides a middle ground: it iterates over every cached
13
+ * resolution for `getEntityRecords` and invalidates only the entries where
14
+ * the first two arguments match `['postType', 'attachment']`.
15
+ */
16
+
17
+ /**
18
+ * WordPress dependencies
19
+ */
20
+ import { useCallback } from '@wordpress/element';
21
+ import { store as coreStore } from '@wordpress/core-data';
22
+ import { useRegistry } from '@wordpress/data';
23
+
24
+ /**
25
+ * Returns a stable callback that invalidates all cached `getEntityRecords`
26
+ * resolutions for `postType / attachment`, leaving every other entity type
27
+ * untouched.
28
+ */
29
+ export function useInvalidateAttachmentResolutions() {
30
+ const registry = useRegistry();
31
+
32
+ return useCallback( () => {
33
+ const resolvers = registry.select( coreStore ).getCachedResolvers();
34
+
35
+ // getCachedResolvers() is typed as Record<string, unknown> but the
36
+ // values are EquivalentKeyMap instances (Map-like). Cast the same
37
+ // way the resolvers-cache-middleware does internally.
38
+ const entityRecordResolutions = resolvers.getEntityRecords as
39
+ | Map< string[], { status: string } >
40
+ | undefined;
41
+
42
+ entityRecordResolutions?.forEach( ( _value, args ) => {
43
+ if ( args[ 0 ] === 'postType' && args[ 1 ] === 'attachment' ) {
44
+ registry
45
+ .dispatch( coreStore )
46
+ .invalidateResolution( 'getEntityRecords', args );
47
+ }
48
+ } );
49
+ }, [ registry ] );
50
+ }