diff --git a/examples/transloadit/main.js b/examples/transloadit/main.js index 88720ac570..fdf3c4a31e 100644 --- a/examples/transloadit/main.js +++ b/examples/transloadit/main.js @@ -1,6 +1,7 @@ import Uppy from '@uppy/core' import Dashboard from '@uppy/dashboard' import Form from '@uppy/form' +import GoldenRetriever from '@uppy/golden-retriever' import ImageEditor from '@uppy/image-editor' import RemoteSources from '@uppy/remote-sources' import Transloadit, { COMPANION_URL } from '@uppy/transloadit' @@ -119,9 +120,6 @@ window.formUppyWithDashboard = formUppyWithDashboard const dashboard = new Uppy({ debug: true, autoProceed: false, - restrictions: { - allowedFileTypes: ['.png'], - }, }) .use(Dashboard, { inline: true, @@ -140,6 +138,7 @@ const dashboard = new Uppy({ }, }, }) + .use(GoldenRetriever) window.dashboard = dashboard diff --git a/examples/transloadit/package.json b/examples/transloadit/package.json index 6072b43ab1..74705e47f3 100644 --- a/examples/transloadit/package.json +++ b/examples/transloadit/package.json @@ -15,6 +15,7 @@ "@uppy/remote-sources": "workspace:*", "@uppy/transloadit": "workspace:*", "@uppy/webcam": "workspace:*", + "@uppy/golden-retriever": "workspace:*", "express": "^4.22.0", "he": "^1.2.0" }, diff --git a/packages/@uppy/core/src/Uppy.ts b/packages/@uppy/core/src/Uppy.ts index f434894ae1..76e43cfcd5 100644 --- a/packages/@uppy/core/src/Uppy.ts +++ b/packages/@uppy/core/src/Uppy.ts @@ -1854,6 +1854,29 @@ export class Uppy< }, }, }) + + // Drop any currentUpload whose fileIDs are now all complete. This + // mirrors what `#removeUpload` does at the end of `#runUpload`, but + // is needed for completion paths that bypass `#runUpload` — notably + // SSE-driven Transloadit assembly completion during GoldenRetriever + // restore (no Resume click → no `uppy.restore(uploadID)` → orphaned + // entry in `currentUploads`, which then makes `uppy.clear()` throw + // when an uploader has `individualCancellation: false`). + const { currentUploads, files } = this.getState() + const updatedCurrentUploads = Object.fromEntries( + Object.entries(currentUploads).filter(([, upload]) => + upload.fileIDs.some((id) => { + const f = files[id] + return f && !f.progress.complete && !f.error + }), + ), + ) + if ( + Object.keys(updatedCurrentUploads).length !== + Object.keys(currentUploads).length + ) { + this.setState({ currentUploads: updatedCurrentUploads }) + } }) this.on('restored', () => { diff --git a/packages/@uppy/dashboard/src/components/StatusBar/StatusBar.tsx b/packages/@uppy/dashboard/src/components/StatusBar/StatusBar.tsx index 6f87315ccf..cff098a04b 100644 --- a/packages/@uppy/dashboard/src/components/StatusBar/StatusBar.tsx +++ b/packages/@uppy/dashboard/src/components/StatusBar/StatusBar.tsx @@ -21,11 +21,25 @@ type StatusBarProps = { i18n: I18n } -function getUploadingState( +/** + * Mirror of Transloadit's `AssemblyStatus['ok']` union (see `@transloadit/types`). + * Kept structural here so `@uppy/dashboard` does not depend on `@uppy/transloadit`. + */ +export type AssemblyOk = + | 'ASSEMBLY_UPLOADING' + | 'ASSEMBLY_EXECUTING' + | 'ASSEMBLY_COMPLETED' + | 'ASSEMBLY_CANCELED' + | 'ASSEMBLY_EXPIRED' + | 'ASSEMBLY_REPLAYING' + | 'REQUEST_ABORTED' + +export function getUploadingState( error: unknown, isAllComplete: boolean, recoveredState: State['recoveredState'], files: Record>, + assemblyOk?: AssemblyOk, ): StatusBarUIProps['uploadState'] { if (error) { return statusBarStates.STATE_ERROR @@ -35,6 +49,31 @@ function getUploadingState( return statusBarStates.STATE_COMPLETE } + // ASSEMBLY_EXECUTING implies all uploads are done by Transloadit's state + // machine, so it always outranks recoveredState. + + // ASSEMBLY_UPLOADING is ambiguous: server may be waiting for more bytes + // (tus partial / Companion mid-flight) OR may already have everything and + // be about to transition to EXECUTING. We can disambiguate by file progress: + // if every started file has uploadComplete:true, the bytes are on the server + // and the Resume button would be a no-op — show progress UI instead. If any + // file still has work to do, KEEP the Resume button so the user can trigger + // 'restore-confirmed' → uppy.restore(uploadId), which is the only path that + // re-invokes tus / Companion (Transloadit disables tus auto-resume via + // `storeFingerprintForResuming: false`). + if (assemblyOk === 'ASSEMBLY_EXECUTING') { + return statusBarStates.STATE_POSTPROCESSING + } + if (assemblyOk === 'ASSEMBLY_UPLOADING') { + const allUploadsComplete = Object.values(files).every( + (f) => f.progress.uploadStarted && f.progress.uploadComplete, + ) + if (allUploadsComplete) { + return statusBarStates.STATE_UPLOADING + } + // Fall through: any unfinished upload means the user must press Resume. + } + if (recoveredState) { return statusBarStates.STATE_WAITING } @@ -198,6 +237,7 @@ export default class StatusBar< } render(): ComponentChild { + const state = this.props.uppy.getState() const { capabilities, files, @@ -205,7 +245,14 @@ export default class StatusBar< totalProgress, error, recoveredState, - } = this.props.uppy.getState() + } = state + // Read Transloadit's live assembly status `.ok` (if installed) so a live + // assembly can outrank the recovery prompt — see issue #6017. + const assemblyOk = ( + state.plugins?.Transloadit as + | { assemblyStatus?: { ok?: AssemblyOk } } + | undefined + )?.assemblyStatus?.ok const { newFiles, @@ -256,6 +303,7 @@ export default class StatusBar< isAllComplete, recoveredState, files || {}, + assemblyOk, )} allowNewUpload={allowNewUpload} totalProgress={totalProgress} diff --git a/packages/@uppy/status-bar/src/StatusBar.tsx b/packages/@uppy/status-bar/src/StatusBar.tsx index 07d8827446..414129e3f0 100644 --- a/packages/@uppy/status-bar/src/StatusBar.tsx +++ b/packages/@uppy/status-bar/src/StatusBar.tsx @@ -24,11 +24,25 @@ declare module '@uppy/core' { const speedFilterHalfLife = 2000 const ETAFilterHalfLife = 2000 -function getUploadingState( +/** + * Mirror of Transloadit's `AssemblyStatus['ok']` union (see `@transloadit/types`). + * Kept structural here so `@uppy/status-bar` does not depend on `@uppy/transloadit`. + */ +type AssemblyOk = + | 'ASSEMBLY_UPLOADING' + | 'ASSEMBLY_EXECUTING' + | 'ASSEMBLY_COMPLETED' + | 'ASSEMBLY_CANCELED' + | 'ASSEMBLY_EXPIRED' + | 'ASSEMBLY_REPLAYING' + | 'REQUEST_ABORTED' + +export function getUploadingState( error: unknown, isAllComplete: boolean, recoveredState: unknown, files: Record>, + assemblyOk?: AssemblyOk, ): StatusBarUIProps['uploadState'] { if (error) { return statusBarStates.STATE_ERROR @@ -38,6 +52,31 @@ function getUploadingState( return statusBarStates.STATE_COMPLETE } + // ASSEMBLY_EXECUTING implies all uploads are done by Transloadit's state + // machine, so it always outranks recoveredState. + // + // ASSEMBLY_UPLOADING is ambiguous: server may be waiting for more bytes + // (tus partial / Companion mid-flight) OR may already have everything and + // be about to transition to EXECUTING. We can disambiguate by file progress: + // if every started file has uploadComplete:true, the bytes are on the server + // and the Resume button would be a no-op — show progress UI instead. If any + // file still has work to do, KEEP the Resume button so the user can trigger + // 'restore-confirmed' → uppy.restore(uploadId), which is the only path that + // re-invokes tus / Companion (Transloadit disables tus auto-resume via + // `storeFingerprintForResuming: false`). + if (assemblyOk === 'ASSEMBLY_EXECUTING') { + return statusBarStates.STATE_POSTPROCESSING + } + if (assemblyOk === 'ASSEMBLY_UPLOADING') { + const allUploadsComplete = Object.values(files).every( + (f) => f.progress.uploadStarted && f.progress.uploadComplete, + ) + if (allUploadsComplete) { + return statusBarStates.STATE_UPLOADING + } + // Fall through: any unfinished upload means the user must press Resume. + } + if (recoveredState) { return statusBarStates.STATE_WAITING } @@ -217,6 +256,15 @@ export default class StatusBar extends UIPlugin< total: totalSize, }) + // Transloadit's plugin state, if installed, exposes the live assembly + // status. Reading `.ok` here lets a non-terminal assembly outrank the + // "press Upload to resume" UI on restore — see issue #6017. + const assemblyOk = ( + state.plugins?.Transloadit as + | { assemblyStatus?: { ok?: AssemblyOk } } + | undefined + )?.assemblyStatus?.ok + return StatusBarUI({ error, uploadState: getUploadingState( @@ -224,6 +272,7 @@ export default class StatusBar extends UIPlugin< isAllComplete, recoveredState, state.files || {}, + assemblyOk, ), allowNewUpload, totalProgress, diff --git a/packages/@uppy/transloadit/src/Assembly.ts b/packages/@uppy/transloadit/src/Assembly.ts index ea2f676cf4..ca6384ce76 100644 --- a/packages/@uppy/transloadit/src/Assembly.ts +++ b/packages/@uppy/transloadit/src/Assembly.ts @@ -140,6 +140,13 @@ class TransloaditAssembly extends Emitter { this.#sse.addEventListener('assembly_execution_progress', (e) => { const details = JSON.parse(e.data) + // setting combined execution progress of the assembly + if (typeof details.progress_combined === 'number') { + this.status = { + ...this.status, + execution_progress: details.progress_combined, + } + } this.emit('execution-progress', details) }) diff --git a/packages/@uppy/transloadit/src/index.test.js b/packages/@uppy/transloadit/src/index.test.js index 8f3cf6e7da..ffcdd31c5a 100644 --- a/packages/@uppy/transloadit/src/index.test.js +++ b/packages/@uppy/transloadit/src/index.test.js @@ -317,4 +317,198 @@ describe('Transloadit', () => { // Should be reset to true expect(uppy.getState().allowNewUpload).toBe(true) }) + + it('populates plugin state assemblyStatus during a real upload flow', async () => { + const assemblyStatusBase = { + assembly_id: 'flow-test-id', + websocket_url: 'ws://localhost:8080', + tus_url: 'http://localhost/resumable/files/', + assembly_ssl_url: 'https://api2.transloadit.com/assemblies/flow-test-id', + } + + const tusUploads = new Map() + let uploadIndex = 0 + const tusBaseUrl = 'http://localhost/resumable/files/' + + const server = setupServer( + http.options('http://localhost/resumable/files*', () => { + return new HttpResponse(null, { + status: 204, + headers: { + 'Tus-Resumable': '1.0.0', + 'Tus-Version': '1.0.0', + 'Tus-Extension': 'creation,creation-defer-length', + }, + }) + }), + http.post('http://localhost/resumable/files*', ({ request }) => { + const uploadLengthHeader = request.headers.get('upload-length') + const uploadLength = uploadLengthHeader ? Number(uploadLengthHeader) : 0 + const uploadId = `flow-upload-${uploadIndex++}` + tusUploads.set(uploadId, { + length: Number.isNaN(uploadLength) ? 0 : uploadLength, + offset: 0, + }) + + return new HttpResponse(null, { + status: 201, + headers: { + Location: `${tusBaseUrl}${uploadId}`, + 'Upload-Offset': '0', + 'Tus-Resumable': '1.0.0', + }, + }) + }), + http.patch( + 'http://localhost/resumable/files/:uploadId', + async ({ request, params }) => { + const upload = tusUploads.get(params.uploadId) + if (!upload) return new HttpResponse(null, { status: 404 }) + const body = await request.arrayBuffer() + const offsetHeader = request.headers.get('upload-offset') + const baseOffset = offsetHeader ? Number(offsetHeader) : upload.offset + const nextOffset = baseOffset + body.byteLength + upload.offset = nextOffset + return new HttpResponse(null, { + status: 204, + headers: { + 'Upload-Offset': String(nextOffset), + 'Tus-Resumable': '1.0.0', + }, + }) + }, + ), + http.post('https://api2.transloadit.com/assemblies', () => { + return HttpResponse.json({ + ...assemblyStatusBase, + ok: 'ASSEMBLY_UPLOADING', + }) + }), + http.get('https://api2.transloadit.com/assemblies/*', () => { + return HttpResponse.json({ + ...assemblyStatusBase, + ok: 'ASSEMBLY_COMPLETED', + results: {}, + }) + }), + http.post('https://transloaditstatus.com/client_error', () => { + return HttpResponse.json({}) + }), + ) + + server.listen({ onUnhandledRequest: 'error' }) + + const uppy = new Core() + uppy.use(Transloadit, { + assemblyOptions: { + params: { + auth: { key: 'test-auth-key' }, + template_id: 'test-template-id', + }, + }, + }) + + // Before upload: assemblyStatus should be undefined + expect(uppy.getState().plugins.Transloadit.assemblyStatus).toBeUndefined() + + // Track the assemblyStatus values we see as the store updates. + // This avoids racing against the upload completion. + const seenStatuses = [] + const unsub = uppy.store.subscribe((_prev, next) => { + seenStatuses.push(next.plugins.Transloadit.assemblyStatus) + }) + + uppy.addFile({ + source: 'test', + name: 'test.jpg', + data: new File([new Uint8Array([1, 2, 3, 4, 5])], 'test.jpg', { + type: 'image/jpeg', + }), + }) + + await uppy.upload() + unsub() + + // At some point during the upload the plugin must have written a defined + // assemblyStatus into plugin state. + const definedStatuses = seenStatuses.filter((s) => s != null) + expect(definedStatuses.length).toBeGreaterThan(0) + expect(definedStatuses[0].assembly_id).toBe('flow-test-id') + + // After upload completes: the final Assembly status remains in plugin state + // so the UI can display the completed results. It is only cleared on a new + // upload or explicit cancel. + const finalStatus = uppy.getState().plugins.Transloadit.assemblyStatus + expect(finalStatus).toBeDefined() + expect(finalStatus.assembly_id).toBe('flow-test-id') + + server.close() + }) + + it('clears assemblyStatus at the start of a new upload', async () => { + const uppy = new Core() + uppy.use(Transloadit, { + assemblyOptions: { + params: { + auth: { key: 'test-auth-key' }, + template_id: 'test-template-id', + }, + }, + }) + + const plugin = uppy.getPlugin('Transloadit') + // Simulate a prior Assembly still sitting in plugin state. + plugin.setPluginState({ + assemblyStatus: { assembly_id: 'prior', ok: 'ASSEMBLY_COMPLETED' }, + }) + expect(uppy.getState().plugins.Transloadit.assemblyStatus).toBeDefined() + + // Make #createAssembly fail so we can observe the clear-on-start without + // running a full upload. + plugin.client.createAssembly = () => Promise.reject(new Error('stop here')) + + uppy.addFile({ + source: 'test', + name: 'abc', + data: new Uint8Array(100), + }) + + await uppy.upload().catch(() => {}) + + // `#prepareUpload` should have cleared the stale status at its start. + // After the failure, it may still be undefined (no new Assembly created). + expect(uppy.getState().plugins.Transloadit.assemblyStatus).toBeUndefined() + }) + + it('clears assemblyStatus on cancelAll even after Assembly was torn down', async () => { + // Regression: after upload completion, `#afterUpload` sets `this.assembly = undefined`, + // but the cached completed status stays in plugin state so the UI can show results. + // When the user clicks "Upload other files" (which calls `uppy.cancelAll()`), the + // cached status must be cleared so the UI resets — even though `this.assembly` is + // already gone and `#cancelAssembly` won't be called. + const uppy = new Core() + uppy.use(Transloadit, { + assemblyOptions: { + params: { + auth: { key: 'test-auth-key' }, + template_id: 'test-template-id', + }, + }, + }) + + const plugin = uppy.getPlugin('Transloadit') + // Simulate the post-completion state: assemblyStatus still cached, but + // this.#assembly already torn down by `#afterUpload`'s finally block. + plugin.setPluginState({ + assemblyStatus: { assembly_id: 'done', ok: 'ASSEMBLY_COMPLETED' }, + }) + expect(plugin.assembly).toBeUndefined() + expect(uppy.getState().plugins.Transloadit.assemblyStatus).toBeDefined() + + uppy.cancelAll() + // Allow the async #onCancelAll handler to run. + await Promise.resolve() + + expect(uppy.getState().plugins.Transloadit.assemblyStatus).toBeUndefined() + }) }) diff --git a/packages/@uppy/transloadit/src/index.ts b/packages/@uppy/transloadit/src/index.ts index 60ce29fa1e..b546bdcb1c 100644 --- a/packages/@uppy/transloadit/src/index.ts +++ b/packages/@uppy/transloadit/src/index.ts @@ -26,7 +26,9 @@ import AssemblyWatcher from './AssemblyWatcher.js' import Client, { type AssemblyError } from './Client.js' import locale from './locale.js' -export type AssemblyResponse = AssemblyStatus +export type AssemblyResponse = AssemblyStatus & { + execution_progress?: number +} export type AssemblyFile = AssemblyStatusUpload export type AssemblyResult = AssemblyStatusResult & { localId: string | null } export type AssemblyParameters = AssemblyInstructionsInput @@ -79,6 +81,7 @@ type TransloaditState = { string, { assembly: string; id: string; uploadedFile: AssemblyFile } > + assemblyStatus: AssemblyResponse | undefined results: Array<{ result: AssemblyResult stepName: string @@ -504,6 +507,16 @@ export default class Transloadit< #handleAssemblyStatusUpdate = ( assemblyResponse: AssemblyResponse | undefined, ) => { + /** + * Note: We only Set a defined assemblyResponse into uppyState. Clearing plugin state is handled + * explicitly by `#prepareUpload` (new upload) and `#cancelAssembly` / `#onError` + * (user-initiated teardown). Otherwise the `this.assembly = undefined` cleanup + * in `#afterUpload`'s finally would wipe the final completed status from the + * UI right after upload completes. + */ + if (assemblyResponse != null) { + this.setPluginState({ assemblyStatus: assemblyResponse }) + } this.uppy.emit('restore:plugin-data-changed', { [this.id]: assemblyResponse ? { assemblyResponse } : undefined, }) @@ -637,6 +650,7 @@ export default class Transloadit< // TODO bubble this through AssemblyWatcher so its event handlers can clean up correctly this.uppy.emit('transloadit:assembly-cancelled', assembly) this.assembly = undefined + this.setPluginState({ assemblyStatus: undefined }) } /** @@ -650,6 +664,10 @@ export default class Transloadit< this.uppy.log(err) } } + // Always clear the cached Assembly status + // this.assembly was already set undefined by `#afterUpload` finally block + this.setPluginState({ assemblyStatus: undefined }) + // Reset allowNewUpload when upload is cancelled this.uppy.setState({ allowNewUpload: true }) } @@ -824,6 +842,10 @@ export default class Transloadit< // TODO we should rewrite to instead infer allowNewUpload based on upload state this.uppy.setState({ allowNewUpload: false }) + // Clear any previous Assembly's status so the UI doesn't show stale results + // from a prior run. The new status will be written on the next status event. + this.setPluginState({ assemblyStatus: undefined }) + const assemblyOptions = ( typeof this.opts.assemblyOptions === 'function' ? await this.opts.assemblyOptions() @@ -836,6 +858,16 @@ export default class Transloadit< validateParams(assemblyOptions.params) try { + // `this.assembly` is normally cleared by `#afterUpload`'s `finally`, + // but `#afterUpload` only runs as part of `#runUpload`. The post-restore + // SSE-only completion path bypasses `#runUpload`, so a completed + // `this.assembly` can linger. Without this guard the `??` below would + // reuse it, `#attachAssemblyMetadata` wouldn't run for the new file, + // and tus would throw "neither an endpoint or an upload URL is provided" + if (this.assembly?.status?.ok === 'ASSEMBLY_COMPLETED') { + this.assembly = undefined + } + const assembly = // this.assembly can already be defined if we recovered files with Golden Retriever (this.#onRestored) this.assembly ?? (await this.#createAssembly(fileIDs, assemblyOptions)) @@ -1006,6 +1038,7 @@ export default class Transloadit< this.uppy.on('restored', this.#onRestored) this.setPluginState({ + assemblyStatus: undefined, // Contains file data from Transloadit, indexed by their Transloadit-assigned ID. files: {}, // Contains result data from Transloadit. diff --git a/yarn.lock b/yarn.lock index e5ae52e942..5191831ade 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14576,6 +14576,7 @@ __metadata: "@uppy/dashboard": "workspace:*" "@uppy/drop-target": "workspace:*" "@uppy/form": "workspace:*" + "@uppy/golden-retriever": "workspace:*" "@uppy/image-editor": "workspace:*" "@uppy/remote-sources": "workspace:*" "@uppy/transloadit": "workspace:*"