@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.
- package/CHANGELOG.md +4 -0
- package/build/components/media-upload-modal/index.cjs +133 -87
- package/build/components/media-upload-modal/index.cjs.map +3 -3
- package/build/components/media-upload-modal/upload-status-popover.cjs +156 -0
- package/build/components/media-upload-modal/upload-status-popover.cjs.map +7 -0
- package/build/components/media-upload-modal/use-invalidate-attachment-resolutions.cjs +45 -0
- package/build/components/media-upload-modal/use-invalidate-attachment-resolutions.cjs.map +7 -0
- package/build/components/media-upload-modal/use-upload-status.cjs +127 -0
- package/build/components/media-upload-modal/use-upload-status.cjs.map +7 -0
- package/build-module/components/media-upload-modal/index.mjs +126 -88
- package/build-module/components/media-upload-modal/index.mjs.map +2 -2
- package/build-module/components/media-upload-modal/upload-status-popover.mjs +131 -0
- package/build-module/components/media-upload-modal/upload-status-popover.mjs.map +7 -0
- package/build-module/components/media-upload-modal/use-invalidate-attachment-resolutions.mjs +20 -0
- package/build-module/components/media-upload-modal/use-invalidate-attachment-resolutions.mjs.map +7 -0
- package/build-module/components/media-upload-modal/use-upload-status.mjs +102 -0
- package/build-module/components/media-upload-modal/use-upload-status.mjs.map +7 -0
- package/build-style/style-rtl.css +73 -3
- package/build-style/style.css +73 -3
- package/build-types/components/media-upload-modal/index.d.ts.map +1 -1
- package/build-types/components/media-upload-modal/upload-status-popover.d.ts +15 -0
- package/build-types/components/media-upload-modal/upload-status-popover.d.ts.map +1 -0
- package/build-types/components/media-upload-modal/use-invalidate-attachment-resolutions.d.ts +22 -0
- package/build-types/components/media-upload-modal/use-invalidate-attachment-resolutions.d.ts.map +1 -0
- package/build-types/components/media-upload-modal/use-upload-status.d.ts +41 -0
- package/build-types/components/media-upload-modal/use-upload-status.d.ts.map +1 -0
- package/package.json +17 -15
- package/src/components/media-upload-modal/index.tsx +129 -107
- package/src/components/media-upload-modal/style.scss +88 -3
- package/src/components/media-upload-modal/test/use-upload-status.test.ts +501 -0
- package/src/components/media-upload-modal/upload-status-popover.tsx +155 -0
- package/src/components/media-upload-modal/use-invalidate-attachment-resolutions.ts +50 -0
- 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
|
+
}
|