From 96dc19766ce0ff4b02e2c4378ee33ae6d90fff68 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Tue, 19 Mar 2024 21:00:07 +0200 Subject: [PATCH 01/27] @uppy/dashboard: refactor to stable lifecycle method (#4999) --- .../@uppy/dashboard/src/components/Slide.jsx | 137 ++++++++---------- 1 file changed, 62 insertions(+), 75 deletions(-) diff --git a/packages/@uppy/dashboard/src/components/Slide.jsx b/packages/@uppy/dashboard/src/components/Slide.jsx index 2b6dde38d4..b28065a255 100644 --- a/packages/@uppy/dashboard/src/components/Slide.jsx +++ b/packages/@uppy/dashboard/src/components/Slide.jsx @@ -1,4 +1,5 @@ -import { cloneElement, Component, toChildArray } from 'preact' +import { cloneElement, toChildArray } from 'preact' +import { useEffect, useState, useRef } from 'preact/hooks' import classNames from 'classnames' const transitionName = 'uppy-transition-slideDownUp' @@ -13,87 +14,73 @@ const duration = 250 * but it should be simple to extend this for any type of single-element * transition by setting the CSS name and duration as props. */ -class Slide extends Component { - constructor (props) { - super(props) +function Slide ({ children }) { + const [cachedChildren, setCachedChildren] = useState(null); + const [className, setClassName] = useState(''); + const enterTimeoutRef = useRef(); + const leaveTimeoutRef = useRef(); + const animationFrameRef = useRef(); + + const handleEnterTransition = () => { + setClassName(`${transitionName}-enter`); + + cancelAnimationFrame(animationFrameRef.current); + clearTimeout(leaveTimeoutRef.current); + leaveTimeoutRef.current = undefined; + + animationFrameRef.current = requestAnimationFrame(() => { + setClassName(`${transitionName}-enter ${transitionName}-enter-active`); + + enterTimeoutRef.current = setTimeout(() => { + setClassName(''); + }, duration); + }); + }; + + const handleLeaveTransition = () => { + setClassName(`${transitionName}-leave`); + + cancelAnimationFrame(animationFrameRef.current); + clearTimeout(enterTimeoutRef.current); + enterTimeoutRef.current = undefined; + + animationFrameRef.current = requestAnimationFrame(() => { + setClassName(`${transitionName}-leave ${transitionName}-leave-active`); + + leaveTimeoutRef.current = setTimeout(() => { + setCachedChildren(null); + setClassName(''); + }, duration); + }); + }; + + useEffect(() => { + const child = toChildArray(children)[0]; + if (cachedChildren === child) return; - this.state = { - cachedChildren: null, - className: '', - } - } - - // TODO: refactor to stable lifecycle method - // eslint-disable-next-line - componentWillUpdate (nextProps) { - const { cachedChildren } = this.state - const child = toChildArray(nextProps.children)[0] - - if (cachedChildren === child) return null - - const patch = { - cachedChildren: child, - } - - // Enter transition if (child && !cachedChildren) { - patch.className = `${transitionName}-enter` - - cancelAnimationFrame(this.animationFrame) - clearTimeout(this.leaveTimeout) - this.leaveTimeout = undefined - - this.animationFrame = requestAnimationFrame(() => { - // Force it to render before we add the active class - // this.base.getBoundingClientRect() - - this.setState({ - className: `${transitionName}-enter ${transitionName}-enter-active`, - }) - - this.enterTimeout = setTimeout(() => { - this.setState({ className: '' }) - }, duration) - }) + handleEnterTransition(); + } else if (cachedChildren && !child && !leaveTimeoutRef.current) { + handleLeaveTransition(); } - // Leave transition - if (cachedChildren && !child && this.leaveTimeout === undefined) { - patch.cachedChildren = cachedChildren - patch.className = `${transitionName}-leave` - - cancelAnimationFrame(this.animationFrame) - clearTimeout(this.enterTimeout) - this.enterTimeout = undefined - this.animationFrame = requestAnimationFrame(() => { - this.setState({ - className: `${transitionName}-leave ${transitionName}-leave-active`, - }) - - this.leaveTimeout = setTimeout(() => { - this.setState({ - cachedChildren: null, - className: '', - }) - }, duration) - }) - } + setCachedChildren(child); + }, [children, cachedChildren]); // Dependency array to trigger effect on children change - // eslint-disable-next-line - this.setState(patch) - } - render () { - const { cachedChildren, className } = this.state + useEffect(() => { + return () => { + clearTimeout(enterTimeoutRef.current); + clearTimeout(leaveTimeoutRef.current); + cancelAnimationFrame(animationFrameRef.current); + }; + }, []); // Cleanup useEffect - if (!cachedChildren) { - return null - } + if (!cachedChildren) return null; - return cloneElement(cachedChildren, { - className: classNames(className, cachedChildren.props.className), - }) - } -} + return cloneElement(cachedChildren, { + className: classNames(className, cachedChildren.props.className), + }); +}; export default Slide From 7b0533b4bb4b3a2eee07b3aad2ab5ab82cb02e27 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Tue, 19 Mar 2024 23:30:32 +0200 Subject: [PATCH 02/27] @uppy/aws-s3-multipart: refactor to TS (#4902) --- .../src/HTTPCommunicationQueue.ts | 427 +++++++ ...tipartUploader.js => MultipartUploader.ts} | 153 ++- .../src/createSignedURL.test.js | 113 -- .../src/createSignedURL.test.ts | 141 +++ ...{createSignedURL.js => createSignedURL.ts} | 86 +- packages/@uppy/aws-s3-multipart/src/index.js | 934 --------------- .../src/{index.test.js => index.test.ts} | 327 +++--- packages/@uppy/aws-s3-multipart/src/index.ts | 1007 +++++++++++++++++ packages/@uppy/aws-s3-multipart/src/utils.ts | 28 + .../aws-s3-multipart/tsconfig.build.json | 30 + packages/@uppy/aws-s3-multipart/tsconfig.json | 26 + packages/@uppy/core/src/Uppy.ts | 5 +- 12 files changed, 2014 insertions(+), 1263 deletions(-) create mode 100644 packages/@uppy/aws-s3-multipart/src/HTTPCommunicationQueue.ts rename packages/@uppy/aws-s3-multipart/src/{MultipartUploader.js => MultipartUploader.ts} (56%) delete mode 100644 packages/@uppy/aws-s3-multipart/src/createSignedURL.test.js create mode 100644 packages/@uppy/aws-s3-multipart/src/createSignedURL.test.ts rename packages/@uppy/aws-s3-multipart/src/{createSignedURL.js => createSignedURL.ts} (73%) delete mode 100644 packages/@uppy/aws-s3-multipart/src/index.js rename packages/@uppy/aws-s3-multipart/src/{index.test.js => index.test.ts} (69%) create mode 100644 packages/@uppy/aws-s3-multipart/src/index.ts create mode 100644 packages/@uppy/aws-s3-multipart/src/utils.ts create mode 100644 packages/@uppy/aws-s3-multipart/tsconfig.build.json create mode 100644 packages/@uppy/aws-s3-multipart/tsconfig.json diff --git a/packages/@uppy/aws-s3-multipart/src/HTTPCommunicationQueue.ts b/packages/@uppy/aws-s3-multipart/src/HTTPCommunicationQueue.ts new file mode 100644 index 0000000000..0a86c50f5f --- /dev/null +++ b/packages/@uppy/aws-s3-multipart/src/HTTPCommunicationQueue.ts @@ -0,0 +1,427 @@ +import type { Meta, UppyFile } from '@uppy/utils/lib/UppyFile' +import type { + RateLimitedQueue, + WrapPromiseFunctionType, +} from '@uppy/utils/lib/RateLimitedQueue' +import { pausingUploadReason, type Chunk } from './MultipartUploader.ts' +import type AwsS3Multipart from './index.ts' +import { throwIfAborted } from './utils.ts' +import type { Body, UploadPartBytesResult, UploadResult } from './utils.ts' +import type { AwsS3MultipartOptions, uploadPartBytes } from './index.ts' + +function removeMetadataFromURL(urlString: string) { + const urlObject = new URL(urlString) + urlObject.search = '' + urlObject.hash = '' + return urlObject.href +} + +export class HTTPCommunicationQueue { + #abortMultipartUpload: WrapPromiseFunctionType< + AwsS3Multipart['abortMultipartUpload'] + > + + #cache = new WeakMap() + + #createMultipartUpload: WrapPromiseFunctionType< + AwsS3Multipart['createMultipartUpload'] + > + + #fetchSignature: WrapPromiseFunctionType['signPart']> + + #getUploadParameters: WrapPromiseFunctionType< + AwsS3Multipart['getUploadParameters'] + > + + #listParts: WrapPromiseFunctionType['listParts']> + + #previousRetryDelay: number + + #requests + + #retryDelays: { values: () => Iterator } + + #sendCompletionRequest: WrapPromiseFunctionType< + AwsS3Multipart['completeMultipartUpload'] + > + + #setS3MultipartState + + #uploadPartBytes: WrapPromiseFunctionType + + #getFile + + constructor( + requests: RateLimitedQueue, + options: AwsS3MultipartOptions, + setS3MultipartState: (file: UppyFile, result: UploadResult) => void, + getFile: (file: UppyFile) => UppyFile, + ) { + this.#requests = requests + this.#setS3MultipartState = setS3MultipartState + this.#getFile = getFile + this.setOptions(options) + } + + setOptions(options: Partial>): void { + const requests = this.#requests + + if ('abortMultipartUpload' in options) { + this.#abortMultipartUpload = requests.wrapPromiseFunction( + options.abortMultipartUpload as any, + { priority: 1 }, + ) + } + if ('createMultipartUpload' in options) { + this.#createMultipartUpload = requests.wrapPromiseFunction( + options.createMultipartUpload as any, + { priority: -1 }, + ) + } + if ('signPart' in options) { + this.#fetchSignature = requests.wrapPromiseFunction( + options.signPart as any, + ) + } + if ('listParts' in options) { + this.#listParts = requests.wrapPromiseFunction(options.listParts as any) + } + if ('completeMultipartUpload' in options) { + this.#sendCompletionRequest = requests.wrapPromiseFunction( + options.completeMultipartUpload as any, + { priority: 1 }, + ) + } + if ('retryDelays' in options) { + this.#retryDelays = options.retryDelays ?? [] + } + if ('uploadPartBytes' in options) { + this.#uploadPartBytes = requests.wrapPromiseFunction( + options.uploadPartBytes as any, + { priority: Infinity }, + ) + } + if ('getUploadParameters' in options) { + this.#getUploadParameters = requests.wrapPromiseFunction( + options.getUploadParameters as any, + ) + } + } + + async #shouldRetry(err: any, retryDelayIterator: Iterator) { + const requests = this.#requests + const status = err?.source?.status + + // TODO: this retry logic is taken out of Tus. We should have a centralized place for retrying, + // perhaps the rate limited queue, and dedupe all plugins with that. + if (status == null) { + return false + } + if (status === 403 && err.message === 'Request has expired') { + if (!requests.isPaused) { + // We don't want to exhaust the retryDelayIterator as long as there are + // more than one request in parallel, to give slower connection a chance + // to catch up with the expiry set in Companion. + if (requests.limit === 1 || this.#previousRetryDelay == null) { + const next = retryDelayIterator.next() + if (next == null || next.done) { + return false + } + // If there are more than 1 request done in parallel, the RLQ limit is + // decreased and the failed request is requeued after waiting for a bit. + // If there is only one request in parallel, the limit can't be + // decreased, so we iterate over `retryDelayIterator` as we do for + // other failures. + // `#previousRetryDelay` caches the value so we can re-use it next time. + this.#previousRetryDelay = next.value + } + // No need to stop the other requests, we just want to lower the limit. + requests.rateLimit(0) + await new Promise((resolve) => + setTimeout(resolve, this.#previousRetryDelay), + ) + } + } else if (status === 429) { + // HTTP 429 Too Many Requests => to avoid the whole download to fail, pause all requests. + if (!requests.isPaused) { + const next = retryDelayIterator.next() + if (next == null || next.done) { + return false + } + requests.rateLimit(next.value) + } + } else if (status > 400 && status < 500 && status !== 409) { + // HTTP 4xx, the server won't send anything, it's doesn't make sense to retry + return false + } else if (typeof navigator !== 'undefined' && navigator.onLine === false) { + // The navigator is offline, let's wait for it to come back online. + if (!requests.isPaused) { + requests.pause() + window.addEventListener( + 'online', + () => { + requests.resume() + }, + { once: true }, + ) + } + } else { + // Other error code means the request can be retried later. + const next = retryDelayIterator.next() + if (next == null || next.done) { + return false + } + await new Promise((resolve) => setTimeout(resolve, next.value)) + } + return true + } + + async getUploadId( + file: UppyFile, + signal: AbortSignal, + ): Promise { + let cachedResult + // As the cache is updated asynchronously, there could be a race condition + // where we just miss a new result so we loop here until we get nothing back, + // at which point it's out turn to create a new cache entry. + // eslint-disable-next-line no-cond-assign + while ((cachedResult = this.#cache.get(file.data)) != null) { + try { + return await cachedResult + } catch { + // In case of failure, we want to ignore the cached error. + // At this point, either there's a new cached value, or we'll exit the loop a create a new one. + } + } + + const promise = this.#createMultipartUpload(this.#getFile(file), signal) + + const abortPromise = () => { + promise.abort(signal.reason) + this.#cache.delete(file.data) + } + signal.addEventListener('abort', abortPromise, { once: true }) + this.#cache.set(file.data, promise) + promise.then( + async (result) => { + signal.removeEventListener('abort', abortPromise) + this.#setS3MultipartState(file, result) + this.#cache.set(file.data, result) + }, + () => { + signal.removeEventListener('abort', abortPromise) + this.#cache.delete(file.data) + }, + ) + + return promise + } + + async abortFileUpload(file: UppyFile): Promise { + const result = this.#cache.get(file.data) + if (result == null) { + // If the createMultipartUpload request never was made, we don't + // need to send the abortMultipartUpload request. + return + } + // Remove the cache entry right away for follow-up requests do not try to + // use the soon-to-be aborted cached values. + this.#cache.delete(file.data) + this.#setS3MultipartState(file, Object.create(null)) + let awaitedResult + try { + awaitedResult = await result + } catch { + // If the cached result rejects, there's nothing to abort. + return + } + await this.#abortMultipartUpload(this.#getFile(file), awaitedResult) + } + + async #nonMultipartUpload( + file: UppyFile, + chunk: Chunk, + signal?: AbortSignal, + ): Promise { + const { + method = 'POST', + url, + fields, + headers, + } = await this.#getUploadParameters(this.#getFile(file), { + signal, + }).abortOn(signal) + + let body + const data = chunk.getData() + if (method.toUpperCase() === 'POST') { + const formData = new FormData() + Object.entries(fields!).forEach(([key, value]) => + formData.set(key, value), + ) + formData.set('file', data) + body = formData + } else { + body = data + } + + const { onProgress, onComplete } = chunk + + const result = await this.#uploadPartBytes({ + signature: { url, headers, method } as any, + body, + size: data.size, + onProgress, + onComplete, + signal, + }).abortOn(signal) + + return 'location' in result ? + (result as UploadPartBytesResult & B) + : ({ + location: removeMetadataFromURL(url), + ...result, + } as any) + } + + async uploadFile( + file: UppyFile, + chunks: Chunk[], + signal: AbortSignal, + ): Promise> { + throwIfAborted(signal) + if (chunks.length === 1 && !chunks[0].shouldUseMultipart) { + return this.#nonMultipartUpload(file, chunks[0], signal) + } + const { uploadId, key } = await this.getUploadId(file, signal) + throwIfAborted(signal) + try { + const parts = await Promise.all( + chunks.map((chunk, i) => this.uploadChunk(file, i + 1, chunk, signal)), + ) + throwIfAborted(signal) + return await this.#sendCompletionRequest( + this.#getFile(file), + { key, uploadId, parts, signal }, + signal, + ).abortOn(signal) + } catch (err) { + if (err?.cause !== pausingUploadReason && err?.name !== 'AbortError') { + // We purposefully don't wait for the promise and ignore its status, + // because we want the error `err` to bubble up ASAP to report it to the + // user. A failure to abort is not that big of a deal anyway. + this.abortFileUpload(file) + } + throw err + } + } + + restoreUploadFile(file: UppyFile, uploadIdAndKey: UploadResult): void { + this.#cache.set(file.data, uploadIdAndKey) + } + + async resumeUploadFile( + file: UppyFile, + chunks: Array, + signal: AbortSignal, + ): Promise { + throwIfAborted(signal) + if ( + chunks.length === 1 && + chunks[0] != null && + !chunks[0].shouldUseMultipart + ) { + return this.#nonMultipartUpload(file, chunks[0], signal) + } + const { uploadId, key } = await this.getUploadId(file, signal) + throwIfAborted(signal) + const alreadyUploadedParts = await this.#listParts( + this.#getFile(file), + { uploadId, key, signal }, + signal, + ).abortOn(signal) + throwIfAborted(signal) + const parts = await Promise.all( + chunks.map((chunk, i) => { + const partNumber = i + 1 + const alreadyUploadedInfo = alreadyUploadedParts.find( + ({ PartNumber }) => PartNumber === partNumber, + ) + if (alreadyUploadedInfo == null) { + return this.uploadChunk(file, partNumber, chunk!, signal) + } + // Already uploaded chunks are set to null. If we are restoring the upload, we need to mark it as already uploaded. + chunk?.setAsUploaded?.() + return { PartNumber: partNumber, ETag: alreadyUploadedInfo.ETag } + }), + ) + throwIfAborted(signal) + return this.#sendCompletionRequest( + this.#getFile(file), + { key, uploadId, parts, signal }, + signal, + ).abortOn(signal) + } + + async uploadChunk( + file: UppyFile, + partNumber: number, + chunk: Chunk, + signal: AbortSignal, + ): Promise { + throwIfAborted(signal) + const { uploadId, key } = await this.getUploadId(file, signal) + + const signatureRetryIterator = this.#retryDelays.values() + const chunkRetryIterator = this.#retryDelays.values() + const shouldRetrySignature = () => { + const next = signatureRetryIterator.next() + if (next == null || next.done) { + return null + } + return next.value + } + + for (;;) { + throwIfAborted(signal) + const chunkData = chunk.getData() + const { onProgress, onComplete } = chunk + let signature + + try { + signature = await this.#fetchSignature(this.#getFile(file), { + uploadId, + key, + partNumber, + body: chunkData, + signal, + }).abortOn(signal) + } catch (err) { + const timeout = shouldRetrySignature() + if (timeout == null || signal.aborted) { + throw err + } + await new Promise((resolve) => setTimeout(resolve, timeout)) + // eslint-disable-next-line no-continue + continue + } + + throwIfAborted(signal) + try { + return { + PartNumber: partNumber, + ...(await this.#uploadPartBytes({ + signature, + body: chunkData, + size: chunkData.size, + onProgress, + onComplete, + signal, + }).abortOn(signal)), + } + } catch (err) { + if (!(await this.#shouldRetry(err, chunkRetryIterator))) throw err + } + } + } +} diff --git a/packages/@uppy/aws-s3-multipart/src/MultipartUploader.js b/packages/@uppy/aws-s3-multipart/src/MultipartUploader.ts similarity index 56% rename from packages/@uppy/aws-s3-multipart/src/MultipartUploader.js rename to packages/@uppy/aws-s3-multipart/src/MultipartUploader.ts index 1bc1f7b9b8..665041afb4 100644 --- a/packages/@uppy/aws-s3-multipart/src/MultipartUploader.js +++ b/packages/@uppy/aws-s3-multipart/src/MultipartUploader.ts @@ -1,24 +1,53 @@ +import type { Uppy } from '@uppy/core' import { AbortController } from '@uppy/utils/lib/AbortController' +import type { Meta, UppyFile } from '@uppy/utils/lib/UppyFile' +import type { HTTPCommunicationQueue } from './HTTPCommunicationQueue' +import type { Body } from './utils' const MB = 1024 * 1024 +interface MultipartUploaderOptions { + getChunkSize?: (file: { size: number }) => number + onProgress?: (bytesUploaded: number, bytesTotal: number) => void + onPartComplete?: (part: { PartNumber: number; ETag: string }) => void + shouldUseMultipart?: boolean | ((file: UppyFile) => boolean) + onSuccess?: (result: B) => void + onError?: (err: unknown) => void + companionComm: HTTPCommunicationQueue + file: UppyFile + log: Uppy['log'] + + uploadId: string + key: string +} + const defaultOptions = { - getChunkSize (file) { + getChunkSize(file: { size: number }) { return Math.ceil(file.size / 10000) }, - onProgress () {}, - onPartComplete () {}, - onSuccess () {}, - onError (err) { + onProgress() {}, + onPartComplete() {}, + onSuccess() {}, + onError(err: unknown) { throw err }, +} satisfies Partial> + +export interface Chunk { + getData: () => Blob + onProgress: (ev: ProgressEvent) => void + onComplete: (etag: string) => void + shouldUseMultipart: boolean + setAsUploaded?: () => void } -function ensureInt (value) { +function ensureInt(value: T): T extends number | string ? number : never { if (typeof value === 'string') { + // @ts-expect-error TS is not able to recognize it's fine. return parseInt(value, 10) } if (typeof value === 'number') { + // @ts-expect-error TS is not able to recognize it's fine. return value } throw new TypeError('Expected a number') @@ -32,47 +61,41 @@ export const pausingUploadReason = Symbol('pausing upload, not an actual error') * (based on the user-provided `shouldUseMultipart` option value) and to manage * the chunk splitting. */ -class MultipartUploader { +class MultipartUploader { + options: MultipartUploaderOptions & + Required, keyof typeof defaultOptions>> + #abortController = new AbortController() - /** @type {import("../types/chunk").Chunk[]} */ - #chunks + #chunks: Array - /** @type {{ uploaded: number, etag?: string, done?: boolean }[]} */ - #chunkState + #chunkState: { uploaded: number; etag?: string; done?: boolean }[] /** * The (un-chunked) data to upload. - * - * @type {Blob} */ - #data + #data: Blob - /** @type {import("@uppy/core").UppyFile} */ - #file + #file: UppyFile - /** @type {boolean} */ #uploadHasStarted = false - /** @type {(err?: Error | any) => void} */ - #onError + #onError: (err: unknown) => void - /** @type {() => void} */ - #onSuccess + #onSuccess: (result: B) => void - /** @type {import('../types/index').AwsS3MultipartOptions["shouldUseMultipart"]} */ - #shouldUseMultipart + #shouldUseMultipart: MultipartUploaderOptions['shouldUseMultipart'] - /** @type {boolean} */ - #isRestoring + #isRestoring: boolean - #onReject = (err) => (err?.cause === pausingUploadReason ? null : this.#onError(err)) + #onReject = (err: unknown) => + (err as any)?.cause === pausingUploadReason ? null : this.#onError(err) #maxMultipartParts = 10_000 #minPartSize = 5 * MB - constructor (data, options) { + constructor(data: Blob, options: MultipartUploaderOptions) { this.options = { ...defaultOptions, ...options, @@ -89,7 +112,7 @@ class MultipartUploader { // When we are restoring an upload, we already have an UploadId and a Key. Otherwise // we need to call `createMultipartUpload` to get an `uploadId` and a `key`. // Non-multipart uploads are not restorable. - this.#isRestoring = options.uploadId && options.key + this.#isRestoring = (options.uploadId && options.key) as any as boolean this.#initChunks() } @@ -98,15 +121,19 @@ class MultipartUploader { // and calculates the optimal part size. When using multipart part uploads every part except for the last has // to be at least 5 MB and there can be no more than 10K parts. // This means we sometimes need to change the preferred part size from the user in order to meet these requirements. - #initChunks () { + #initChunks() { const fileSize = this.#data.size - const shouldUseMultipart = typeof this.#shouldUseMultipart === 'function' - ? this.#shouldUseMultipart(this.#file) + const shouldUseMultipart = + typeof this.#shouldUseMultipart === 'function' ? + this.#shouldUseMultipart(this.#file) : Boolean(this.#shouldUseMultipart) if (shouldUseMultipart && fileSize > this.#minPartSize) { // At least 5MB per request: - let chunkSize = Math.max(this.options.getChunkSize(this.#data), this.#minPartSize) + let chunkSize = Math.max( + this.options.getChunkSize(this.#data), + this.#minPartSize, + ) let arraySize = Math.floor(fileSize / chunkSize) // At most 10k requests per file: @@ -132,41 +159,48 @@ class MultipartUploader { shouldUseMultipart, } if (this.#isRestoring) { - const size = offset + chunkSize > fileSize ? fileSize - offset : chunkSize + const size = + offset + chunkSize > fileSize ? fileSize - offset : chunkSize // setAsUploaded is called by listPart, to keep up-to-date the // quantity of data that is left to actually upload. - this.#chunks[j].setAsUploaded = () => { + this.#chunks[j]!.setAsUploaded = () => { this.#chunks[j] = null this.#chunkState[j].uploaded = size } } } } else { - this.#chunks = [{ - getData: () => this.#data, - onProgress: this.#onPartProgress(0), - onComplete: this.#onPartComplete(0), - shouldUseMultipart, - }] + this.#chunks = [ + { + getData: () => this.#data, + onProgress: this.#onPartProgress(0), + onComplete: this.#onPartComplete(0), + shouldUseMultipart, + }, + ] } this.#chunkState = this.#chunks.map(() => ({ uploaded: 0 })) } - #createUpload () { - this - .options.companionComm.uploadFile(this.#file, this.#chunks, this.#abortController.signal) + #createUpload() { + this.options.companionComm + .uploadFile( + this.#file, + this.#chunks as Chunk[], + this.#abortController.signal, + ) .then(this.#onSuccess, this.#onReject) this.#uploadHasStarted = true } - #resumeUpload () { - this - .options.companionComm.resumeUploadFile(this.#file, this.#chunks, this.#abortController.signal) + #resumeUpload() { + this.options.companionComm + .resumeUploadFile(this.#file, this.#chunks, this.#abortController.signal) .then(this.#onSuccess, this.#onReject) } - #onPartProgress = (index) => (ev) => { + #onPartProgress = (index: number) => (ev: ProgressEvent) => { if (!ev.lengthComputable) return this.#chunkState[index].uploaded = ensureInt(ev.loaded) @@ -175,7 +209,7 @@ class MultipartUploader { this.options.onProgress(totalUploaded, this.#data.size) } - #onPartComplete = (index) => (etag) => { + #onPartComplete = (index: number) => (etag: string) => { // This avoids the net::ERR_OUT_OF_MEMORY in Chromium Browsers. this.#chunks[index] = null this.#chunkState[index].etag = etag @@ -188,37 +222,44 @@ class MultipartUploader { this.options.onPartComplete(part) } - #abortUpload () { + #abortUpload() { this.#abortController.abort() - this.options.companionComm.abortFileUpload(this.#file).catch((err) => this.options.log(err)) + this.options.companionComm + .abortFileUpload(this.#file) + .catch((err: unknown) => this.options.log(err as Error)) } - start () { + start(): void { if (this.#uploadHasStarted) { - if (!this.#abortController.signal.aborted) this.#abortController.abort(pausingUploadReason) + if (!this.#abortController.signal.aborted) + this.#abortController.abort(pausingUploadReason) this.#abortController = new AbortController() this.#resumeUpload() } else if (this.#isRestoring) { - this.options.companionComm.restoreUploadFile(this.#file, { uploadId: this.options.uploadId, key: this.options.key }) + this.options.companionComm.restoreUploadFile(this.#file, { + uploadId: this.options.uploadId, + key: this.options.key, + }) this.#resumeUpload() } else { this.#createUpload() } } - pause () { + pause(): void { this.#abortController.abort(pausingUploadReason) // Swap it out for a new controller, because this instance may be resumed later. this.#abortController = new AbortController() } - abort (opts = undefined) { + abort(opts?: { really?: boolean }): void { if (opts?.really) this.#abortUpload() else this.pause() } // TODO: remove this in the next major - get chunkState () { + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types + get chunkState() { return this.#chunkState } } diff --git a/packages/@uppy/aws-s3-multipart/src/createSignedURL.test.js b/packages/@uppy/aws-s3-multipart/src/createSignedURL.test.js deleted file mode 100644 index bb873829eb..0000000000 --- a/packages/@uppy/aws-s3-multipart/src/createSignedURL.test.js +++ /dev/null @@ -1,113 +0,0 @@ -import { describe, it, beforeEach, afterEach } from 'vitest' -import assert from 'node:assert' -import { S3Client, UploadPartCommand, PutObjectCommand } from '@aws-sdk/client-s3' -import { getSignedUrl } from '@aws-sdk/s3-request-presigner' -import createSignedURL from './createSignedURL.js' - -const bucketName = 'some-bucket' -const s3ClientOptions = { - region: 'us-bar-1', - credentials: { - accessKeyId: 'foo', - secretAccessKey: 'bar', - sessionToken: 'foobar', - }, -} -const { Date: OriginalDate } = globalThis - -describe('createSignedURL', () => { - beforeEach(() => { - const now_ms = OriginalDate.now() - globalThis.Date = function Date () { - if (new.target) { - return Reflect.construct(OriginalDate, [now_ms]) - } - return Reflect.apply(OriginalDate, this, [now_ms]) - } - globalThis.Date.now = function now () { - return now_ms - } - }) - afterEach(() => { - globalThis.Date = OriginalDate - }) - it('should be able to sign non-multipart upload', async () => { - const client = new S3Client(s3ClientOptions) - assert.strictEqual( - (await createSignedURL({ - accountKey: s3ClientOptions.credentials.accessKeyId, - accountSecret: s3ClientOptions.credentials.secretAccessKey, - sessionToken: s3ClientOptions.credentials.sessionToken, - bucketName, - Key: 'some/key', - Region: s3ClientOptions.region, - expires: 900, - })).searchParams.get('X-Amz-Signature'), - new URL(await getSignedUrl(client, new PutObjectCommand({ - Bucket: bucketName, - Fields: {}, - Key: 'some/key', - }), { expiresIn: 900 })).searchParams.get('X-Amz-Signature'), - ) - }) - it('should be able to sign multipart upload', async () => { - const client = new S3Client(s3ClientOptions) - const partNumber = 99 - const uploadId = 'dummyUploadId' - assert.strictEqual( - (await createSignedURL({ - accountKey: s3ClientOptions.credentials.accessKeyId, - accountSecret: s3ClientOptions.credentials.secretAccessKey, - sessionToken: s3ClientOptions.credentials.sessionToken, - uploadId, - partNumber, - bucketName, - Key: 'some/key', - Region: s3ClientOptions.region, - expires: 900, - })).searchParams.get('X-Amz-Signature'), - new URL(await getSignedUrl(client, new UploadPartCommand({ - Bucket: bucketName, - UploadId: uploadId, - PartNumber: partNumber, - Key: 'some/key', - }), { expiresIn: 900 })).searchParams.get('X-Amz-Signature'), - ) - }) - it('should escape path and query as restricted to RFC 3986', async () => { - const client = new S3Client(s3ClientOptions) - const partNumber = 99 - const specialChars = ';?:@&=+$,#!\'()' - const uploadId = `Upload${specialChars}Id` - // '.*' chars of path should be encoded - const Key = `${specialChars}.*/${specialChars}.*.ext` - const implResult = - await createSignedURL({ - accountKey: s3ClientOptions.credentials.accessKeyId, - accountSecret: s3ClientOptions.credentials.secretAccessKey, - sessionToken: s3ClientOptions.credentials.sessionToken, - uploadId, - partNumber, - bucketName, - Key, - Region: s3ClientOptions.region, - expires: 900, - }) - const sdkResult = - new URL( - await getSignedUrl(client, new UploadPartCommand({ - Bucket: bucketName, - UploadId: uploadId, - PartNumber: partNumber, - Key, - }), { expiresIn: 900 } - ) - ) - assert.strictEqual(implResult.pathname, sdkResult.pathname) - - const extractUploadId = /([?&])uploadId=([^&]+?)(&|$)/ - const extractSignature = /([?&])X-Amz-Signature=([^&]+?)(&|$)/ - assert.strictEqual(implResult.search.match(extractUploadId)[2], sdkResult.search.match(extractUploadId)[2]) - assert.strictEqual(implResult.search.match(extractSignature)[2], sdkResult.search.match(extractSignature)[2]) - }) -}) diff --git a/packages/@uppy/aws-s3-multipart/src/createSignedURL.test.ts b/packages/@uppy/aws-s3-multipart/src/createSignedURL.test.ts new file mode 100644 index 0000000000..7ac738b294 --- /dev/null +++ b/packages/@uppy/aws-s3-multipart/src/createSignedURL.test.ts @@ -0,0 +1,141 @@ +import { describe, it, beforeEach, afterEach } from 'vitest' +import assert from 'node:assert' +import { + S3Client, + UploadPartCommand, + PutObjectCommand, +} from '@aws-sdk/client-s3' +import { getSignedUrl } from '@aws-sdk/s3-request-presigner' +import createSignedURL from './createSignedURL.ts' + +const bucketName = 'some-bucket' +const s3ClientOptions = { + region: 'us-bar-1', + credentials: { + accessKeyId: 'foo', + secretAccessKey: 'bar', + sessionToken: 'foobar', + }, +} +const { Date: OriginalDate } = globalThis + +describe('createSignedURL', () => { + beforeEach(() => { + const now_ms = OriginalDate.now() + // @ts-expect-error we're touching globals for test purposes. + globalThis.Date = function Date() { + if (new.target) { + return Reflect.construct(OriginalDate, [now_ms]) + } + return Reflect.apply(OriginalDate, this, [now_ms]) + } + globalThis.Date.now = function now() { + return now_ms + } + }) + afterEach(() => { + globalThis.Date = OriginalDate + }) + it('should be able to sign non-multipart upload', async () => { + const client = new S3Client(s3ClientOptions) + assert.strictEqual( + ( + await createSignedURL({ + accountKey: s3ClientOptions.credentials.accessKeyId, + accountSecret: s3ClientOptions.credentials.secretAccessKey, + sessionToken: s3ClientOptions.credentials.sessionToken, + bucketName, + Key: 'some/key', + Region: s3ClientOptions.region, + expires: 900, + }) + ).searchParams.get('X-Amz-Signature'), + new URL( + await getSignedUrl( + client, + new PutObjectCommand({ + Bucket: bucketName, + Key: 'some/key', + }), + { expiresIn: 900 }, + ), + ).searchParams.get('X-Amz-Signature'), + ) + }) + it('should be able to sign multipart upload', async () => { + const client = new S3Client(s3ClientOptions) + const partNumber = 99 + const uploadId = 'dummyUploadId' + assert.strictEqual( + ( + await createSignedURL({ + accountKey: s3ClientOptions.credentials.accessKeyId, + accountSecret: s3ClientOptions.credentials.secretAccessKey, + sessionToken: s3ClientOptions.credentials.sessionToken, + uploadId, + partNumber, + bucketName, + Key: 'some/key', + Region: s3ClientOptions.region, + expires: 900, + }) + ).searchParams.get('X-Amz-Signature'), + new URL( + await getSignedUrl( + client, + new UploadPartCommand({ + Bucket: bucketName, + UploadId: uploadId, + PartNumber: partNumber, + Key: 'some/key', + }), + { expiresIn: 900 }, + ), + ).searchParams.get('X-Amz-Signature'), + ) + }) + + it('should escape path and query as restricted to RFC 3986', async () => { + const client = new S3Client(s3ClientOptions) + const partNumber = 99 + const specialChars = ";?:@&=+$,#!'()" + const uploadId = `Upload${specialChars}Id` + // '.*' chars of path should be encoded + const Key = `${specialChars}.*/${specialChars}.*.ext` + const implResult = await createSignedURL({ + accountKey: s3ClientOptions.credentials.accessKeyId, + accountSecret: s3ClientOptions.credentials.secretAccessKey, + sessionToken: s3ClientOptions.credentials.sessionToken, + uploadId, + partNumber, + bucketName, + Key, + Region: s3ClientOptions.region, + expires: 900, + }) + const sdkResult = new URL( + await getSignedUrl( + client, + new UploadPartCommand({ + Bucket: bucketName, + UploadId: uploadId, + PartNumber: partNumber, + Key, + }), + { expiresIn: 900 }, + ), + ) + assert.strictEqual(implResult.pathname, sdkResult.pathname) + + const extractUploadId = /([?&])uploadId=([^&]+?)(&|$)/ + const extractSignature = /([?&])X-Amz-Signature=([^&]+?)(&|$)/ + assert.strictEqual( + implResult.search.match(extractUploadId)![2], + sdkResult.search.match(extractUploadId)![2], + ) + assert.strictEqual( + implResult.search.match(extractSignature)![2], + sdkResult.search.match(extractSignature)![2], + ) + }) +}) diff --git a/packages/@uppy/aws-s3-multipart/src/createSignedURL.js b/packages/@uppy/aws-s3-multipart/src/createSignedURL.ts similarity index 73% rename from packages/@uppy/aws-s3-multipart/src/createSignedURL.js rename to packages/@uppy/aws-s3-multipart/src/createSignedURL.ts index bef07e5b9c..e0a261c6c0 100644 --- a/packages/@uppy/aws-s3-multipart/src/createSignedURL.js +++ b/packages/@uppy/aws-s3-multipart/src/createSignedURL.ts @@ -5,44 +5,51 @@ * * @see https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html#create-canonical-request * - * @param {object} param0 - * @param {string} param0.method – The HTTP method. - * @param {string} param0.CanonicalUri – The URI-encoded version of the absolute + * @param param0 + * @param param0.method – The HTTP method. + * @param param0.CanonicalUri – The URI-encoded version of the absolute * path component URL (everything between the host and the question mark * character (?) that starts the query string parameters). If the absolute path * is empty, use a forward slash character (/). - * @param {string} param0.CanonicalQueryString – The URL-encoded query string + * @param param0.CanonicalQueryString – The URL-encoded query string * parameters, separated by ampersands (&). Percent-encode reserved characters, * including the space character. Encode names and values separately. If there * are empty parameters, append the equals sign to the parameter name before * encoding. After encoding, sort the parameters alphabetically by key name. If * there is no query string, use an empty string (""). - * @param {Record} param0.SignedHeaders – The request headers, + * @param param0.SignedHeaders – The request headers, * that will be signed, and their values, separated by newline characters. * For the values, trim any leading or trailing spaces, convert sequential * spaces to a single space, and separate the values for a multi-value header * using commas. You must include the host header (HTTP/1.1), and any x-amz-* * headers in the signature. You can optionally include other standard headers * in the signature, such as content-type. - * @param {string} param0.HashedPayload – A string created using the payload in + * @param param0.HashedPayload – A string created using the payload in * the body of the HTTP request as input to a hash function. This string uses * lowercase hexadecimal characters. If the payload is empty, use an empty * string as the input to the hash function. - * @returns {string} */ -function createCanonicalRequest ({ +function createCanonicalRequest({ method = 'PUT', CanonicalUri = '/', CanonicalQueryString = '', SignedHeaders, HashedPayload, -}) { - const headerKeys = Object.keys(SignedHeaders).map(k => k.toLowerCase()).sort() +}: { + method?: string + CanonicalUri: string + CanonicalQueryString: string + SignedHeaders: Record + HashedPayload: string +}): string { + const headerKeys = Object.keys(SignedHeaders) + .map((k) => k.toLowerCase()) + .sort() return [ method, CanonicalUri, CanonicalQueryString, - ...headerKeys.map(k => `${k}:${SignedHeaders[k]}`), + ...headerKeys.map((k) => `${k}:${SignedHeaders[k]}`), '', headerKeys.join(';'), HashedPayload, @@ -52,17 +59,23 @@ function createCanonicalRequest ({ const ec = new TextEncoder() const algorithm = { name: 'HMAC', hash: 'SHA-256' } -async function digest (data) { +async function digest(data: string): ReturnType { const { subtle } = globalThis.crypto return subtle.digest(algorithm.hash, ec.encode(data)) } -async function generateHmacKey (secret) { +async function generateHmacKey(secret: string | Uint8Array | ArrayBuffer) { const { subtle } = globalThis.crypto - return subtle.importKey('raw', typeof secret === 'string' ? ec.encode(secret) : secret, algorithm, false, ['sign']) + return subtle.importKey( + 'raw', + typeof secret === 'string' ? ec.encode(secret) : secret, + algorithm, + false, + ['sign'], + ) } -function arrayBufferToHexString (arrayBuffer) { +function arrayBufferToHexString(arrayBuffer: ArrayBuffer) { const byteArray = new Uint8Array(arrayBuffer) let hexString = '' for (let i = 0; i < byteArray.length; i++) { @@ -71,27 +84,35 @@ function arrayBufferToHexString (arrayBuffer) { return hexString } -async function hash (key, data) { +async function hash(key: Parameters[0], data: string) { const { subtle } = globalThis.crypto return subtle.sign(algorithm, await generateHmacKey(key), ec.encode(data)) } -function percentEncode(c) { - return `%${c.charCodeAt(0).toString(16).toUpperCase()}` -} - /** * @see https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html - * @param {Record} param0 - * @returns {Promise} the signed URL */ -export default async function createSignedURL ({ - accountKey, accountSecret, sessionToken, +export default async function createSignedURL({ + accountKey, + accountSecret, + sessionToken, bucketName, - Key, Region, + Key, + Region, expires, - uploadId, partNumber, -}) { + uploadId, + partNumber, +}: { + accountKey: string + accountSecret: string + sessionToken: string + bucketName: string + Key: string + Region: string + expires: string | number + uploadId?: string + partNumber?: string | number +}): Promise { const Service = 's3' const host = `${bucketName}.${Service}.${Region}.amazonaws.com` /** @@ -100,7 +121,7 @@ export default async function createSignedURL ({ * * @see https://tc39.es/ecma262/#sec-encodeuri-uri */ - const CanonicalUri = `/${encodeURI(Key).replace(/[;?:@&=+$,#!'()*]/g, percentEncode)}` + const CanonicalUri = `/${encodeURI(Key).replace(/[;?:@&=+$,#!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)}` const payload = 'UNSIGNED-PAYLOAD' const requestDateTime = new Date().toISOString().replace(/[-:]|\.\d+/g, '') // YYYYMMDDTHHMMSSZ @@ -113,14 +134,17 @@ export default async function createSignedURL ({ url.searchParams.set('X-Amz-Content-Sha256', payload) url.searchParams.set('X-Amz-Credential', `${accountKey}/${scope}`) url.searchParams.set('X-Amz-Date', requestDateTime) - url.searchParams.set('X-Amz-Expires', expires) + url.searchParams.set('X-Amz-Expires', expires as string) // We are signing on the client, so we expect there's going to be a session token: url.searchParams.set('X-Amz-Security-Token', sessionToken) url.searchParams.set('X-Amz-SignedHeaders', 'host') // Those two are present only for Multipart Uploads: - if (partNumber) url.searchParams.set('partNumber', partNumber) + if (partNumber) url.searchParams.set('partNumber', partNumber as string) if (uploadId) url.searchParams.set('uploadId', uploadId) - url.searchParams.set('x-id', partNumber && uploadId ? 'UploadPart' : 'PutObject') + url.searchParams.set( + 'x-id', + partNumber && uploadId ? 'UploadPart' : 'PutObject', + ) // Step 1: Create a canonical request const canonical = createCanonicalRequest({ diff --git a/packages/@uppy/aws-s3-multipart/src/index.js b/packages/@uppy/aws-s3-multipart/src/index.js deleted file mode 100644 index 4c23ae9772..0000000000 --- a/packages/@uppy/aws-s3-multipart/src/index.js +++ /dev/null @@ -1,934 +0,0 @@ -import BasePlugin from '@uppy/core/lib/BasePlugin.js' -import { RequestClient } from '@uppy/companion-client' -import EventManager from '@uppy/utils/lib/EventManager' -import { RateLimitedQueue } from '@uppy/utils/lib/RateLimitedQueue' -import { filterNonFailedFiles, filterFilesToEmitUploadStarted } from '@uppy/utils/lib/fileFilters' -import { createAbortError } from '@uppy/utils/lib/AbortController' - -import MultipartUploader, { pausingUploadReason } from './MultipartUploader.js' -import createSignedURL from './createSignedURL.js' -import packageJson from '../package.json' - -function assertServerError (res) { - if (res && res.error) { - const error = new Error(res.message) - Object.assign(error, res.error) - throw error - } - return res -} - -function removeMetadataFromURL (urlString) { - const urlObject = new URL(urlString) - urlObject.search = '' - urlObject.hash = '' - return urlObject.href -} - -/** - * Computes the expiry time for a request signed with temporary credentials. If - * no expiration was provided, or an invalid value (e.g. in the past) is - * provided, undefined is returned. This function assumes the client clock is in - * sync with the remote server, which is a requirement for the signature to be - * validated for AWS anyway. - * - * @param {import('../types/index.js').AwsS3STSResponse['credentials']} credentials - * @returns {number | undefined} - */ -function getExpiry (credentials) { - const expirationDate = credentials.Expiration - if (expirationDate) { - const timeUntilExpiry = Math.floor((new Date(expirationDate) - Date.now()) / 1000) - if (timeUntilExpiry > 9) { - return timeUntilExpiry - } - } - return undefined -} - -function getAllowedMetadata ({ meta, allowedMetaFields, querify = false }) { - const metaFields = allowedMetaFields ?? Object.keys(meta) - - if (!meta) return {} - - return Object.fromEntries( - metaFields - .filter(key => meta[key] != null) - .map((key) => { - const realKey = querify ? `metadata[${key}]` : key - const value = String(meta[key]) - return [realKey, value] - }), - ) -} - -function throwIfAborted (signal) { - if (signal?.aborted) { throw createAbortError('The operation was aborted', { cause: signal.reason }) } -} - -class HTTPCommunicationQueue { - #abortMultipartUpload - - #cache = new WeakMap() - - #createMultipartUpload - - #fetchSignature - - #getUploadParameters - - #listParts - - #previousRetryDelay - - #requests - - #retryDelays - - #sendCompletionRequest - - #setS3MultipartState - - #uploadPartBytes - - #getFile - - constructor (requests, options, setS3MultipartState, getFile) { - this.#requests = requests - this.#setS3MultipartState = setS3MultipartState - this.#getFile = getFile - this.setOptions(options) - } - - setOptions (options) { - const requests = this.#requests - - if ('abortMultipartUpload' in options) { - this.#abortMultipartUpload = requests.wrapPromiseFunction(options.abortMultipartUpload, { priority:1 }) - } - if ('createMultipartUpload' in options) { - this.#createMultipartUpload = requests.wrapPromiseFunction(options.createMultipartUpload, { priority:-1 }) - } - if ('signPart' in options) { - this.#fetchSignature = requests.wrapPromiseFunction(options.signPart) - } - if ('listParts' in options) { - this.#listParts = requests.wrapPromiseFunction(options.listParts) - } - if ('completeMultipartUpload' in options) { - this.#sendCompletionRequest = requests.wrapPromiseFunction(options.completeMultipartUpload, { priority:1 }) - } - if ('retryDelays' in options) { - this.#retryDelays = options.retryDelays ?? [] - } - if ('uploadPartBytes' in options) { - this.#uploadPartBytes = requests.wrapPromiseFunction(options.uploadPartBytes, { priority:Infinity }) - } - if ('getUploadParameters' in options) { - this.#getUploadParameters = requests.wrapPromiseFunction(options.getUploadParameters) - } - } - - async #shouldRetry (err, retryDelayIterator) { - const requests = this.#requests - const status = err?.source?.status - - // TODO: this retry logic is taken out of Tus. We should have a centralized place for retrying, - // perhaps the rate limited queue, and dedupe all plugins with that. - if (status == null) { - return false - } - if (status === 403 && err.message === 'Request has expired') { - if (!requests.isPaused) { - // We don't want to exhaust the retryDelayIterator as long as there are - // more than one request in parallel, to give slower connection a chance - // to catch up with the expiry set in Companion. - if (requests.limit === 1 || this.#previousRetryDelay == null) { - const next = retryDelayIterator.next() - if (next == null || next.done) { - return false - } - // If there are more than 1 request done in parallel, the RLQ limit is - // decreased and the failed request is requeued after waiting for a bit. - // If there is only one request in parallel, the limit can't be - // decreased, so we iterate over `retryDelayIterator` as we do for - // other failures. - // `#previousRetryDelay` caches the value so we can re-use it next time. - this.#previousRetryDelay = next.value - } - // No need to stop the other requests, we just want to lower the limit. - requests.rateLimit(0) - await new Promise(resolve => setTimeout(resolve, this.#previousRetryDelay)) - } - } else if (status === 429) { - // HTTP 429 Too Many Requests => to avoid the whole download to fail, pause all requests. - if (!requests.isPaused) { - const next = retryDelayIterator.next() - if (next == null || next.done) { - return false - } - requests.rateLimit(next.value) - } - } else if (status > 400 && status < 500 && status !== 409) { - // HTTP 4xx, the server won't send anything, it's doesn't make sense to retry - return false - } else if (typeof navigator !== 'undefined' && navigator.onLine === false) { - // The navigator is offline, let's wait for it to come back online. - if (!requests.isPaused) { - requests.pause() - window.addEventListener('online', () => { - requests.resume() - }, { once: true }) - } - } else { - // Other error code means the request can be retried later. - const next = retryDelayIterator.next() - if (next == null || next.done) { - return false - } - await new Promise(resolve => setTimeout(resolve, next.value)) - } - return true - } - - async getUploadId (file, signal) { - let cachedResult - // As the cache is updated asynchronously, there could be a race condition - // where we just miss a new result so we loop here until we get nothing back, - // at which point it's out turn to create a new cache entry. - while ((cachedResult = this.#cache.get(file.data)) != null) { - try { - return await cachedResult - } catch { - // In case of failure, we want to ignore the cached error. - // At this point, either there's a new cached value, or we'll exit the loop a create a new one. - } - } - - const promise = this.#createMultipartUpload(this.#getFile(file), signal) - - const abortPromise = () => { - promise.abort(signal.reason) - this.#cache.delete(file.data) - } - signal.addEventListener('abort', abortPromise, { once: true }) - this.#cache.set(file.data, promise) - promise.then(async (result) => { - signal.removeEventListener('abort', abortPromise) - this.#setS3MultipartState(file, result) - this.#cache.set(file.data, result) - }, () => { - signal.removeEventListener('abort', abortPromise) - this.#cache.delete(file.data) - }) - - return promise - } - - async abortFileUpload (file) { - const result = this.#cache.get(file.data) - if (result == null) { - // If the createMultipartUpload request never was made, we don't - // need to send the abortMultipartUpload request. - return - } - // Remove the cache entry right away for follow-up requests do not try to - // use the soon-to-be aborted chached values. - this.#cache.delete(file.data) - this.#setS3MultipartState(file, Object.create(null)) - let awaitedResult - try { - awaitedResult = await result - } catch { - // If the cached result rejects, there's nothing to abort. - return - } - await this.#abortMultipartUpload(this.#getFile(file), awaitedResult) - } - - async #nonMultipartUpload (file, chunk, signal) { - const { - method = 'POST', - url, - fields, - headers, - } = await this.#getUploadParameters(this.#getFile(file), { signal }).abortOn(signal) - - let body - const data = chunk.getData() - if (method.toUpperCase() === 'POST') { - const formData = new FormData() - Object.entries(fields).forEach(([key, value]) => formData.set(key, value)) - formData.set('file', data) - body = formData - } else { - body = data - } - - const { onProgress, onComplete } = chunk - - const result = await this.#uploadPartBytes({ - signature: { url, headers, method }, - body, - size: data.size, - onProgress, - onComplete, - signal, - }).abortOn(signal) - - return 'location' in result ? result : { - location: removeMetadataFromURL(url), - ...result, - } - } - - /** - * @param {import("@uppy/core").UppyFile} file - * @param {import("../types/chunk").Chunk[]} chunks - * @param {AbortSignal} signal - * @returns {Promise} - */ - async uploadFile (file, chunks, signal) { - throwIfAborted(signal) - if (chunks.length === 1 && !chunks[0].shouldUseMultipart) { - return this.#nonMultipartUpload(file, chunks[0], signal) - } - const { uploadId, key } = await this.getUploadId(file, signal) - throwIfAborted(signal) - try { - const parts = await Promise.all(chunks.map((chunk, i) => this.uploadChunk(file, i + 1, chunk, signal))) - throwIfAborted(signal) - return await this.#sendCompletionRequest( - this.#getFile(file), - { key, uploadId, parts, signal }, - signal, - ).abortOn(signal) - } catch (err) { - if (err?.cause !== pausingUploadReason && err?.name !== 'AbortError') { - // We purposefully don't wait for the promise and ignore its status, - // because we want the error `err` to bubble up ASAP to report it to the - // user. A failure to abort is not that big of a deal anyway. - this.abortFileUpload(file) - } - throw err - } - } - - restoreUploadFile (file, uploadIdAndKey) { - this.#cache.set(file.data, uploadIdAndKey) - } - - async resumeUploadFile (file, chunks, signal) { - throwIfAborted(signal) - if (chunks.length === 1 && chunks[0] != null && !chunks[0].shouldUseMultipart) { - return this.#nonMultipartUpload(file, chunks[0], signal) - } - const { uploadId, key } = await this.getUploadId(file, signal) - throwIfAborted(signal) - const alreadyUploadedParts = await this.#listParts( - this.#getFile(file), - { uploadId, key, signal }, - signal, - ).abortOn(signal) - throwIfAborted(signal) - const parts = await Promise.all( - chunks - .map((chunk, i) => { - const partNumber = i + 1 - const alreadyUploadedInfo = alreadyUploadedParts.find(({ PartNumber }) => PartNumber === partNumber) - if (alreadyUploadedInfo == null) { - return this.uploadChunk(file, partNumber, chunk, signal) - } - // Already uploaded chunks are set to null. If we are restoring the upload, we need to mark it as already uploaded. - chunk?.setAsUploaded?.() - return { PartNumber: partNumber, ETag: alreadyUploadedInfo.ETag } - }), - ) - throwIfAborted(signal) - return this.#sendCompletionRequest( - this.#getFile(file), - { key, uploadId, parts, signal }, - signal, - ).abortOn(signal) - } - - /** - * - * @param {import("@uppy/core").UppyFile} file - * @param {number} partNumber - * @param {import("../types/chunk").Chunk} chunk - * @param {AbortSignal} signal - * @returns {Promise} - */ - async uploadChunk (file, partNumber, chunk, signal) { - throwIfAborted(signal) - const { uploadId, key } = await this.getUploadId(file, signal) - - const signatureRetryIterator = this.#retryDelays.values() - const chunkRetryIterator = this.#retryDelays.values() - const shouldRetrySignature = () => { - const next = signatureRetryIterator.next() - if (next == null || next.done) { - return null - } - return next.value - } - - for (;;) { - throwIfAborted(signal) - const chunkData = chunk.getData() - const { onProgress, onComplete } = chunk - let signature - - try { - signature = await this.#fetchSignature(this.#getFile(file), { - uploadId, key, partNumber, body: chunkData, signal, - }).abortOn(signal) - } catch (err) { - const timeout = shouldRetrySignature() - if (timeout == null || signal.aborted) { - throw err - } - await new Promise(resolve => setTimeout(resolve, timeout)) - // eslint-disable-next-line no-continue - continue - } - - throwIfAborted(signal) - try { - return { - PartNumber: partNumber, - ...await this.#uploadPartBytes({ - signature, body: chunkData, size: chunkData.size, onProgress, onComplete, signal, - }).abortOn(signal), - } - } catch (err) { - if (!await this.#shouldRetry(err, chunkRetryIterator)) throw err - } - } - } -} - -export default class AwsS3Multipart extends BasePlugin { - static VERSION = packageJson.version - - #companionCommunicationQueue - - #client - - constructor (uppy, opts) { - super(uppy, opts) - this.type = 'uploader' - this.id = this.opts.id || 'AwsS3Multipart' - this.title = 'AWS S3 Multipart' - this.#client = new RequestClient(uppy, opts) - - const defaultOptions = { - // TODO: null here means “include all”, [] means include none. - // This is inconsistent with @uppy/aws-s3 and @uppy/transloadit - allowedMetaFields: null, - limit: 6, - shouldUseMultipart: (file) => file.size !== 0, // TODO: Switch default to: - // eslint-disable-next-line no-bitwise - // shouldUseMultipart: (file) => file.size >> 10 >> 10 > 100, - retryDelays: [0, 1000, 3000, 5000], - createMultipartUpload: this.createMultipartUpload.bind(this), - listParts: this.listParts.bind(this), - abortMultipartUpload: this.abortMultipartUpload.bind(this), - completeMultipartUpload: this.completeMultipartUpload.bind(this), - getTemporarySecurityCredentials: false, - signPart: opts?.getTemporarySecurityCredentials ? this.createSignedURL.bind(this) : this.signPart.bind(this), - uploadPartBytes: AwsS3Multipart.uploadPartBytes, - getUploadParameters: opts?.getTemporarySecurityCredentials - ? this.createSignedURL.bind(this) - : this.getUploadParameters.bind(this), - companionHeaders: {}, - } - - this.opts = { ...defaultOptions, ...opts } - if (opts?.prepareUploadParts != null && opts.signPart == null) { - this.opts.signPart = async (file, { uploadId, key, partNumber, body, signal }) => { - const { presignedUrls, headers } = await opts - .prepareUploadParts(file, { uploadId, key, parts: [{ number: partNumber, chunk: body }], signal }) - return { url: presignedUrls?.[partNumber], headers: headers?.[partNumber] } - } - } - - /** - * Simultaneous upload limiting is shared across all uploads with this plugin. - * - * @type {RateLimitedQueue} - */ - this.requests = this.opts.rateLimitedQueue ?? new RateLimitedQueue(this.opts.limit) - this.#companionCommunicationQueue = new HTTPCommunicationQueue( - this.requests, - this.opts, - this.#setS3MultipartState, - this.#getFile, - ) - - this.uploaders = Object.create(null) - this.uploaderEvents = Object.create(null) - this.uploaderSockets = Object.create(null) - } - - [Symbol.for('uppy test: getClient')] () { return this.#client } - - setOptions (newOptions) { - this.#companionCommunicationQueue.setOptions(newOptions) - super.setOptions(newOptions) - this.#setCompanionHeaders() - } - - /** - * Clean up all references for a file's upload: the MultipartUploader instance, - * any events related to the file, and the Companion WebSocket connection. - * - * Set `opts.abort` to tell S3 that the multipart upload is cancelled and must be removed. - * This should be done when the user cancels the upload, not when the upload is completed or errored. - */ - resetUploaderReferences (fileID, opts = {}) { - if (this.uploaders[fileID]) { - this.uploaders[fileID].abort({ really: opts.abort || false }) - this.uploaders[fileID] = null - } - if (this.uploaderEvents[fileID]) { - this.uploaderEvents[fileID].remove() - this.uploaderEvents[fileID] = null - } - if (this.uploaderSockets[fileID]) { - this.uploaderSockets[fileID].close() - this.uploaderSockets[fileID] = null - } - } - - // TODO: make this a private method in the next major - assertHost (method) { - if (!this.opts.companionUrl) { - throw new Error(`Expected a \`companionUrl\` option containing a Companion address, or if you are not using Companion, a custom \`${method}\` implementation.`) - } - } - - createMultipartUpload (file, signal) { - this.assertHost('createMultipartUpload') - throwIfAborted(signal) - - const metadata = getAllowedMetadata({ meta: file.meta, allowedMetaFields: this.opts.allowedMetaFields }) - - return this.#client.post('s3/multipart', { - filename: file.name, - type: file.type, - metadata, - }, { signal }).then(assertServerError) - } - - listParts (file, { key, uploadId }, signal) { - this.assertHost('listParts') - throwIfAborted(signal) - - const filename = encodeURIComponent(key) - return this.#client.get(`s3/multipart/${uploadId}?key=${filename}`, { signal }) - .then(assertServerError) - } - - completeMultipartUpload (file, { key, uploadId, parts }, signal) { - this.assertHost('completeMultipartUpload') - throwIfAborted(signal) - - const filename = encodeURIComponent(key) - const uploadIdEnc = encodeURIComponent(uploadId) - return this.#client.post(`s3/multipart/${uploadIdEnc}/complete?key=${filename}`, { parts }, { signal }) - .then(assertServerError) - } - - /** - * @type {import("../types").AwsS3STSResponse | Promise} - */ - #cachedTemporaryCredentials - - async #getTemporarySecurityCredentials (options) { - throwIfAborted(options?.signal) - - if (this.#cachedTemporaryCredentials == null) { - // We do not await it just yet, so concurrent calls do not try to override it: - if (this.opts.getTemporarySecurityCredentials === true) { - this.assertHost('getTemporarySecurityCredentials') - this.#cachedTemporaryCredentials = this.#client.get('s3/sts', null, options).then(assertServerError) - } else { - this.#cachedTemporaryCredentials = this.opts.getTemporarySecurityCredentials(options) - } - this.#cachedTemporaryCredentials = await this.#cachedTemporaryCredentials - setTimeout(() => { - // At half the time left before expiration, we clear the cache. That's - // an arbitrary tradeoff to limit the number of requests made to the - // remote while limiting the risk of using an expired token in case the - // clocks are not exactly synced. - // The HTTP cache should be configured to ensure a client doesn't request - // more tokens than it needs, but this timeout provides a second layer of - // security in case the HTTP cache is disabled or misconfigured. - this.#cachedTemporaryCredentials = null - }, (getExpiry(this.#cachedTemporaryCredentials.credentials) || 0) * 500) - } - - return this.#cachedTemporaryCredentials - } - - async createSignedURL (file, options) { - const data = await this.#getTemporarySecurityCredentials(options) - const expires = getExpiry(data.credentials) || 604_800 // 604 800 is the max value accepted by AWS. - - const { uploadId, key, partNumber, signal } = options - - // Return an object in the correct shape. - return { - method: 'PUT', - expires, - fields: {}, - url: `${await createSignedURL({ - accountKey: data.credentials.AccessKeyId, - accountSecret: data.credentials.SecretAccessKey, - sessionToken: data.credentials.SessionToken, - expires, - bucketName: data.bucket, - Region: data.region, - Key: key ?? `${crypto.randomUUID()}-${file.name}`, - uploadId, - partNumber, - signal, - })}`, - // Provide content type header required by S3 - headers: { - 'Content-Type': file.type, - }, - } - } - - signPart (file, { uploadId, key, partNumber, signal }) { - this.assertHost('signPart') - throwIfAborted(signal) - - if (uploadId == null || key == null || partNumber == null) { - throw new Error('Cannot sign without a key, an uploadId, and a partNumber') - } - - const filename = encodeURIComponent(key) - return this.#client.get(`s3/multipart/${uploadId}/${partNumber}?key=${filename}`, { signal }) - .then(assertServerError) - } - - abortMultipartUpload (file, { key, uploadId }, signal) { - this.assertHost('abortMultipartUpload') - - const filename = encodeURIComponent(key) - const uploadIdEnc = encodeURIComponent(uploadId) - return this.#client.delete(`s3/multipart/${uploadIdEnc}?key=${filename}`, undefined, { signal }) - .then(assertServerError) - } - - getUploadParameters (file, options) { - const { meta } = file - const { type, name: filename } = meta - const metadata = getAllowedMetadata({ meta, allowedMetaFields: this.opts.allowedMetaFields, querify: true }) - - const query = new URLSearchParams({ filename, type, ...metadata }) - - return this.#client.get(`s3/params?${query}`, options) - } - - static async uploadPartBytes ({ signature: { url, expires, headers, method = 'PUT' }, body, size = body.size, onProgress, onComplete, signal }) { - throwIfAborted(signal) - - if (url == null) { - throw new Error('Cannot upload to an undefined URL') - } - - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest() - xhr.open(method, url, true) - if (headers) { - Object.keys(headers).forEach((key) => { - xhr.setRequestHeader(key, headers[key]) - }) - } - xhr.responseType = 'text' - if (typeof expires === 'number') { - xhr.timeout = expires * 1000 - } - - function onabort () { - xhr.abort() - } - function cleanup () { - signal.removeEventListener('abort', onabort) - } - signal.addEventListener('abort', onabort) - - xhr.upload.addEventListener('progress', (ev) => { - onProgress(ev) - }) - - xhr.addEventListener('abort', () => { - cleanup() - - reject(createAbortError()) - }) - - xhr.addEventListener('timeout', () => { - cleanup() - - const error = new Error('Request has expired') - error.source = { status: 403 } - reject(error) - }) - xhr.addEventListener('load', (ev) => { - cleanup() - - if (ev.target.status === 403 && ev.target.responseText.includes('Request has expired')) { - const error = new Error('Request has expired') - error.source = ev.target - reject(error) - return - } if (ev.target.status < 200 || ev.target.status >= 300) { - const error = new Error('Non 2xx') - error.source = ev.target - reject(error) - return - } - - // todo make a proper onProgress API (breaking change) - onProgress?.({ loaded: size, lengthComputable: true }) - - // NOTE This must be allowed by CORS. - const etag = ev.target.getResponseHeader('ETag') - const location = ev.target.getResponseHeader('Location') - - if (method.toUpperCase() === 'POST' && location === null) { - // Not being able to read the Location header is not a fatal error. - // eslint-disable-next-line no-console - console.warn('AwsS3/Multipart: Could not read the Location header. This likely means CORS is not configured correctly on the S3 Bucket. See https://uppy.io/docs/aws-s3-multipart#S3-Bucket-Configuration for instructions.') - } - if (etag === null) { - reject(new Error('AwsS3/Multipart: Could not read the ETag header. This likely means CORS is not configured correctly on the S3 Bucket. See https://uppy.io/docs/aws-s3-multipart#S3-Bucket-Configuration for instructions.')) - return - } - - onComplete?.(etag) - resolve({ - ETag: etag, - ...(location ? { location } : undefined), - }) - }) - - xhr.addEventListener('error', (ev) => { - cleanup() - - const error = new Error('Unknown error') - error.source = ev.target - reject(error) - }) - - xhr.send(body) - }) - } - - #setS3MultipartState = (file, { key, uploadId }) => { - const cFile = this.uppy.getFile(file.id) - if (cFile == null) { - // file was removed from store - return - } - - this.uppy.setFileState(file.id, { - s3Multipart: { - ...cFile.s3Multipart, - key, - uploadId, - }, - }) - } - - #getFile = (file) => { - return this.uppy.getFile(file.id) || file - } - - #uploadLocalFile (file) { - return new Promise((resolve, reject) => { - const onProgress = (bytesUploaded, bytesTotal) => { - this.uppy.emit('upload-progress', this.uppy.getFile(file.id), { - uploader: this, - bytesUploaded, - bytesTotal, - }) - } - - const onError = (err) => { - this.uppy.log(err) - this.uppy.emit('upload-error', file, err) - - this.resetUploaderReferences(file.id) - reject(err) - } - - const onSuccess = (result) => { - const uploadResp = { - body: { - ...result, - }, - uploadURL: result.location, - } - - this.resetUploaderReferences(file.id) - - this.uppy.emit('upload-success', this.#getFile(file), uploadResp) - - if (result.location) { - this.uppy.log(`Download ${file.name} from ${result.location}`) - } - - resolve() - } - - const onPartComplete = (part) => { - this.uppy.emit('s3-multipart:part-uploaded', this.#getFile(file), part) - } - - const upload = new MultipartUploader(file.data, { - // .bind to pass the file object to each handler. - companionComm: this.#companionCommunicationQueue, - - log: (...args) => this.uppy.log(...args), - getChunkSize: this.opts.getChunkSize ? this.opts.getChunkSize.bind(this) : null, - - onProgress, - onError, - onSuccess, - onPartComplete, - - file, - shouldUseMultipart: this.opts.shouldUseMultipart, - - ...file.s3Multipart, - }) - - this.uploaders[file.id] = upload - const eventManager = new EventManager(this.uppy) - this.uploaderEvents[file.id] = eventManager - - eventManager.onFileRemove(file.id, (removed) => { - upload.abort() - this.resetUploaderReferences(file.id, { abort: true }) - resolve(`upload ${removed.id} was removed`) - }) - - eventManager.onCancelAll(file.id, ({ reason } = {}) => { - if (reason === 'user') { - upload.abort() - this.resetUploaderReferences(file.id, { abort: true }) - } - resolve(`upload ${file.id} was canceled`) - }) - - eventManager.onFilePause(file.id, (isPaused) => { - if (isPaused) { - upload.pause() - } else { - upload.start() - } - }) - - eventManager.onPauseAll(file.id, () => { - upload.pause() - }) - - eventManager.onResumeAll(file.id, () => { - upload.start() - }) - - upload.start() - }) - } - - // eslint-disable-next-line class-methods-use-this - #getCompanionClientArgs (file) { - return { - ...file.remote.body, - protocol: 's3-multipart', - size: file.data.size, - metadata: file.meta, - } - } - - #upload = async (fileIDs) => { - if (fileIDs.length === 0) return undefined - - const files = this.uppy.getFilesByIds(fileIDs) - const filesFiltered = filterNonFailedFiles(files) - const filesToEmit = filterFilesToEmitUploadStarted(filesFiltered) - - this.uppy.emit('upload-start', filesToEmit) - - const promises = filesFiltered.map((file) => { - if (file.isRemote) { - const getQueue = () => this.requests - this.#setResumableUploadsCapability(false) - const controller = new AbortController() - - const removedHandler = (removedFile) => { - if (removedFile.id === file.id) controller.abort() - } - this.uppy.on('file-removed', removedHandler) - - const uploadPromise = this.uppy.getRequestClientForFile(file).uploadRemoteFile( - file, - this.#getCompanionClientArgs(file), - { signal: controller.signal, getQueue }, - ) - - this.requests.wrapSyncFunction(() => { - this.uppy.off('file-removed', removedHandler) - }, { priority: -1 })() - - return uploadPromise - } - - return this.#uploadLocalFile(file) - }) - - const upload = await Promise.all(promises) - // After the upload is done, another upload may happen with only local files. - // We reset the capability so that the next upload can use resumable uploads. - this.#setResumableUploadsCapability(true) - return upload - } - - #setCompanionHeaders = () => { - this.#client.setCompanionHeaders(this.opts.companionHeaders) - } - - #setResumableUploadsCapability = (boolean) => { - const { capabilities } = this.uppy.getState() - this.uppy.setState({ - capabilities: { - ...capabilities, - resumableUploads: boolean, - }, - }) - } - - #resetResumableCapability = () => { - this.#setResumableUploadsCapability(true) - } - - install () { - this.#setResumableUploadsCapability(true) - this.uppy.addPreProcessor(this.#setCompanionHeaders) - this.uppy.addUploader(this.#upload) - this.uppy.on('cancel-all', this.#resetResumableCapability) - } - - uninstall () { - this.uppy.removePreProcessor(this.#setCompanionHeaders) - this.uppy.removeUploader(this.#upload) - this.uppy.off('cancel-all', this.#resetResumableCapability) - } -} diff --git a/packages/@uppy/aws-s3-multipart/src/index.test.js b/packages/@uppy/aws-s3-multipart/src/index.test.ts similarity index 69% rename from packages/@uppy/aws-s3-multipart/src/index.test.js rename to packages/@uppy/aws-s3-multipart/src/index.test.ts index 33c94eedd6..97f1b45904 100644 --- a/packages/@uppy/aws-s3-multipart/src/index.test.js +++ b/packages/@uppy/aws-s3-multipart/src/index.test.ts @@ -3,7 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import 'whatwg-fetch' import nock from 'nock' import Core from '@uppy/core' -import AwsS3Multipart from './index.js' +import AwsS3Multipart from './index.ts' +import type { Body } from './utils.ts' const KB = 1024 const MB = KB * KB @@ -12,36 +13,41 @@ describe('AwsS3Multipart', () => { beforeEach(() => nock.disableNetConnect()) it('Registers AwsS3Multipart upload plugin', () => { - const core = new Core() + const core = new Core() core.use(AwsS3Multipart) - const pluginNames = core[Symbol.for('uppy test: getPlugins')]('uploader').map((plugin) => plugin.constructor.name) + // @ts-expect-error private property + const pluginNames = core[Symbol.for('uppy test: getPlugins')]( + 'uploader', + ).map((plugin: AwsS3Multipart) => plugin.constructor.name) expect(pluginNames).toContain('AwsS3Multipart') }) describe('companionUrl assertion', () => { it('Throws an error for main functions if configured without companionUrl', () => { - const core = new Core() + const core = new Core() core.use(AwsS3Multipart) - const awsS3Multipart = core.getPlugin('AwsS3Multipart') + const awsS3Multipart = core.getPlugin('AwsS3Multipart')! const err = 'Expected a `companionUrl` option' const file = {} const opts = {} - expect(() => awsS3Multipart.opts.createMultipartUpload(file)).toThrow( - err, - ) + expect(() => awsS3Multipart.opts.createMultipartUpload(file)).toThrow(err) expect(() => awsS3Multipart.opts.listParts(file, opts)).toThrow(err) - expect(() => awsS3Multipart.opts.completeMultipartUpload(file, opts)).toThrow(err) - expect(() => awsS3Multipart.opts.abortMultipartUpload(file, opts)).toThrow(err) + expect(() => + awsS3Multipart.opts.completeMultipartUpload(file, opts), + ).toThrow(err) + expect(() => + awsS3Multipart.opts.abortMultipartUpload(file, opts), + ).toThrow(err) expect(() => awsS3Multipart.opts.signPart(file, opts)).toThrow(err) }) }) describe('non-multipart upload', () => { it('should handle POST uploads', async () => { - const core = new Core() + const core = new Core() core.use(AwsS3Multipart, { shouldUseMultipart: false, limit: 0, @@ -71,7 +77,7 @@ describe('AwsS3Multipart', () => { source: 'vi', name: 'multitest.dat', type: 'application/octet-stream', - data: new File([new Uint8Array(fileSize)], { + data: new File([new Uint8Array(fileSize)], '', { type: 'application/octet-stream', }), }) @@ -87,6 +93,7 @@ describe('AwsS3Multipart', () => { ETag: 'test', location: 'http://example.com', }, + status: 200, uploadURL: 'http://example.com', }) @@ -95,11 +102,11 @@ describe('AwsS3Multipart', () => { }) describe('without companionUrl (custom main functions)', () => { - let core - let awsS3Multipart + let core: Core + let awsS3Multipart: AwsS3Multipart beforeEach(() => { - core = new Core() + core = new Core() core.use(AwsS3Multipart, { limit: 0, createMultipartUpload: vi.fn(() => { @@ -110,17 +117,19 @@ describe('AwsS3Multipart', () => { }), completeMultipartUpload: vi.fn(async () => ({ location: 'test' })), abortMultipartUpload: vi.fn(), - prepareUploadParts: vi.fn(async (file, { parts }) => { - const presignedUrls = {} - parts.forEach(({ number }) => { - presignedUrls[ - number - ] = `https://bucket.s3.us-east-2.amazonaws.com/test/upload/multitest.dat?partNumber=${number}&uploadId=6aeb1980f3fc7ce0b5454d25b71992&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIATEST%2F20210729%2Fus-east-2%2Fs3%2Faws4_request&X-Amz-Date=20210729T014044Z&X-Amz-Expires=600&X-Amz-SignedHeaders=host&X-Amz-Signature=test` - }) - return { presignedUrls, headers: { 1: { 'Content-MD5': 'foo' } } } - }), + prepareUploadParts: vi.fn( + async (file, { parts }: { parts: { number: number }[] }) => { + const presignedUrls: Record = {} + parts.forEach(({ number }) => { + presignedUrls[number] = + `https://bucket.s3.us-east-2.amazonaws.com/test/upload/multitest.dat?partNumber=${number}&uploadId=6aeb1980f3fc7ce0b5454d25b71992&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIATEST%2F20210729%2Fus-east-2%2Fs3%2Faws4_request&X-Amz-Date=20210729T014044Z&X-Amz-Expires=600&X-Amz-SignedHeaders=host&X-Amz-Signature=test` + }) + return { presignedUrls, headers: { 1: { 'Content-MD5': 'foo' } } } + }, + ), + listParts: undefined as any, }) - awsS3Multipart = core.getPlugin('AwsS3Multipart') + awsS3Multipart = core.getPlugin('AwsS3Multipart') as any }) it('Calls the prepareUploadParts function totalChunks / limit times', async () => { @@ -137,15 +146,23 @@ describe('AwsS3Multipart', () => { const fileSize = 5 * MB + 1 * MB scope - .options((uri) => uri.includes('test/upload/multitest.dat?partNumber=1')) - .reply(function replyFn () { - expect(this.req.headers['access-control-request-headers']).toEqual('Content-MD5') + .options((uri) => + uri.includes('test/upload/multitest.dat?partNumber=1'), + ) + .reply(function replyFn() { + expect(this.req.headers['access-control-request-headers']).toEqual( + 'Content-MD5', + ) return [200, ''] }) scope - .options((uri) => uri.includes('test/upload/multitest.dat?partNumber=2')) - .reply(function replyFn () { - expect(this.req.headers['access-control-request-headers']).toBeUndefined() + .options((uri) => + uri.includes('test/upload/multitest.dat?partNumber=2'), + ) + .reply(function replyFn() { + expect( + this.req.headers['access-control-request-headers'], + ).toBeUndefined() return [200, ''] }) scope @@ -159,7 +176,7 @@ describe('AwsS3Multipart', () => { source: 'vi', name: 'multitest.dat', type: 'application/octet-stream', - data: new File([new Uint8Array(fileSize)], { + data: new File([new Uint8Array(fileSize)], '', { type: 'application/octet-stream', }), }) @@ -167,7 +184,7 @@ describe('AwsS3Multipart', () => { await core.upload() expect( - awsS3Multipart.opts.prepareUploadParts.mock.calls.length, + (awsS3Multipart.opts as any).prepareUploadParts.mock.calls.length, ).toEqual(2) scope.done() @@ -201,14 +218,17 @@ describe('AwsS3Multipart', () => { source: 'vi', name: 'multitest.dat', type: 'application/octet-stream', - data: new File([new Uint8Array(fileSize)], { + data: new File([new Uint8Array(fileSize)], '', { type: 'application/octet-stream', }), }) await core.upload() - function validatePartData ({ parts }, expected) { + function validatePartData( + { parts }: { parts: { number: number; chunk: unknown }[] }, + expected: number[], + ) { expect(parts.map((part) => part.number)).toEqual(expected) for (const part of parts) { @@ -216,13 +236,25 @@ describe('AwsS3Multipart', () => { } } - expect(awsS3Multipart.opts.prepareUploadParts.mock.calls.length).toEqual(10) + expect( + (awsS3Multipart.opts as any).prepareUploadParts.mock.calls.length, + ).toEqual(10) - validatePartData(awsS3Multipart.opts.prepareUploadParts.mock.calls[0][1], [1]) - validatePartData(awsS3Multipart.opts.prepareUploadParts.mock.calls[1][1], [2]) - validatePartData(awsS3Multipart.opts.prepareUploadParts.mock.calls[2][1], [3]) + validatePartData( + (awsS3Multipart.opts as any).prepareUploadParts.mock.calls[0][1], + [1], + ) + validatePartData( + (awsS3Multipart.opts as any).prepareUploadParts.mock.calls[1][1], + [2], + ) + validatePartData( + (awsS3Multipart.opts as any).prepareUploadParts.mock.calls[2][1], + [3], + ) - const completeCall = awsS3Multipart.opts.completeMultipartUpload.mock.calls[0][1] + const completeCall = (awsS3Multipart.opts as any).completeMultipartUpload + .mock.calls[0][1] expect(completeCall.parts).toEqual([ { ETag: 'test', PartNumber: 1 }, @@ -254,13 +286,21 @@ describe('AwsS3Multipart', () => { .options((uri) => uri.includes('test/upload/multitest.dat')) .reply(200, '') scope - .put((uri) => uri.includes('test/upload/multitest.dat') && !uri.includes('partNumber=7')) + .put( + (uri) => + uri.includes('test/upload/multitest.dat') && + !uri.includes('partNumber=7'), + ) .reply(200, '', { ETag: 'test' }) // Fail the part 7 upload once, then let it succeed let calls = 0 scope - .put((uri) => uri.includes('test/upload/multitest.dat') && uri.includes('partNumber=7')) + .put( + (uri) => + uri.includes('test/upload/multitest.dat') && + uri.includes('partNumber=7'), + ) .reply(() => (calls++ === 0 ? [500] : [200, '', { ETag: 'test' }])) scope.persist() @@ -271,14 +311,25 @@ describe('AwsS3Multipart', () => { awsS3Multipart.setOptions({ retryDelays: [10], createMultipartUpload: vi.fn((file) => { - const multipartUploader = awsS3Multipart.uploaders[file.id] + // @ts-expect-error protected property + const multipartUploader = awsS3Multipart.uploaders[file.id]! const testChunkState = multipartUploader.chunkState[6] let busy = false let done = false - busySpy = vi.fn((value) => { busy = value }) - doneSpy = vi.fn((value) => { done = value }) - Object.defineProperty(testChunkState, 'busy', { get: () => busy, set: busySpy }) - Object.defineProperty(testChunkState, 'done', { get: () => done, set: doneSpy }) + busySpy = vi.fn((value) => { + busy = value + }) + doneSpy = vi.fn((value) => { + done = value + }) + Object.defineProperty(testChunkState, 'busy', { + get: () => busy, + set: busySpy, + }) + Object.defineProperty(testChunkState, 'done', { + get: () => done, + set: doneSpy, + }) return { uploadId: '6aeb1980f3fc7ce0b5454d25b71992', @@ -291,7 +342,7 @@ describe('AwsS3Multipart', () => { source: 'vi', name: 'multitest.dat', type: 'application/octet-stream', - data: new File([new Uint8Array(fileSize)], { + data: new File([new Uint8Array(fileSize)], '', { type: 'application/octet-stream', }), }) @@ -299,19 +350,23 @@ describe('AwsS3Multipart', () => { await core.upload() // The chunk should be marked as done once - expect(doneSpy.mock.calls.length).toEqual(1) - expect(doneSpy.mock.calls[0][0]).toEqual(true) + expect(doneSpy!.mock.calls.length).toEqual(1) + expect(doneSpy!.mock.calls[0][0]).toEqual(true) // Any changes that set busy to false should only happen after the chunk has been marked done, // otherwise a race condition occurs (see PR #3955) - const doneCallOrderNumber = doneSpy.mock.invocationCallOrder[0] - for (const [index, callArgs] of busySpy.mock.calls.entries()) { + const doneCallOrderNumber = doneSpy!.mock.invocationCallOrder[0] + for (const [index, callArgs] of busySpy!.mock.calls.entries()) { if (callArgs[0] === false) { - expect(busySpy.mock.invocationCallOrder[index]).toBeGreaterThan(doneCallOrderNumber) + expect(busySpy!.mock.invocationCallOrder[index]).toBeGreaterThan( + doneCallOrderNumber, + ) } } - expect(awsS3Multipart.opts.prepareUploadParts.mock.calls.length).toEqual(10) + expect( + (awsS3Multipart.opts as any).prepareUploadParts.mock.calls.length, + ).toEqual(10) }) }) @@ -323,36 +378,41 @@ describe('AwsS3Multipart', () => { } }) - const signPart = vi - .fn(async (file, { partNumber }) => { - return { url: `https://bucket.s3.us-east-2.amazonaws.com/test/upload/multitest.dat?partNumber=${partNumber}&uploadId=6aeb1980f3fc7ce0b5454d25b71992&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIATEST%2F20210729%2Fus-east-2%2Fs3%2Faws4_request&X-Amz-Date=20210729T014044Z&X-Amz-Expires=600&X-Amz-SignedHeaders=host&X-Amz-Signature=test` } - }) + const signPart = vi.fn(async (file, { partNumber }) => { + return { + url: `https://bucket.s3.us-east-2.amazonaws.com/test/upload/multitest.dat?partNumber=${partNumber}&uploadId=6aeb1980f3fc7ce0b5454d25b71992&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIATEST%2F20210729%2Fus-east-2%2Fs3%2Faws4_request&X-Amz-Date=20210729T014044Z&X-Amz-Expires=600&X-Amz-SignedHeaders=host&X-Amz-Signature=test`, + } + }) const uploadPartBytes = vi.fn() - afterEach(() => vi.clearAllMocks()) + afterEach(() => { + vi.clearAllMocks() + }) it('retries uploadPartBytes when it fails once', async () => { - const core = new Core() - .use(AwsS3Multipart, { - createMultipartUpload, - completeMultipartUpload: vi.fn(async () => ({ location: 'test' })), + const core = new Core().use(AwsS3Multipart, { + createMultipartUpload, + completeMultipartUpload: vi.fn(async () => ({ location: 'test' })), + abortMultipartUpload: vi.fn(() => { // eslint-disable-next-line no-throw-literal - abortMultipartUpload: vi.fn(() => { throw 'should ignore' }), - signPart, - uploadPartBytes: - uploadPartBytes - // eslint-disable-next-line prefer-promise-reject-errors - .mockImplementationOnce(() => Promise.reject({ source: { status: 500 } })), - }) - const awsS3Multipart = core.getPlugin('AwsS3Multipart') + throw 'should ignore' + }), + signPart, + uploadPartBytes: uploadPartBytes.mockImplementationOnce(() => + // eslint-disable-next-line prefer-promise-reject-errors + Promise.reject({ source: { status: 500 } }), + ), + listParts: undefined as any, + }) + const awsS3Multipart = core.getPlugin('AwsS3Multipart')! const fileSize = 5 * MB + 1 * MB core.addFile({ source: 'vi', name: 'multitest.dat', type: 'application/octet-stream', - data: new File([new Uint8Array(fileSize)], { + data: new File([new Uint8Array(fileSize)], '', { type: 'application/octet-stream', }), }) @@ -363,18 +423,19 @@ describe('AwsS3Multipart', () => { }) it('calls `upload-error` when uploadPartBytes fails after all retries', async () => { - const core = new Core() - .use(AwsS3Multipart, { - retryDelays: [10], - createMultipartUpload, - completeMultipartUpload: vi.fn(async () => ({ location: 'test' })), - abortMultipartUpload: vi.fn(), - signPart, - uploadPartBytes: uploadPartBytes - // eslint-disable-next-line prefer-promise-reject-errors - .mockImplementation(() => Promise.reject({ source: { status: 500 } })), - }) - const awsS3Multipart = core.getPlugin('AwsS3Multipart') + const core = new Core().use(AwsS3Multipart, { + retryDelays: [10], + createMultipartUpload, + completeMultipartUpload: vi.fn(async () => ({ location: 'test' })), + abortMultipartUpload: vi.fn(), + signPart, + uploadPartBytes: uploadPartBytes.mockImplementation(() => + // eslint-disable-next-line prefer-promise-reject-errors + Promise.reject({ source: { status: 500 } }), + ), + listParts: undefined as any, + }) + const awsS3Multipart = core.getPlugin('AwsS3Multipart')! const fileSize = 5 * MB + 1 * MB const mock = vi.fn() core.on('upload-error', mock) @@ -383,7 +444,7 @@ describe('AwsS3Multipart', () => { source: 'vi', name: 'multitest.dat', type: 'application/octet-stream', - data: new File([new Uint8Array(fileSize)], { + data: new File([new Uint8Array(fileSize)], '', { type: 'application/octet-stream', }), }) @@ -396,19 +457,20 @@ describe('AwsS3Multipart', () => { }) describe('dynamic companionHeader', () => { - let core - let awsS3Multipart + let core: Core + let awsS3Multipart: AwsS3Multipart const oldToken = 'old token' const newToken = 'new token' beforeEach(() => { - core = new Core() + core = new Core() core.use(AwsS3Multipart, { + companionUrl: '', companionHeaders: { authorization: oldToken, }, }) - awsS3Multipart = core.getPlugin('AwsS3Multipart') + awsS3Multipart = core.getPlugin('AwsS3Multipart') as any }) it('companionHeader is updated before uploading file', async () => { @@ -420,23 +482,29 @@ describe('AwsS3Multipart', () => { await core.upload() + // @ts-expect-error private property const client = awsS3Multipart[Symbol.for('uppy test: getClient')]() - expect(client[Symbol.for('uppy test: getCompanionHeaders')]().authorization).toEqual(newToken) + expect( + client[Symbol.for('uppy test: getCompanionHeaders')]().authorization, + ).toEqual(newToken) }) }) describe('dynamic companionHeader using setOption', () => { - let core - let awsS3Multipart + let core: Core + let awsS3Multipart: AwsS3Multipart const newToken = 'new token' it('companionHeader is updated before uploading file', async () => { - core = new Core() + core = new Core() core.use(AwsS3Multipart) /* Set up preprocessor */ core.addPreProcessor(() => { - awsS3Multipart = core.getPlugin('AwsS3Multipart') + awsS3Multipart = core.getPlugin('AwsS3Multipart') as AwsS3Multipart< + any, + Body + > awsS3Multipart.setOptions({ companionHeaders: { authorization: newToken, @@ -446,15 +514,18 @@ describe('AwsS3Multipart', () => { await core.upload() + // @ts-expect-error private property const client = awsS3Multipart[Symbol.for('uppy test: getClient')]() - expect(client[Symbol.for('uppy test: getCompanionHeaders')]().authorization).toEqual(newToken) + expect( + client[Symbol.for('uppy test: getCompanionHeaders')]().authorization, + ).toEqual(newToken) }) }) describe('file metadata across custom main functions', () => { - let core - const createMultipartUpload = vi.fn(file => { + let core: Core + const createMultipartUpload = vi.fn((file) => { core.setFileMeta(file.id, { ...file.meta, createMultipartUpload: true, @@ -487,8 +558,10 @@ describe('AwsS3Multipart', () => { listParts: true, }) - const partKeys = Object.keys(file.meta).filter(metaKey => metaKey.startsWith('part')) - return partKeys.map(metaKey => ({ + const partKeys = Object.keys(file.meta).filter((metaKey) => + metaKey.startsWith('part'), + ) + return partKeys.map((metaKey) => ({ PartNumber: file.meta[metaKey], ETag: metaKey, Size: 5 * MB, @@ -508,7 +581,6 @@ describe('AwsS3Multipart', () => { expect(file.meta.createMultipartUpload).toBe(true) expect(file.meta.signPart).toBe(true) expect(file.meta.abortingPart).toBe(5) - return {} }) beforeEach(() => { @@ -520,14 +592,13 @@ describe('AwsS3Multipart', () => { }) it('preserves file metadata if upload is completed', async () => { - core = new Core() - .use(AwsS3Multipart, { - createMultipartUpload, - signPart, - listParts, - completeMultipartUpload, - abortMultipartUpload, - }) + core = new Core().use(AwsS3Multipart, { + createMultipartUpload, + signPart, + listParts, + completeMultipartUpload, + abortMultipartUpload, + }) nock('https://bucket.s3.us-east-2.amazonaws.com') .defaultReplyHeaders({ @@ -545,7 +616,7 @@ describe('AwsS3Multipart', () => { source: 'vi', name: 'multitest.dat', type: 'application/octet-stream', - data: new File([new Uint8Array(fileSize)], { + data: new File([new Uint8Array(fileSize)], '', { type: 'application/octet-stream', }), }) @@ -565,7 +636,9 @@ describe('AwsS3Multipart', () => { abortingPart: partData.partNumber, }) core.removeFile(file.id) - return {} + return { + url: undefined as any as string, + } } core.setFileMeta(file.id, { @@ -579,14 +652,13 @@ describe('AwsS3Multipart', () => { } }) - core = new Core() - .use(AwsS3Multipart, { - createMultipartUpload, - signPart: signPartWithAbort, - listParts, - completeMultipartUpload, - abortMultipartUpload, - }) + core = new Core().use(AwsS3Multipart, { + createMultipartUpload, + signPart: signPartWithAbort, + listParts, + completeMultipartUpload, + abortMultipartUpload, + }) nock('https://bucket.s3.us-east-2.amazonaws.com') .defaultReplyHeaders({ @@ -604,7 +676,7 @@ describe('AwsS3Multipart', () => { source: 'vi', name: 'multitest.dat', type: 'application/octet-stream', - data: new File([new Uint8Array(fileSize)], { + data: new File([new Uint8Array(fileSize)], '', { type: 'application/octet-stream', }), }) @@ -649,14 +721,13 @@ describe('AwsS3Multipart', () => { } }) - core = new Core() - .use(AwsS3Multipart, { - createMultipartUpload, - signPart: signPartWithPause, - listParts, - completeMultipartUpload: completeMultipartUploadAfterPause, - abortMultipartUpload, - }) + core = new Core().use(AwsS3Multipart, { + createMultipartUpload, + signPart: signPartWithPause, + listParts, + completeMultipartUpload: completeMultipartUploadAfterPause, + abortMultipartUpload, + }) nock('https://bucket.s3.us-east-2.amazonaws.com') .defaultReplyHeaders({ @@ -674,7 +745,7 @@ describe('AwsS3Multipart', () => { source: 'vi', name: 'multitest.dat', type: 'application/octet-stream', - data: new File([new Uint8Array(fileSize)], { + data: new File([new Uint8Array(fileSize)], '', { type: 'application/octet-stream', }), }) diff --git a/packages/@uppy/aws-s3-multipart/src/index.ts b/packages/@uppy/aws-s3-multipart/src/index.ts new file mode 100644 index 0000000000..94836509c4 --- /dev/null +++ b/packages/@uppy/aws-s3-multipart/src/index.ts @@ -0,0 +1,1007 @@ +import BasePlugin, { + type DefinePluginOpts, + type PluginOpts, +} from '@uppy/core/lib/BasePlugin.js' +import { RequestClient } from '@uppy/companion-client' +import type { RequestOptions } from '@uppy/utils/lib/CompanionClientProvider.ts' +import type { Body as _Body, Meta, UppyFile } from '@uppy/utils/lib/UppyFile' +import type { Uppy } from '@uppy/core' +import EventManager from '@uppy/core/lib/EventManager.js' +import { RateLimitedQueue } from '@uppy/utils/lib/RateLimitedQueue' +import { + filterNonFailedFiles, + filterFilesToEmitUploadStarted, +} from '@uppy/utils/lib/fileFilters' +import { createAbortError } from '@uppy/utils/lib/AbortController' + +import MultipartUploader from './MultipartUploader.ts' +import { throwIfAborted } from './utils.ts' +import type { + UploadResult, + UploadResultWithSignal, + MultipartUploadResultWithSignal, + UploadPartBytesResult, + Body, +} from './utils.ts' +import createSignedURL from './createSignedURL.ts' +import { HTTPCommunicationQueue } from './HTTPCommunicationQueue.ts' +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore We don't want TS to generate types for the package.json +import packageJson from '../package.json' + +interface MultipartFile extends UppyFile { + s3Multipart: UploadResult +} + +type PartUploadedCallback = ( + file: UppyFile, + part: { PartNumber: number; ETag: string }, +) => void + +declare module '@uppy/core' { + export interface UppyEventMap { + 's3-multipart:part-uploaded': PartUploadedCallback + } +} + +function assertServerError(res: T): T { + if ((res as any)?.error) { + const error = new Error((res as any).message) + Object.assign(error, (res as any).error) + throw error + } + return res +} + +export interface AwsS3STSResponse { + credentials: { + AccessKeyId: string + SecretAccessKey: string + SessionToken: string + Expiration?: string + } + bucket: string + region: string +} + +/** + * Computes the expiry time for a request signed with temporary credentials. If + * no expiration was provided, or an invalid value (e.g. in the past) is + * provided, undefined is returned. This function assumes the client clock is in + * sync with the remote server, which is a requirement for the signature to be + * validated for AWS anyway. + */ +function getExpiry( + credentials: AwsS3STSResponse['credentials'], +): number | undefined { + const expirationDate = credentials.Expiration + if (expirationDate) { + const timeUntilExpiry = Math.floor( + ((new Date(expirationDate) as any as number) - Date.now()) / 1000, + ) + if (timeUntilExpiry > 9) { + return timeUntilExpiry + } + } + return undefined +} + +function getAllowedMetadata>({ + meta, + allowedMetaFields, + querify = false, +}: { + meta: M + allowedMetaFields?: string[] | null + querify?: boolean +}) { + const metaFields = allowedMetaFields ?? Object.keys(meta) + + if (!meta) return {} + + return Object.fromEntries( + metaFields + .filter((key) => meta[key] != null) + .map((key) => { + const realKey = querify ? `metadata[${key}]` : key + const value = String(meta[key]) + return [realKey, value] + }), + ) +} + +type MaybePromise = T | Promise + +type SignPartOptions = { + uploadId: string + key: string + partNumber: number + body: Blob + signal?: AbortSignal +} + +export type AwsS3UploadParameters = + | { + method: 'POST' + url: string + fields: Record + expires?: number + headers?: Record + } + | { + method?: 'PUT' + url: string + fields?: Record + expires?: number + headers?: Record + } + +export interface AwsS3Part { + PartNumber?: number + Size?: number + ETag?: string +} + +type AWSS3WithCompanion = { + companionUrl: string + companionHeaders?: Record + companionCookiesRule?: string + getTemporarySecurityCredentials?: true +} +type AWSS3WithoutCompanion = { + getTemporarySecurityCredentials?: (options?: { + signal?: AbortSignal + }) => MaybePromise + uploadPartBytes?: (options: { + signature: AwsS3UploadParameters + body: FormData | Blob + size?: number + onProgress: any + onComplete: any + signal?: AbortSignal + }) => Promise +} + +type AWSS3NonMultipartWithCompanionMandatory = { + // No related options +} + +type AWSS3NonMultipartWithoutCompanionMandatory< + M extends Meta, + B extends Body, +> = { + getUploadParameters: ( + file: UppyFile, + options: RequestOptions, + ) => MaybePromise +} +type AWSS3NonMultipartWithCompanion = AWSS3WithCompanion & + AWSS3NonMultipartWithCompanionMandatory & { + shouldUseMultipart: false + } + +type AWSS3NonMultipartWithoutCompanion< + M extends Meta, + B extends Body, +> = AWSS3WithoutCompanion & + AWSS3NonMultipartWithoutCompanionMandatory & { + shouldUseMultipart: false + } + +type AWSS3MultipartWithoutCompanionMandatorySignPart< + M extends Meta, + B extends Body, +> = { + signPart: ( + file: UppyFile, + opts: SignPartOptions, + ) => MaybePromise +} +/** @deprecated Use signPart instead */ +type AWSS3MultipartWithoutCompanionMandatoryPrepareUploadParts< + M extends Meta, + B extends Body, +> = { + /** @deprecated Use signPart instead */ + prepareUploadParts: ( + file: UppyFile, + partData: { + uploadId: string + key: string + parts: [{ number: number; chunk: Blob }] + signal?: AbortSignal + }, + ) => MaybePromise<{ + presignedUrls: Record + headers?: Record> + }> +} +type AWSS3MultipartWithoutCompanionMandatory = { + getChunkSize?: (file: UppyFile) => number + createMultipartUpload: (file: UppyFile) => MaybePromise + listParts: ( + file: UppyFile, + opts: UploadResultWithSignal, + ) => MaybePromise + abortMultipartUpload: ( + file: UppyFile, + opts: UploadResultWithSignal, + ) => MaybePromise + completeMultipartUpload: ( + file: UppyFile, + opts: { + uploadId: string + key: string + parts: AwsS3Part[] + signal: AbortSignal + }, + ) => MaybePromise<{ location?: string }> +} & ( + | AWSS3MultipartWithoutCompanionMandatorySignPart + | AWSS3MultipartWithoutCompanionMandatoryPrepareUploadParts +) + +type AWSS3MultipartWithoutCompanion< + M extends Meta, + B extends Body, +> = AWSS3WithoutCompanion & + AWSS3MultipartWithoutCompanionMandatory & { + shouldUseMultipart?: true + } + +type AWSS3MultipartWithCompanion< + M extends Meta, + B extends Body, +> = AWSS3WithCompanion & + Partial> & { + shouldUseMultipart?: true + } + +type AWSS3MaybeMultipartWithCompanion< + M extends Meta, + B extends Body, +> = AWSS3WithCompanion & + Partial> & + AWSS3NonMultipartWithCompanionMandatory & { + shouldUseMultipart: (file: UppyFile) => boolean + } + +type AWSS3MaybeMultipartWithoutCompanion< + M extends Meta, + B extends Body, +> = AWSS3WithoutCompanion & + AWSS3MultipartWithoutCompanionMandatory & + AWSS3NonMultipartWithoutCompanionMandatory & { + shouldUseMultipart: (file: UppyFile) => boolean + } + +type RequestClientOptions = Partial< + ConstructorParameters>[1] +> + +interface _AwsS3MultipartOptions extends PluginOpts, RequestClientOptions { + allowedMetaFields?: string[] | null + limit?: number + retryDelays?: number[] | null +} + +export type AwsS3MultipartOptions< + M extends Meta, + B extends Body, +> = _AwsS3MultipartOptions & + ( + | AWSS3NonMultipartWithCompanion + | AWSS3NonMultipartWithoutCompanion + | AWSS3MultipartWithCompanion + | AWSS3MultipartWithoutCompanion + | AWSS3MaybeMultipartWithCompanion + | AWSS3MaybeMultipartWithoutCompanion + ) + +const defaultOptions = { + // TODO: null here means “include all”, [] means include none. + // This is inconsistent with @uppy/aws-s3 and @uppy/transloadit + allowedMetaFields: null, + limit: 6, + getTemporarySecurityCredentials: false as any, + shouldUseMultipart: ((file: UppyFile) => + file.size !== 0) as any as true, // TODO: Switch default to: + // eslint-disable-next-line no-bitwise + // shouldUseMultipart: (file) => file.size >> 10 >> 10 > 100, + retryDelays: [0, 1000, 3000, 5000], + companionHeaders: {}, +} satisfies Partial> + +export default class AwsS3Multipart< + M extends Meta, + B extends Body, +> extends BasePlugin< + DefinePluginOpts, keyof typeof defaultOptions> & + // We also have a few dynamic options defined below: + Pick< + AWSS3MultipartWithoutCompanionMandatory, + | 'getChunkSize' + | 'createMultipartUpload' + | 'listParts' + | 'abortMultipartUpload' + | 'completeMultipartUpload' + > & + Required> & + AWSS3MultipartWithoutCompanionMandatorySignPart & + AWSS3NonMultipartWithoutCompanionMandatory, + M, + B +> { + static VERSION = packageJson.version + + #companionCommunicationQueue + + #client: RequestClient + + protected requests: any + + protected uploaderEvents: Record | null> + + protected uploaders: Record | null> + + protected uploaderSockets: Record + + constructor(uppy: Uppy, opts: AwsS3MultipartOptions) { + super(uppy, { + ...defaultOptions, + uploadPartBytes: AwsS3Multipart.uploadPartBytes, + createMultipartUpload: null as any, + listParts: null as any, + abortMultipartUpload: null as any, + completeMultipartUpload: null as any, + signPart: null as any, + getUploadParameters: null as any, + ...opts, + }) + // We need the `as any` here because of the dynamic default options. + this.type = 'uploader' + this.id = this.opts.id || 'AwsS3Multipart' + // @ts-expect-error TODO: remove unused + this.title = 'AWS S3 Multipart' + // TODO: only initiate `RequestClient` is `companionUrl` is defined. + this.#client = new RequestClient(uppy, opts as any) + + const dynamicDefaultOptions = { + createMultipartUpload: this.createMultipartUpload, + listParts: this.listParts, + abortMultipartUpload: this.abortMultipartUpload, + completeMultipartUpload: this.completeMultipartUpload, + signPart: + opts?.getTemporarySecurityCredentials ? + this.createSignedURL + : this.signPart, + getUploadParameters: + opts?.getTemporarySecurityCredentials ? + (this.createSignedURL as any) + : this.getUploadParameters, + } satisfies Partial> + + for (const key of Object.keys(dynamicDefaultOptions)) { + if (this.opts[key as keyof typeof dynamicDefaultOptions] == null) { + this.opts[key as keyof typeof dynamicDefaultOptions] = + dynamicDefaultOptions[key as keyof typeof dynamicDefaultOptions].bind( + this, + ) + } + } + if ( + (opts as AWSS3MultipartWithoutCompanionMandatoryPrepareUploadParts) + ?.prepareUploadParts != null && + (opts as AWSS3MultipartWithoutCompanionMandatorySignPart) + .signPart == null + ) { + this.opts.signPart = async ( + file: UppyFile, + { uploadId, key, partNumber, body, signal }: SignPartOptions, + ) => { + const { presignedUrls, headers } = await ( + opts as AWSS3MultipartWithoutCompanionMandatoryPrepareUploadParts< + M, + B + > + ).prepareUploadParts(file, { + uploadId, + key, + parts: [{ number: partNumber, chunk: body }], + signal, + }) + return { + url: presignedUrls?.[partNumber], + headers: headers?.[partNumber], + } + } + } + + /** + * Simultaneous upload limiting is shared across all uploads with this plugin. + * + * @type {RateLimitedQueue} + */ + this.requests = + (this.opts as any).rateLimitedQueue ?? + new RateLimitedQueue(this.opts.limit) + this.#companionCommunicationQueue = new HTTPCommunicationQueue( + this.requests, + this.opts, + this.#setS3MultipartState, + this.#getFile, + ) + + this.uploaders = Object.create(null) + this.uploaderEvents = Object.create(null) + this.uploaderSockets = Object.create(null) + } + + private [Symbol.for('uppy test: getClient')]() { + return this.#client + } + + setOptions(newOptions: Partial>): void { + this.#companionCommunicationQueue.setOptions(newOptions) + super.setOptions(newOptions) + this.#setCompanionHeaders() + } + + /** + * Clean up all references for a file's upload: the MultipartUploader instance, + * any events related to the file, and the Companion WebSocket connection. + * + * Set `opts.abort` to tell S3 that the multipart upload is cancelled and must be removed. + * This should be done when the user cancels the upload, not when the upload is completed or errored. + */ + resetUploaderReferences(fileID: string, opts?: { abort: boolean }): void { + if (this.uploaders[fileID]) { + this.uploaders[fileID]!.abort({ really: opts?.abort || false }) + this.uploaders[fileID] = null + } + if (this.uploaderEvents[fileID]) { + this.uploaderEvents[fileID]!.remove() + this.uploaderEvents[fileID] = null + } + if (this.uploaderSockets[fileID]) { + // @ts-expect-error TODO: remove this block in the next major + this.uploaderSockets[fileID].close() + // @ts-expect-error TODO: remove this block in the next major + this.uploaderSockets[fileID] = null + } + } + + // TODO: make this a private method in the next major + assertHost(method: string): void { + if (!this.opts.companionUrl) { + throw new Error( + `Expected a \`companionUrl\` option containing a Companion address, or if you are not using Companion, a custom \`${method}\` implementation.`, + ) + } + } + + createMultipartUpload( + file: UppyFile, + signal?: AbortSignal, + ): Promise { + this.assertHost('createMultipartUpload') + throwIfAborted(signal) + + const metadata = getAllowedMetadata({ + meta: file.meta, + allowedMetaFields: this.opts.allowedMetaFields, + }) + + return this.#client + .post( + 's3/multipart', + { + filename: file.name, + type: file.type, + metadata, + }, + { signal }, + ) + .then(assertServerError) + } + + listParts( + file: UppyFile, + { key, uploadId, signal }: UploadResultWithSignal, + oldSignal?: AbortSignal, + ): Promise { + signal ??= oldSignal // eslint-disable-line no-param-reassign + this.assertHost('listParts') + throwIfAborted(signal) + + const filename = encodeURIComponent(key) + return this.#client + .get(`s3/multipart/${uploadId}?key=${filename}`, { signal }) + .then(assertServerError) + } + + completeMultipartUpload( + file: UppyFile, + { key, uploadId, parts, signal }: MultipartUploadResultWithSignal, + oldSignal?: AbortSignal, + ): Promise { + signal ??= oldSignal // eslint-disable-line no-param-reassign + this.assertHost('completeMultipartUpload') + throwIfAborted(signal) + + const filename = encodeURIComponent(key) + const uploadIdEnc = encodeURIComponent(uploadId) + return this.#client + .post( + `s3/multipart/${uploadIdEnc}/complete?key=${filename}`, + { parts }, + { signal }, + ) + .then(assertServerError) + } + + #cachedTemporaryCredentials: MaybePromise + + async #getTemporarySecurityCredentials(options?: RequestOptions) { + throwIfAborted(options?.signal) + + if (this.#cachedTemporaryCredentials == null) { + // We do not await it just yet, so concurrent calls do not try to override it: + if (this.opts.getTemporarySecurityCredentials === true) { + this.assertHost('getTemporarySecurityCredentials') + this.#cachedTemporaryCredentials = this.#client + .get('s3/sts', options) + .then(assertServerError) + } else { + this.#cachedTemporaryCredentials = + this.opts.getTemporarySecurityCredentials(options) + } + this.#cachedTemporaryCredentials = await this.#cachedTemporaryCredentials + setTimeout( + () => { + // At half the time left before expiration, we clear the cache. That's + // an arbitrary tradeoff to limit the number of requests made to the + // remote while limiting the risk of using an expired token in case the + // clocks are not exactly synced. + // The HTTP cache should be configured to ensure a client doesn't request + // more tokens than it needs, but this timeout provides a second layer of + // security in case the HTTP cache is disabled or misconfigured. + this.#cachedTemporaryCredentials = null as any + }, + (getExpiry(this.#cachedTemporaryCredentials.credentials) || 0) * 500, + ) + } + + return this.#cachedTemporaryCredentials + } + + async createSignedURL( + file: UppyFile, + options: SignPartOptions, + ): Promise { + const data = await this.#getTemporarySecurityCredentials(options) + const expires = getExpiry(data.credentials) || 604_800 // 604 800 is the max value accepted by AWS. + + const { uploadId, key, partNumber } = options + + // Return an object in the correct shape. + return { + method: 'PUT', + expires, + fields: {}, + url: `${await createSignedURL({ + accountKey: data.credentials.AccessKeyId, + accountSecret: data.credentials.SecretAccessKey, + sessionToken: data.credentials.SessionToken, + expires, + bucketName: data.bucket, + Region: data.region, + Key: key ?? `${crypto.randomUUID()}-${file.name}`, + uploadId, + partNumber, + })}`, + // Provide content type header required by S3 + headers: { + 'Content-Type': file.type as string, + }, + } + } + + signPart( + file: UppyFile, + { uploadId, key, partNumber, signal }: SignPartOptions, + ): Promise { + this.assertHost('signPart') + throwIfAborted(signal) + + if (uploadId == null || key == null || partNumber == null) { + throw new Error( + 'Cannot sign without a key, an uploadId, and a partNumber', + ) + } + + const filename = encodeURIComponent(key) + return this.#client + .get( + `s3/multipart/${uploadId}/${partNumber}?key=${filename}`, + { signal }, + ) + .then(assertServerError) + } + + abortMultipartUpload( + file: UppyFile, + { key, uploadId, signal }: UploadResultWithSignal, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + oldSignal?: AbortSignal, // TODO: remove in next major + ): Promise { + signal ??= oldSignal // eslint-disable-line no-param-reassign + this.assertHost('abortMultipartUpload') + + const filename = encodeURIComponent(key) + const uploadIdEnc = encodeURIComponent(uploadId) + return this.#client + .delete(`s3/multipart/${uploadIdEnc}?key=${filename}`, undefined, { + signal, + }) + .then(assertServerError) + } + + getUploadParameters( + file: UppyFile, + options: RequestOptions, + ): Promise { + const { meta } = file + const { type, name: filename } = meta + const metadata = getAllowedMetadata({ + meta, + allowedMetaFields: this.opts.allowedMetaFields, + querify: true, + }) + + const query = new URLSearchParams({ filename, type, ...metadata } as Record< + string, + string + >) + + return this.#client.get(`s3/params?${query}`, options) + } + + static async uploadPartBytes({ + signature: { url, expires, headers, method = 'PUT' }, + body, + size = (body as Blob).size, + onProgress, + onComplete, + signal, + }: { + signature: AwsS3UploadParameters + body: FormData | Blob + size?: number + onProgress: any + onComplete: any + signal?: AbortSignal + }): Promise { + throwIfAborted(signal) + + if (url == null) { + throw new Error('Cannot upload to an undefined URL') + } + + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + xhr.open(method, url, true) + if (headers) { + Object.keys(headers).forEach((key) => { + xhr.setRequestHeader(key, headers[key]) + }) + } + xhr.responseType = 'text' + if (typeof expires === 'number') { + xhr.timeout = expires * 1000 + } + + function onabort() { + xhr.abort() + } + function cleanup() { + signal?.removeEventListener('abort', onabort) + } + signal?.addEventListener('abort', onabort) + + xhr.upload.addEventListener('progress', (ev) => { + onProgress(ev) + }) + + xhr.addEventListener('abort', () => { + cleanup() + + reject(createAbortError()) + }) + + xhr.addEventListener('timeout', () => { + cleanup() + + const error = new Error('Request has expired') + ;(error as any).source = { status: 403 } + reject(error) + }) + xhr.addEventListener('load', (ev) => { + cleanup() + + if ( + xhr.status === 403 && + xhr.responseText.includes('Request has expired') + ) { + const error = new Error('Request has expired') + ;(error as any).source = xhr + reject(error) + return + } + if (xhr.status < 200 || xhr.status >= 300) { + const error = new Error('Non 2xx') + ;(error as any).source = xhr + reject(error) + return + } + + // todo make a proper onProgress API (breaking change) + onProgress?.({ loaded: size, lengthComputable: true }) + + // NOTE This must be allowed by CORS. + const etag = xhr.getResponseHeader('ETag') + const location = xhr.getResponseHeader('Location') + + if (method.toUpperCase() === 'POST' && location === null) { + // Not being able to read the Location header is not a fatal error. + // eslint-disable-next-line no-console + console.warn( + 'AwsS3/Multipart: Could not read the Location header. This likely means CORS is not configured correctly on the S3 Bucket. See https://uppy.io/docs/aws-s3-multipart#S3-Bucket-Configuration for instructions.', + ) + } + if (etag === null) { + reject( + new Error( + 'AwsS3/Multipart: Could not read the ETag header. This likely means CORS is not configured correctly on the S3 Bucket. See https://uppy.io/docs/aws-s3-multipart#S3-Bucket-Configuration for instructions.', + ), + ) + return + } + + onComplete?.(etag) + resolve({ + ETag: etag, + ...(location ? { location } : undefined), + }) + }) + + xhr.addEventListener('error', (ev) => { + cleanup() + + const error = new Error('Unknown error') + ;(error as any).source = ev.target + reject(error) + }) + + xhr.send(body) + }) + } + + #setS3MultipartState = ( + file: UppyFile, + { key, uploadId }: UploadResult, + ) => { + const cFile = this.uppy.getFile(file.id) + if (cFile == null) { + // file was removed from store + return + } + + this.uppy.setFileState(file.id, { + s3Multipart: { + ...(cFile as MultipartFile).s3Multipart, + key, + uploadId, + }, + } as Partial>) + } + + #getFile = (file: UppyFile) => { + return this.uppy.getFile(file.id) || file + } + + #uploadLocalFile(file: UppyFile) { + return new Promise((resolve, reject) => { + const onProgress = (bytesUploaded: number, bytesTotal: number) => { + this.uppy.emit('upload-progress', this.uppy.getFile(file.id), { + // @ts-expect-error TODO: figure out if we need this + uploader: this, + bytesUploaded, + bytesTotal, + }) + } + + const onError = (err: unknown) => { + this.uppy.log(err as Error) + this.uppy.emit('upload-error', file, err as Error) + + this.resetUploaderReferences(file.id) + reject(err) + } + + const onSuccess = (result: B) => { + const uploadResp = { + body: { + ...result, + }, + status: 200, + uploadURL: result.location, + } + + this.resetUploaderReferences(file.id) + + this.uppy.emit('upload-success', this.#getFile(file), uploadResp) + + if (result.location) { + this.uppy.log(`Download ${file.name} from ${result.location}`) + } + + resolve() + } + + const upload = new MultipartUploader(file.data, { + // .bind to pass the file object to each handler. + companionComm: this.#companionCommunicationQueue, + + log: (...args: Parameters['log']>) => this.uppy.log(...args), + getChunkSize: + this.opts.getChunkSize ? this.opts.getChunkSize.bind(this) : null, + + onProgress, + onError, + onSuccess, + onPartComplete: (part) => { + this.uppy.emit( + 's3-multipart:part-uploaded', + this.#getFile(file), + part, + ) + }, + + file, + shouldUseMultipart: this.opts.shouldUseMultipart, + + ...(file as MultipartFile).s3Multipart, + }) + + this.uploaders[file.id] = upload + const eventManager = new EventManager(this.uppy) + this.uploaderEvents[file.id] = eventManager + + eventManager.onFileRemove(file.id, (removed) => { + upload.abort() + this.resetUploaderReferences(file.id, { abort: true }) + resolve(`upload ${removed} was removed`) + }) + + eventManager.onCancelAll(file.id, (options) => { + if (options?.reason === 'user') { + upload.abort() + this.resetUploaderReferences(file.id, { abort: true }) + } + resolve(`upload ${file.id} was canceled`) + }) + + eventManager.onFilePause(file.id, (isPaused) => { + if (isPaused) { + upload.pause() + } else { + upload.start() + } + }) + + eventManager.onPauseAll(file.id, () => { + upload.pause() + }) + + eventManager.onResumeAll(file.id, () => { + upload.start() + }) + + upload.start() + }) + } + + // eslint-disable-next-line class-methods-use-this + #getCompanionClientArgs(file: UppyFile) { + return { + ...file.remote?.body, + protocol: 's3-multipart', + size: file.data.size, + metadata: file.meta, + } + } + + #upload = async (fileIDs: string[]) => { + if (fileIDs.length === 0) return undefined + + const files = this.uppy.getFilesByIds(fileIDs) + const filesFiltered = filterNonFailedFiles(files) + const filesToEmit = filterFilesToEmitUploadStarted(filesFiltered) + + this.uppy.emit('upload-start', filesToEmit) + + const promises = filesFiltered.map((file) => { + if (file.isRemote) { + const getQueue = () => this.requests + this.#setResumableUploadsCapability(false) + const controller = new AbortController() + + const removedHandler = (removedFile: UppyFile) => { + if (removedFile.id === file.id) controller.abort() + } + this.uppy.on('file-removed', removedHandler) + + const uploadPromise = this.uppy + .getRequestClientForFile>(file) + .uploadRemoteFile(file, this.#getCompanionClientArgs(file), { + signal: controller.signal, + getQueue, + }) + + this.requests.wrapSyncFunction( + () => { + this.uppy.off('file-removed', removedHandler) + }, + { priority: -1 }, + )() + + return uploadPromise + } + + return this.#uploadLocalFile(file) + }) + + const upload = await Promise.all(promises) + // After the upload is done, another upload may happen with only local files. + // We reset the capability so that the next upload can use resumable uploads. + this.#setResumableUploadsCapability(true) + return upload + } + + #setCompanionHeaders = () => { + this.#client.setCompanionHeaders(this.opts.companionHeaders) + } + + #setResumableUploadsCapability = (boolean: boolean) => { + const { capabilities } = this.uppy.getState() + this.uppy.setState({ + capabilities: { + ...capabilities, + resumableUploads: boolean, + }, + }) + } + + #resetResumableCapability = () => { + this.#setResumableUploadsCapability(true) + } + + install(): void { + this.#setResumableUploadsCapability(true) + this.uppy.addPreProcessor(this.#setCompanionHeaders) + this.uppy.addUploader(this.#upload) + this.uppy.on('cancel-all', this.#resetResumableCapability) + } + + uninstall(): void { + this.uppy.removePreProcessor(this.#setCompanionHeaders) + this.uppy.removeUploader(this.#upload) + this.uppy.off('cancel-all', this.#resetResumableCapability) + } +} + +export type uploadPartBytes = (typeof AwsS3Multipart< + any, + any +>)['uploadPartBytes'] diff --git a/packages/@uppy/aws-s3-multipart/src/utils.ts b/packages/@uppy/aws-s3-multipart/src/utils.ts new file mode 100644 index 0000000000..69c4f1a5ac --- /dev/null +++ b/packages/@uppy/aws-s3-multipart/src/utils.ts @@ -0,0 +1,28 @@ +import { createAbortError } from '@uppy/utils/lib/AbortController' +import type { Body as _Body } from '@uppy/utils/lib/UppyFile' + +import type { AwsS3Part } from './index' + +export function throwIfAborted(signal?: AbortSignal | null): void { + if (signal?.aborted) { + throw createAbortError('The operation was aborted', { + cause: signal.reason, + }) + } +} + +export type UploadResult = { key: string; uploadId: string } +export type UploadResultWithSignal = UploadResult & { signal?: AbortSignal } +export type MultipartUploadResult = UploadResult & { parts: AwsS3Part[] } +export type MultipartUploadResultWithSignal = MultipartUploadResult & { + signal?: AbortSignal +} + +export type UploadPartBytesResult = { + ETag: string + location?: string +} + +export interface Body extends _Body { + location: string +} diff --git a/packages/@uppy/aws-s3-multipart/tsconfig.build.json b/packages/@uppy/aws-s3-multipart/tsconfig.build.json new file mode 100644 index 0000000000..40df14c108 --- /dev/null +++ b/packages/@uppy/aws-s3-multipart/tsconfig.build.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.shared", + "compilerOptions": { + "noImplicitAny": false, + "outDir": "./lib", + "paths": { + "@uppy/companion-client": ["../companion-client/src/index.js"], + "@uppy/companion-client/lib/*": ["../companion-client/src/*"], + "@uppy/utils/lib/*": ["../utils/src/*"], + "@uppy/core": ["../core/src/index.js"], + "@uppy/core/lib/*": ["../core/src/*"] + }, + "resolveJsonModule": false, + "rootDir": "./src", + "skipLibCheck": true + }, + "include": ["./src/**/*.*"], + "exclude": ["./src/**/*.test.ts"], + "references": [ + { + "path": "../companion-client/tsconfig.build.json" + }, + { + "path": "../utils/tsconfig.build.json" + }, + { + "path": "../core/tsconfig.build.json" + } + ] +} diff --git a/packages/@uppy/aws-s3-multipart/tsconfig.json b/packages/@uppy/aws-s3-multipart/tsconfig.json new file mode 100644 index 0000000000..f43408fa18 --- /dev/null +++ b/packages/@uppy/aws-s3-multipart/tsconfig.json @@ -0,0 +1,26 @@ +{ + "extends": "../../../tsconfig.shared", + "compilerOptions": { + "emitDeclarationOnly": false, + "noEmit": true, + "paths": { + "@uppy/companion-client": ["../companion-client/src/index.js"], + "@uppy/companion-client/lib/*": ["../companion-client/src/*"], + "@uppy/utils/lib/*": ["../utils/src/*"], + "@uppy/core": ["../core/src/index.js"], + "@uppy/core/lib/*": ["../core/src/*"], + }, + }, + "include": ["./package.json", "./src/**/*.*"], + "references": [ + { + "path": "../companion-client/tsconfig.build.json", + }, + { + "path": "../utils/tsconfig.build.json", + }, + { + "path": "../core/tsconfig.build.json", + }, + ], +} diff --git a/packages/@uppy/core/src/Uppy.ts b/packages/@uppy/core/src/Uppy.ts index e683d7bc74..ae7f812356 100644 --- a/packages/@uppy/core/src/Uppy.ts +++ b/packages/@uppy/core/src/Uppy.ts @@ -49,7 +49,10 @@ import locale from './locale.ts' import type BasePlugin from './BasePlugin.ts' import type { Restrictions, ValidateableFile } from './Restricter.ts' -type Processor = (fileIDs: string[], uploadID: string) => Promise | void +type Processor = ( + fileIDs: string[], + uploadID: string, +) => Promise | void type FileRemoveReason = 'user' | 'cancel-all' From 57177237d150b5aa0446104d0696af0011f4a30a Mon Sep 17 00:00:00 2001 From: Mikael Finstad Date: Wed, 20 Mar 2024 12:04:45 +0800 Subject: [PATCH 03/27] improve error msg (#5010) https://github.com/transloadit/uppy/pull/5003#issuecomment-2007045486 --- packages/@uppy/companion/src/config/companion.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@uppy/companion/src/config/companion.js b/packages/@uppy/companion/src/config/companion.js index d5dafcc77e..5c6e8224ae 100644 --- a/packages/@uppy/companion/src/config/companion.js +++ b/packages/@uppy/companion/src/config/companion.js @@ -91,7 +91,7 @@ const validateConfig = (companionOptions) => { if (server && server.path) { // see https://github.com/transloadit/uppy/issues/4271 // todo fix the code so we can allow `/` - if (server.path === '/') throw new Error('server.path cannot be set to /') + if (server.path === '/') throw new Error('If you want to use \'/\' as server.path, leave the \'path\' variable unset') } if (providerOptions) { From 5fcdd8f275e7d45aa85523f8cf2510cbef7a2f9a Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Wed, 20 Mar 2024 14:23:56 +0200 Subject: [PATCH 04/27] @uppy/dashboard: refactor to TypeScript (#4984) Co-authored-by: Murderlon --- packages/@uppy/dashboard/.npmignore | 1 + .../src/{Dashboard.jsx => Dashboard.tsx} | 680 ++++++++++++------ .../dashboard/src/components/AddFiles.jsx | 325 --------- .../dashboard/src/components/AddFiles.tsx | 450 ++++++++++++ .../{AddFilesPanel.jsx => AddFilesPanel.tsx} | 13 +- .../{Dashboard.jsx => Dashboard.tsx} | 148 ++-- .../{EditorPanel.jsx => EditorPanel.tsx} | 19 +- .../components/FileCard/RenderMetaFields.jsx | 46 -- .../components/FileCard/RenderMetaFields.tsx | 54 ++ .../FileCard/{index.jsx => index.tsx} | 57 +- .../FileItem/Buttons/{index.jsx => index.tsx} | 85 ++- .../FileInfo/{index.jsx => index.tsx} | 57 +- .../FileItem/FilePreviewAndLink/index.jsx | 39 - .../FileItem/FilePreviewAndLink/index.tsx | 40 ++ .../FileProgress/{index.jsx => index.tsx} | 71 +- ...aErrorMessage.jsx => MetaErrorMessage.tsx} | 21 +- .../FileItem/{index.jsx => index.tsx} | 45 +- .../components/{FileList.jsx => FileList.tsx} | 74 +- .../{FilePreview.jsx => FilePreview.tsx} | 19 +- ...anelContent.jsx => PickerPanelContent.tsx} | 19 +- ...rPanelTopBar.jsx => PickerPanelTopBar.tsx} | 78 +- .../@uppy/dashboard/src/components/Slide.jsx | 86 --- .../@uppy/dashboard/src/components/Slide.tsx | 96 +++ packages/@uppy/dashboard/src/index.js | 1 - .../src/{index.test.js => index.test.ts} | 47 +- packages/@uppy/dashboard/src/index.ts | 1 + .../dashboard/src/{locale.js => locale.ts} | 0 ...pboard.test.js => copyToClipboard.test.ts} | 2 +- ...{copyToClipboard.js => copyToClipboard.ts} | 14 +- ...Focus.test.js => createSuperFocus.test.ts} | 2 +- ...reateSuperFocus.js => createSuperFocus.ts} | 14 +- .../dashboard/src/utils/getActiveOverlayEl.js | 11 - .../dashboard/src/utils/getActiveOverlayEl.ts | 18 + .../dashboard/src/utils/getFileTypeIcon.jsx | 127 ---- .../dashboard/src/utils/getFileTypeIcon.tsx | 212 ++++++ .../utils/{ignoreEvent.js => ignoreEvent.ts} | 7 +- .../src/utils/{trapFocus.js => trapFocus.ts} | 39 +- packages/@uppy/dashboard/tsconfig.build.json | 60 ++ packages/@uppy/dashboard/tsconfig.json | 56 ++ 39 files changed, 1992 insertions(+), 1142 deletions(-) create mode 100644 packages/@uppy/dashboard/.npmignore rename packages/@uppy/dashboard/src/{Dashboard.jsx => Dashboard.tsx} (62%) delete mode 100644 packages/@uppy/dashboard/src/components/AddFiles.jsx create mode 100644 packages/@uppy/dashboard/src/components/AddFiles.tsx rename packages/@uppy/dashboard/src/components/{AddFilesPanel.jsx => AddFilesPanel.tsx} (71%) rename packages/@uppy/dashboard/src/components/{Dashboard.jsx => Dashboard.tsx} (56%) rename packages/@uppy/dashboard/src/components/{EditorPanel.jsx => EditorPanel.tsx} (70%) delete mode 100644 packages/@uppy/dashboard/src/components/FileCard/RenderMetaFields.jsx create mode 100644 packages/@uppy/dashboard/src/components/FileCard/RenderMetaFields.tsx rename packages/@uppy/dashboard/src/components/FileCard/{index.jsx => index.tsx} (75%) rename packages/@uppy/dashboard/src/components/FileItem/Buttons/{index.jsx => index.tsx} (63%) rename packages/@uppy/dashboard/src/components/FileItem/FileInfo/{index.jsx => index.tsx} (70%) delete mode 100644 packages/@uppy/dashboard/src/components/FileItem/FilePreviewAndLink/index.jsx create mode 100644 packages/@uppy/dashboard/src/components/FileItem/FilePreviewAndLink/index.tsx rename packages/@uppy/dashboard/src/components/FileItem/FileProgress/{index.jsx => index.tsx} (67%) rename packages/@uppy/dashboard/src/components/FileItem/{MetaErrorMessage.jsx => MetaErrorMessage.tsx} (60%) rename packages/@uppy/dashboard/src/components/FileItem/{index.jsx => index.tsx} (75%) rename packages/@uppy/dashboard/src/components/{FileList.jsx => FileList.tsx} (65%) rename packages/@uppy/dashboard/src/components/{FilePreview.jsx => FilePreview.tsx} (57%) rename packages/@uppy/dashboard/src/components/{PickerPanelContent.jsx => PickerPanelContent.tsx} (73%) rename packages/@uppy/dashboard/src/components/{PickerPanelTopBar.jsx => PickerPanelTopBar.tsx} (69%) delete mode 100644 packages/@uppy/dashboard/src/components/Slide.jsx create mode 100644 packages/@uppy/dashboard/src/components/Slide.tsx delete mode 100644 packages/@uppy/dashboard/src/index.js rename packages/@uppy/dashboard/src/{index.test.js => index.test.ts} (77%) create mode 100644 packages/@uppy/dashboard/src/index.ts rename packages/@uppy/dashboard/src/{locale.js => locale.ts} (100%) rename packages/@uppy/dashboard/src/utils/{copyToClipboard.test.js => copyToClipboard.test.ts} (80%) rename packages/@uppy/dashboard/src/utils/{copyToClipboard.js => copyToClipboard.ts} (82%) rename packages/@uppy/dashboard/src/utils/{createSuperFocus.test.js => createSuperFocus.test.ts} (86%) rename packages/@uppy/dashboard/src/utils/{createSuperFocus.js => createSuperFocus.ts} (86%) delete mode 100644 packages/@uppy/dashboard/src/utils/getActiveOverlayEl.js create mode 100644 packages/@uppy/dashboard/src/utils/getActiveOverlayEl.ts delete mode 100644 packages/@uppy/dashboard/src/utils/getFileTypeIcon.jsx create mode 100644 packages/@uppy/dashboard/src/utils/getFileTypeIcon.tsx rename packages/@uppy/dashboard/src/utils/{ignoreEvent.js => ignoreEvent.ts} (77%) rename packages/@uppy/dashboard/src/utils/{trapFocus.js => trapFocus.ts} (70%) create mode 100644 packages/@uppy/dashboard/tsconfig.build.json create mode 100644 packages/@uppy/dashboard/tsconfig.json diff --git a/packages/@uppy/dashboard/.npmignore b/packages/@uppy/dashboard/.npmignore new file mode 100644 index 0000000000..6c816673f0 --- /dev/null +++ b/packages/@uppy/dashboard/.npmignore @@ -0,0 +1 @@ +tsconfig.* diff --git a/packages/@uppy/dashboard/src/Dashboard.jsx b/packages/@uppy/dashboard/src/Dashboard.tsx similarity index 62% rename from packages/@uppy/dashboard/src/Dashboard.jsx rename to packages/@uppy/dashboard/src/Dashboard.tsx index a3fe0cd4cb..f0ccb7483e 100644 --- a/packages/@uppy/dashboard/src/Dashboard.jsx +++ b/packages/@uppy/dashboard/src/Dashboard.tsx @@ -1,4 +1,14 @@ -import { UIPlugin } from '@uppy/core' +import { + UIPlugin, + type UIPluginOptions, + type UnknownPlugin, + type Uppy, + type UploadResult, + type State, +} from '@uppy/core' +import type { ComponentChild, VNode } from 'preact' +import type { DefinePluginOpts } from '@uppy/core/lib/BasePlugin.ts' +import type { Body, Meta, UppyFile } from '@uppy/utils/lib/UppyFile' import StatusBar from '@uppy/status-bar' import Informer from '@uppy/informer' import ThumbnailGenerator from '@uppy/thumbnail-generator' @@ -9,124 +19,274 @@ import { defaultPickerIcon } from '@uppy/provider-views' import { nanoid } from 'nanoid/non-secure' import memoizeOne from 'memoize-one' -import * as trapFocus from './utils/trapFocus.js' -import createSuperFocus from './utils/createSuperFocus.js' -import DashboardUI from './components/Dashboard.jsx' +import * as trapFocus from './utils/trapFocus.ts' +import createSuperFocus from './utils/createSuperFocus.ts' +import DashboardUI from './components/Dashboard.tsx' +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore We don't want TS to generate types for the package.json import packageJson from '../package.json' -import locale from './locale.js' +import locale from './locale.ts' + +type GenericEventCallback = () => void +export type DashboardFileEditStartCallback = ( + file?: UppyFile, +) => void +export type DashboardFileEditCompleteCallback< + M extends Meta, + B extends Body, +> = (file?: UppyFile) => void +export type DashboardShowPlanelCallback = (id: string) => void +declare module '@uppy/core' { + export interface UppyEventMap { + 'dashboard:modal-open': GenericEventCallback + 'dashboard:modal-closed': GenericEventCallback + 'dashboard:show-panel': DashboardShowPlanelCallback + 'dashboard:file-edit-start': DashboardFileEditStartCallback + 'dashboard:file-edit-complete': DashboardFileEditCompleteCallback + 'dashboard:close-panel': (id: string | undefined) => void + 'restore-canceled': GenericEventCallback + } +} + +interface PromiseWithResolvers { + promise: Promise + resolve: (value: T | PromiseLike) => void + reject: (reason?: any) => void +} -const memoize = memoizeOne.default || memoizeOne +const memoize = ((memoizeOne as any).default as false) || memoizeOne const TAB_KEY = 9 const ESC_KEY = 27 -function createPromise () { - const o = {} - o.promise = new Promise((resolve, reject) => { +function createPromise(): PromiseWithResolvers { + const o = {} as PromiseWithResolvers + o.promise = new Promise((resolve, reject) => { o.resolve = resolve o.reject = reject }) return o } +type FieldRenderOptions = { + value: string + onChange: (newVal: string) => void + fieldCSSClasses: { text: string } + required: boolean + form: string +} + +type PreactRender = ( + node: any, + params: Record | null, + ...children: any[] +) => VNode + +interface MetaField { + id: string + name: string + placeholder?: string + render?: (field: FieldRenderOptions, h: PreactRender) => VNode +} + +interface Target { + id: string + name: string + type: string +} + +interface TargetWithRender extends Target { + icon: ComponentChild + render: () => ComponentChild +} + +interface DashboardState { + targets: Target[] + activePickerPanel: Target | undefined + showAddFilesPanel: boolean + activeOverlayType: string | null + fileCardFor: string | null + showFileEditor: boolean + metaFields?: MetaField[] | ((file: UppyFile) => MetaField[]) + [key: string]: unknown +} + +interface DashboardOptions + extends UIPluginOptions { + animateOpenClose?: boolean + browserBackButtonClose?: boolean + closeAfterFinish?: boolean + singleFileFullScreen?: boolean + closeModalOnClickOutside?: boolean + disableInformer?: boolean + disablePageScrollWhenModalOpen?: boolean + disableStatusBar?: boolean + disableThumbnailGenerator?: boolean + height?: string | number + thumbnailWidth?: number + thumbnailHeight?: number + thumbnailType?: string + nativeCameraFacingMode?: ConstrainDOMString + waitForThumbnailsBeforeUpload?: boolean + defaultPickerIcon?: typeof defaultPickerIcon + hideCancelButton?: boolean + hidePauseResumeButton?: boolean + hideProgressAfterFinish?: boolean + hideRetryButton?: boolean + hideUploadButton?: boolean + inline?: boolean + metaFields?: MetaField[] | ((file: UppyFile) => MetaField[]) + note?: string | null + plugins?: string[] + fileManagerSelectionType?: 'files' | 'folders' | 'both' + proudlyDisplayPoweredByUppy?: boolean + showLinkToFileUploadResult?: boolean + showProgressDetails?: boolean + showSelectedFiles?: boolean + showRemoveButtonAfterComplete?: boolean + showNativePhotoCameraButton?: boolean + showNativeVideoCameraButton?: boolean + theme?: 'auto' | 'dark' | 'light' + trigger?: string + width?: string | number + autoOpenFileEditor?: boolean + disabled?: boolean + disableLocalFiles?: boolean + onRequestCloseModal?: () => void + doneButtonHandler?: () => void + onDragOver?: (event: DragEvent) => void + onDragLeave?: (event: DragEvent) => void + onDrop?: (event: DragEvent) => void +} + +// set default options, must be kept in sync with packages/@uppy/react/src/DashboardModal.js +const defaultOptions = { + target: 'body', + metaFields: [], + inline: false, + width: 750, + height: 550, + thumbnailWidth: 280, + thumbnailType: 'image/jpeg', + waitForThumbnailsBeforeUpload: false, + defaultPickerIcon, + showLinkToFileUploadResult: false, + showProgressDetails: false, + hideUploadButton: false, + hideCancelButton: false, + hideRetryButton: false, + hidePauseResumeButton: false, + hideProgressAfterFinish: false, + note: null, + closeModalOnClickOutside: false, + closeAfterFinish: false, + singleFileFullScreen: true, + disableStatusBar: false, + disableInformer: false, + disableThumbnailGenerator: false, + disablePageScrollWhenModalOpen: true, + animateOpenClose: true, + fileManagerSelectionType: 'files', + proudlyDisplayPoweredByUppy: true, + showSelectedFiles: true, + showRemoveButtonAfterComplete: false, + browserBackButtonClose: false, + showNativePhotoCameraButton: false, + showNativeVideoCameraButton: false, + theme: 'light', + autoOpenFileEditor: false, + disabled: false, + disableLocalFiles: false, + + // Dynamic default options, they have to be defined in the constructor (because + // they require access to the `this` keyword), but we still want them to + // appear in the default options so TS knows they'll be defined. + doneButtonHandler: null as any, + onRequestCloseModal: null as any, +} satisfies Partial> + /** * Dashboard UI with previews, metadata editing, tabs for various services and more */ -export default class Dashboard extends UIPlugin { +export default class Dashboard extends UIPlugin< + DefinePluginOpts, keyof typeof defaultOptions>, + M, + B, + DashboardState +> { static VERSION = packageJson.version - #disabledNodes = null + #disabledNodes: HTMLElement[] | null + + private modalName = `uppy-Dashboard-${nanoid()}` + + private superFocus = createSuperFocus() + + private ifFocusedOnUppyRecently = false + + private dashboardIsDisabled: boolean + + private savedScrollPosition: number + + private savedActiveElement: HTMLElement - constructor (uppy, opts) { - super(uppy, opts) + private resizeObserver: ResizeObserver + + private darkModeMediaQuery: MediaQueryList | null + + // Timeouts + private makeDashboardInsidesVisibleAnywayTimeout: ReturnType< + typeof setTimeout + > + + private removeDragOverClassTimeout: ReturnType + + constructor(uppy: Uppy, opts?: DashboardOptions) { + super(uppy, { ...defaultOptions, ...opts }) this.id = this.opts.id || 'Dashboard' this.title = 'Dashboard' this.type = 'orchestrator' - this.modalName = `uppy-Dashboard-${nanoid()}` this.defaultLocale = locale - // set default options, must be kept in sync with packages/@uppy/react/src/DashboardModal.js - const defaultOptions = { - target: 'body', - metaFields: [], - trigger: null, - inline: false, - width: 750, - height: 550, - thumbnailWidth: 280, - thumbnailType: 'image/jpeg', - waitForThumbnailsBeforeUpload: false, - defaultPickerIcon, - showLinkToFileUploadResult: false, - showProgressDetails: false, - hideUploadButton: false, - hideCancelButton: false, - hideRetryButton: false, - hidePauseResumeButton: false, - hideProgressAfterFinish: false, - doneButtonHandler: () => { - this.uppy.clearUploadedFiles() - this.requestCloseModal() - }, - note: null, - closeModalOnClickOutside: false, - closeAfterFinish: false, - singleFileFullScreen: true, - disableStatusBar: false, - disableInformer: false, - disableThumbnailGenerator: false, - disablePageScrollWhenModalOpen: true, - animateOpenClose: true, - fileManagerSelectionType: 'files', - proudlyDisplayPoweredByUppy: true, - onRequestCloseModal: () => this.closeModal(), - showSelectedFiles: true, - showRemoveButtonAfterComplete: false, - browserBackButtonClose: false, - showNativePhotoCameraButton: false, - showNativeVideoCameraButton: false, - theme: 'light', - autoOpenFileEditor: false, - disabled: false, - disableLocalFiles: false, + // Dynamic default options: + this.opts.doneButtonHandler ??= () => { + this.uppy.clearUploadedFiles() + this.requestCloseModal() } - - // merge default options with the ones set by user - this.opts = { ...defaultOptions, ...opts } + this.opts.onRequestCloseModal ??= () => this.closeModal() this.i18nInit() - - this.superFocus = createSuperFocus() - this.ifFocusedOnUppyRecently = false - - // Timeouts - this.makeDashboardInsidesVisibleAnywayTimeout = null - this.removeDragOverClassTimeout = null } - removeTarget = (plugin) => { + removeTarget = (plugin: UnknownPlugin): void => { const pluginState = this.getPluginState() // filter out the one we want to remove - const newTargets = pluginState.targets.filter(target => target.id !== plugin.id) + const newTargets = pluginState.targets.filter( + (target) => target.id !== plugin.id, + ) this.setPluginState({ targets: newTargets, }) } - addTarget = (plugin) => { + addTarget = (plugin: UnknownPlugin): HTMLElement | null => { const callerPluginId = plugin.id || plugin.constructor.name - const callerPluginName = plugin.title || callerPluginId + const callerPluginName = + (plugin as any as { title: string }).title || callerPluginId const callerPluginType = plugin.type - if (callerPluginType !== 'acquirer' - && callerPluginType !== 'progressindicator' - && callerPluginType !== 'editor') { - const msg = 'Dashboard: can only be targeted by plugins of types: acquirer, progressindicator, editor' + if ( + callerPluginType !== 'acquirer' && + callerPluginType !== 'progressindicator' && + callerPluginType !== 'editor' + ) { + const msg = + 'Dashboard: can only be targeted by plugins of types: acquirer, progressindicator, editor' this.uppy.log(msg, 'error') - return undefined + return null } const target = { @@ -146,35 +306,37 @@ export default class Dashboard extends UIPlugin { return this.el } - hideAllPanels = () => { + hideAllPanels = (): void => { const state = this.getPluginState() const update = { - activePickerPanel: false, + activePickerPanel: undefined, showAddFilesPanel: false, activeOverlayType: null, fileCardFor: null, showFileEditor: false, } - if (state.activePickerPanel === update.activePickerPanel - && state.showAddFilesPanel === update.showAddFilesPanel - && state.showFileEditor === update.showFileEditor - && state.activeOverlayType === update.activeOverlayType) { + if ( + state.activePickerPanel === update.activePickerPanel && + state.showAddFilesPanel === update.showAddFilesPanel && + state.showFileEditor === update.showFileEditor && + state.activeOverlayType === update.activeOverlayType + ) { // avoid doing a state update if nothing changed return } this.setPluginState(update) - this.uppy.emit('dashboard:close-panel', state.activePickerPanel.id) + this.uppy.emit('dashboard:close-panel', state.activePickerPanel?.id) } - showPanel = (id) => { + showPanel = (id: string): void => { const { targets } = this.getPluginState() - const activePickerPanel = targets.filter((target) => { + const activePickerPanel = targets.find((target) => { return target.type === 'acquirer' && target.id === id - })[0] + }) this.setPluginState({ activePickerPanel, @@ -184,16 +346,16 @@ export default class Dashboard extends UIPlugin { this.uppy.emit('dashboard:show-panel', id) } - canEditFile = (file) => { + private canEditFile = (file: UppyFile): boolean => { const { targets } = this.getPluginState() const editors = this.#getEditors(targets) - return editors.some((target) => ( - this.uppy.getPlugin(target.id).canEditFile(file) - )) + return editors.some((target) => + (this.uppy.getPlugin(target.id) as any).canEditFile(file), + ) } - openFileEditor = (file) => { + openFileEditor = (file: UppyFile): void => { const { targets } = this.getPluginState() const editors = this.#getEditors(targets) @@ -204,45 +366,45 @@ export default class Dashboard extends UIPlugin { }) editors.forEach((editor) => { - this.uppy.getPlugin(editor.id).selectFile(file) + ;(this.uppy.getPlugin(editor.id) as any).selectFile(file) }) } - closeFileEditor = () => { + closeFileEditor = (): void => { const { metaFields } = this.getPluginState() const isMetaEditorEnabled = metaFields && metaFields.length > 0 if (isMetaEditorEnabled) { this.setPluginState({ showFileEditor: false, - activeOverlayType: 'FileCard' + activeOverlayType: 'FileCard', }) } else { this.setPluginState({ showFileEditor: false, fileCardFor: null, - activeOverlayType: 'AddFiles' + activeOverlayType: 'AddFiles', }) } } - saveFileEditor = () => { + saveFileEditor = (): void => { const { targets } = this.getPluginState() const editors = this.#getEditors(targets) editors.forEach((editor) => { - this.uppy.getPlugin(editor.id).save() + ;(this.uppy.getPlugin(editor.id) as any).save() }) this.closeFileEditor() } - openModal = () => { - const { promise, resolve } = createPromise() + openModal = (): Promise => { + const { promise, resolve } = createPromise() // save scroll position this.savedScrollPosition = window.pageYOffset // save active element, so we can restore focus when modal is closed - this.savedActiveElement = document.activeElement + this.savedActiveElement = document.activeElement as HTMLElement if (this.opts.disablePageScrollWhenModalOpen) { document.body.classList.add('uppy-Dashboard-isFixed') @@ -253,10 +415,10 @@ export default class Dashboard extends UIPlugin { this.setPluginState({ isHidden: false, }) - this.el.removeEventListener('animationend', handler, false) + this.el!.removeEventListener('animationend', handler, false) resolve() } - this.el.addEventListener('animationend', handler, false) + this.el!.addEventListener('animationend', handler, false) } else { this.setPluginState({ isHidden: false, @@ -276,11 +438,9 @@ export default class Dashboard extends UIPlugin { return promise } - closeModal = (opts = {}) => { - const { - // Whether the modal is being closed by the user (`true`) or by other means (e.g. browser back button) - manualClose = true, - } = opts + closeModal = (opts?: { manualClose: boolean }): void | Promise => { + // Whether the modal is being closed by the user (`true`) or by other means (e.g. browser back button) + const manualClose = opts?.manualClose ?? true const { isHidden, isClosing } = this.getPluginState() if (isHidden || isClosing) { @@ -288,7 +448,7 @@ export default class Dashboard extends UIPlugin { return undefined } - const { promise, resolve } = createPromise() + const { promise, resolve } = createPromise() if (this.opts.disablePageScrollWhenModalOpen) { document.body.classList.remove('uppy-Dashboard-isFixed') @@ -307,10 +467,10 @@ export default class Dashboard extends UIPlugin { this.superFocus.cancel() this.savedActiveElement.focus() - this.el.removeEventListener('animationend', handler, false) + this.el!.removeEventListener('animationend', handler, false) resolve() } - this.el.addEventListener('animationend', handler, false) + this.el!.addEventListener('animationend', handler, false) } else { this.setPluginState({ isHidden: true, @@ -342,18 +502,18 @@ export default class Dashboard extends UIPlugin { return promise } - isModalOpen = () => { + isModalOpen = (): boolean => { return !this.getPluginState().isHidden || false } - requestCloseModal = () => { + private requestCloseModal = (): void | Promise => { if (this.opts.onRequestCloseModal) { return this.opts.onRequestCloseModal() } return this.closeModal() } - setDarkModeCapability = (isDarkModeOn) => { + setDarkModeCapability = (isDarkModeOn: boolean): void => { const { capabilities } = this.uppy.getState() this.uppy.setState({ capabilities: { @@ -363,13 +523,13 @@ export default class Dashboard extends UIPlugin { }) } - handleSystemDarkModeChange = (event) => { + private handleSystemDarkModeChange = (event: MediaQueryListEvent) => { const isDarkModeOnNow = event.matches this.uppy.log(`[Dashboard] Dark mode is ${isDarkModeOnNow ? 'on' : 'off'}`) this.setDarkModeCapability(isDarkModeOnNow) } - toggleFileCard = (show, fileID) => { + private toggleFileCard = (show: boolean, fileID: string) => { const file = this.uppy.getFile(fileID) if (show) { this.uppy.emit('dashboard:file-edit-start', file) @@ -383,14 +543,14 @@ export default class Dashboard extends UIPlugin { }) } - toggleAddFilesPanel = (show) => { + private toggleAddFilesPanel = (show: boolean) => { this.setPluginState({ showAddFilesPanel: show, activeOverlayType: show ? 'AddFiles' : null, }) } - addFiles = (files) => { + addFiles = (files: File[]): void => { const descriptors = files.map((file) => ({ source: this.id, name: file.name, @@ -399,8 +559,9 @@ export default class Dashboard extends UIPlugin { meta: { // path of the file relative to the ancestor directory the user selected. // e.g. 'docs/Old Prague/airbnb.pdf' - relativePath: file.relativePath || file.webkitRelativePath || null, - }, + relativePath: + (file as any).relativePath || file.webkitRelativePath || null, + } as any as M, })) try { @@ -416,7 +577,7 @@ export default class Dashboard extends UIPlugin { // ___Why not apply visibility property to .uppy-Dashboard-inner? // Because ideally, acc to specs, ResizeObserver should see invisible elements as of width 0. So even though applying // invisibility to .uppy-Dashboard-inner works now, it may not work in the future. - startListeningToResize = () => { + private startListeningToResize = () => { // Watch for Dashboard container (`.uppy-Dashboard-inner`) resize // and update containerWidth/containerHeight in plugin state accordingly. // Emits first event on initialization. @@ -430,20 +591,26 @@ export default class Dashboard extends UIPlugin { areInsidesReadyToBeVisible: true, }) }) - this.resizeObserver.observe(this.el.querySelector('.uppy-Dashboard-inner')) + this.resizeObserver.observe( + this.el!.querySelector('.uppy-Dashboard-inner')!, + ) // If ResizeObserver fails to emit an event telling us what size to use - default to the mobile view this.makeDashboardInsidesVisibleAnywayTimeout = setTimeout(() => { const pluginState = this.getPluginState() const isModalAndClosed = !this.opts.inline && pluginState.isHidden - if (// We might want to enable this in the future + if ( + // We might want to enable this in the future // if ResizeObserver hasn't yet fired, - !pluginState.areInsidesReadyToBeVisible + !pluginState.areInsidesReadyToBeVisible && // and it's not due to the modal being closed - && !isModalAndClosed + !isModalAndClosed ) { - this.uppy.log('[Dashboard] resize event didn’t fire on time: defaulted to mobile layout', 'warning') + this.uppy.log( + '[Dashboard] resize event didn’t fire on time: defaulted to mobile layout', + 'warning', + ) this.setPluginState({ areInsidesReadyToBeVisible: true, @@ -452,7 +619,7 @@ export default class Dashboard extends UIPlugin { }, 1000) } - stopListeningToResize = () => { + private stopListeningToResize = () => { this.resizeObserver.disconnect() clearTimeout(this.makeDashboardInsidesVisibleAnywayTimeout) @@ -460,8 +627,8 @@ export default class Dashboard extends UIPlugin { // Records whether we have been interacting with uppy right now, // which is then used to determine whether state updates should trigger a refocusing. - recordIfFocusedOnUppyRecently = (event) => { - if (this.el.contains(event.target)) { + private recordIfFocusedOnUppyRecently = (event: Event) => { + if (this.el!.contains(event.target as HTMLElement)) { this.ifFocusedOnUppyRecently = true } else { this.ifFocusedOnUppyRecently = false @@ -472,7 +639,7 @@ export default class Dashboard extends UIPlugin { } } - disableInteractiveElements = (disable) => { + private disableInteractiveElements = (disable: boolean) => { const NODES_TO_DISABLE = [ 'a[href]', 'input:not([disabled])', @@ -482,8 +649,11 @@ export default class Dashboard extends UIPlugin { '[role="button"]:not([disabled])', ] - const nodesToDisable = this.#disabledNodes ?? toArray(this.el.querySelectorAll(NODES_TO_DISABLE)) - .filter(node => !node.classList.contains('uppy-Dashboard-close')) + const nodesToDisable = + this.#disabledNodes ?? + toArray(this.el!.querySelectorAll(NODES_TO_DISABLE as any)).filter( + (node) => !node.classList.contains('uppy-Dashboard-close'), + ) for (const node of nodesToDisable) { // Links can’t have `disabled` attr, so we use `aria-disabled` for a11y @@ -503,24 +673,27 @@ export default class Dashboard extends UIPlugin { this.dashboardIsDisabled = disable } - updateBrowserHistory = () => { + private updateBrowserHistory = () => { // Ensure history state does not already contain our modal name to avoid double-pushing // eslint-disable-next-line no-restricted-globals if (!history.state?.[this.modalName]) { // Push to history so that the page is not lost on browser back button press // eslint-disable-next-line no-restricted-globals - history.pushState({ - // eslint-disable-next-line no-restricted-globals - ...history.state, - [this.modalName]: true, - }, '') + history.pushState( + { + // eslint-disable-next-line no-restricted-globals + ...history.state, + [this.modalName]: true, + }, + '', + ) } // Listen for back button presses window.addEventListener('popstate', this.handlePopState, false) } - handlePopState = (event) => { + private handlePopState = (event: PopStateEvent) => { // Close the modal if the history state no longer contains our modal name if (this.isModalOpen() && (!event.state || !event.state[this.modalName])) { this.closeModal({ manualClose: false }) @@ -538,44 +711,49 @@ export default class Dashboard extends UIPlugin { } } - handleKeyDownInModal = (event) => { + private handleKeyDownInModal = (event: KeyboardEvent) => { // close modal on esc key press - if (event.keyCode === ESC_KEY) this.requestCloseModal(event) + if (event.keyCode === ESC_KEY) this.requestCloseModal() // trap focus on tab key press - if (event.keyCode === TAB_KEY) trapFocus.forModal(event, this.getPluginState().activeOverlayType, this.el) + if (event.keyCode === TAB_KEY) + trapFocus.forModal( + event, + this.getPluginState().activeOverlayType, + this.el, + ) } - handleClickOutside = () => { + private handleClickOutside = () => { if (this.opts.closeModalOnClickOutside) this.requestCloseModal() } - handlePaste = (event) => { + private handlePaste = (event: ClipboardEvent) => { // Let any acquirer plugin (Url/Webcam/etc.) handle pastes to the root this.uppy.iteratePlugins((plugin) => { if (plugin.type === 'acquirer') { // Every Plugin with .type acquirer can define handleRootPaste(event) - plugin.handleRootPaste?.(event) + ;(plugin as any).handleRootPaste?.(event) } }) // Add all dropped files - const files = toArray(event.clipboardData.files) + const files = toArray(event.clipboardData!.files) if (files.length > 0) { this.uppy.log('[Dashboard] Files pasted') this.addFiles(files) } } - handleInputChange = (event) => { + private handleInputChange = (event: InputEvent) => { event.preventDefault() - const files = toArray(event.target.files) + const files = toArray((event.target as HTMLInputElement).files!) if (files.length > 0) { this.uppy.log('[Dashboard] Files selected through input') this.addFiles(files) } } - handleDragOver = (event) => { + private handleDragOver = (event: DragEvent) => { event.preventDefault() event.stopPropagation() @@ -584,7 +762,7 @@ export default class Dashboard extends UIPlugin { const canSomePluginHandleRootDrop = () => { let somePluginCanHandleRootDrop = true this.uppy.iteratePlugins((plugin) => { - if (plugin.canHandleRootDrop?.(event)) { + if ((plugin as any).canHandleRootDrop?.(event)) { somePluginCanHandleRootDrop = true } }) @@ -593,23 +771,24 @@ export default class Dashboard extends UIPlugin { // Check if the "type" of the datatransfer object includes files const doesEventHaveFiles = () => { - const { types } = event.dataTransfer - return types.some(type => type === 'Files') + const { types } = event.dataTransfer! + return types.some((type) => type === 'Files') } // Deny drop, if no plugins can handle datatransfer, there are no files, // or when opts.disabled is set, or new uploads are not allowed - const somePluginCanHandleRootDrop = canSomePluginHandleRootDrop(event) - const hasFiles = doesEventHaveFiles(event) + const somePluginCanHandleRootDrop = canSomePluginHandleRootDrop() + const hasFiles = doesEventHaveFiles() if ( - (!somePluginCanHandleRootDrop && !hasFiles) - || this.opts.disabled + (!somePluginCanHandleRootDrop && !hasFiles) || + this.opts.disabled || // opts.disableLocalFiles should only be taken into account if no plugins // can handle the datatransfer - || (this.opts.disableLocalFiles && (hasFiles || !somePluginCanHandleRootDrop)) - || !this.uppy.getState().allowNewUpload + (this.opts.disableLocalFiles && + (hasFiles || !somePluginCanHandleRootDrop)) || + !this.uppy.getState().allowNewUpload ) { - event.dataTransfer.dropEffect = 'none' // eslint-disable-line no-param-reassign + event.dataTransfer!.dropEffect = 'none' // eslint-disable-line no-param-reassign clearTimeout(this.removeDragOverClassTimeout) return } @@ -617,7 +796,7 @@ export default class Dashboard extends UIPlugin { // Add a small (+) icon on drop // (and prevent browsers from interpreting this as files being _moved_ into the // browser, https://github.com/transloadit/uppy/issues/1978). - event.dataTransfer.dropEffect = 'copy' // eslint-disable-line no-param-reassign + event.dataTransfer!.dropEffect = 'copy' // eslint-disable-line no-param-reassign clearTimeout(this.removeDragOverClassTimeout) this.setPluginState({ isDraggingOver: true }) @@ -625,7 +804,7 @@ export default class Dashboard extends UIPlugin { this.opts.onDragOver?.(event) } - handleDragLeave = (event) => { + private handleDragLeave = (event: DragEvent) => { event.preventDefault() event.stopPropagation() @@ -639,7 +818,7 @@ export default class Dashboard extends UIPlugin { this.opts.onDragLeave?.(event) } - handleDrop = async (event) => { + private handleDrop = async (event: DragEvent) => { event.preventDefault() event.stopPropagation() @@ -651,13 +830,13 @@ export default class Dashboard extends UIPlugin { this.uppy.iteratePlugins((plugin) => { if (plugin.type === 'acquirer') { // Every Plugin with .type acquirer can define handleRootDrop(event) - plugin.handleRootDrop?.(event) + ;(plugin as any).handleRootDrop?.(event) } }) // Add all dropped files let executedDropErrorOnce = false - const logDropError = (error) => { + const logDropError = (error: any) => { this.uppy.log(error, 'error') // In practice all drop errors are most likely the same, @@ -671,7 +850,7 @@ export default class Dashboard extends UIPlugin { this.uppy.log('[Dashboard] Processing dropped files') // Add all dropped files - const files = await getDroppedFiles(event.dataTransfer, { logDropError }) + const files = await getDroppedFiles(event.dataTransfer!, { logDropError }) if (files.length > 0) { this.uppy.log('[Dashboard] Files dropped') this.addFiles(files) @@ -680,7 +859,7 @@ export default class Dashboard extends UIPlugin { this.opts.onDrop?.(event) } - handleRequestThumbnail = (file) => { + private handleRequestThumbnail = (file: UppyFile) => { if (!this.opts.waitForThumbnailsBeforeUpload) { this.uppy.emit('thumbnail:request', file) } @@ -690,15 +869,20 @@ export default class Dashboard extends UIPlugin { * We cancel thumbnail requests when a file item component unmounts to avoid * clogging up the queue when the user scrolls past many elements. */ - handleCancelThumbnail = (file) => { + private handleCancelThumbnail = (file: UppyFile) => { if (!this.opts.waitForThumbnailsBeforeUpload) { this.uppy.emit('thumbnail:cancel', file) } } - handleKeyDownInInline = (event) => { + private handleKeyDownInInline = (event: KeyboardEvent) => { // Trap focus on tab key press. - if (event.keyCode === TAB_KEY) trapFocus.forInline(event, this.getPluginState().activeOverlayType, this.el) + if (event.keyCode === TAB_KEY) + trapFocus.forInline( + event, + this.getPluginState().activeOverlayType, + this.el, + ) } // ___Why do we listen to the 'paste' event on a document instead of onPaste={props.handlePaste} prop, @@ -710,21 +894,21 @@ export default class Dashboard extends UIPlugin { // Because if we click on the 'Drop files here' caption e.g., `document.activeElement` will be 'body'. Which means our // standard determination of whether we're pasting into our Uppy instance won't work. // => Therefore, we need a traditional onPaste={props.handlePaste} handler too. - handlePasteOnBody = (event) => { - const isFocusInOverlay = this.el.contains(document.activeElement) + private handlePasteOnBody = (event: ClipboardEvent) => { + const isFocusInOverlay = this.el!.contains(document.activeElement) if (isFocusInOverlay) { this.handlePaste(event) } } - handleComplete = ({ failed }) => { - if (this.opts.closeAfterFinish && failed.length === 0) { + private handleComplete = ({ failed }: UploadResult) => { + if (this.opts.closeAfterFinish && !failed?.length) { // All uploads are done this.requestCloseModal() } } - handleCancelRestore = () => { + private handleCancelRestore = () => { this.uppy.emit('restore-canceled') } @@ -737,19 +921,23 @@ export default class Dashboard extends UIPlugin { const files = this.uppy.getFiles() if (files.length === 1) { - const thumbnailGenerator = this.uppy.getPlugin(`${this.id}:ThumbnailGenerator`) + const thumbnailGenerator = this.uppy.getPlugin( + `${this.id}:ThumbnailGenerator`, + ) as ThumbnailGenerator | undefined thumbnailGenerator?.setOptions({ thumbnailWidth: LARGE_THUMBNAIL }) const fileForThumbnail = { ...files[0], preview: undefined } - thumbnailGenerator.requestThumbnail(fileForThumbnail).then(() => { - thumbnailGenerator?.setOptions({ thumbnailWidth: this.opts.thumbnailWidth }) + thumbnailGenerator?.requestThumbnail(fileForThumbnail).then(() => { + thumbnailGenerator?.setOptions({ + thumbnailWidth: this.opts.thumbnailWidth, + }) }) } } - #openFileEditorWhenFilesAdded = (files) => { + #openFileEditorWhenFilesAdded = (files: UppyFile[]) => { const firstFile = files[0] - const {metaFields} = this.getPluginState() + const { metaFields } = this.getPluginState() const isMetaEditorEnabled = metaFields && metaFields.length > 0 const isFileEditorEnabled = this.canEditFile(firstFile) @@ -760,14 +948,19 @@ export default class Dashboard extends UIPlugin { } } - initEvents = () => { + initEvents = (): void => { // Modal open button if (this.opts.trigger && !this.opts.inline) { const showModalTrigger = findAllDOMElements(this.opts.trigger) if (showModalTrigger) { - showModalTrigger.forEach(trigger => trigger.addEventListener('click', this.openModal)) + showModalTrigger.forEach((trigger) => + trigger.addEventListener('click', this.openModal), + ) } else { - this.uppy.log('Dashboard modal trigger not found. Make sure `trigger` is set in Dashboard options, unless you are planning to call `dashboard.openModal()` method yourself', 'warning') + this.uppy.log( + 'Dashboard modal trigger not found. Make sure `trigger` is set in Dashboard options, unless you are planning to call `dashboard.openModal()` method yourself', + 'warning', + ) } } @@ -789,7 +982,7 @@ export default class Dashboard extends UIPlugin { document.addEventListener('click', this.recordIfFocusedOnUppyRecently, true) if (this.opts.inline) { - this.el.addEventListener('keydown', this.handleKeyDownInInline) + this.el!.addEventListener('keydown', this.handleKeyDownInInline) } if (this.opts.autoOpenFileEditor) { @@ -797,10 +990,12 @@ export default class Dashboard extends UIPlugin { } } - removeEvents = () => { + removeEvents = (): void => { const showModalTrigger = findAllDOMElements(this.opts.trigger) if (!this.opts.inline && showModalTrigger) { - showModalTrigger.forEach(trigger => trigger.removeEventListener('click', this.openModal)) + showModalTrigger.forEach((trigger) => + trigger.removeEventListener('click', this.openModal), + ) } this.stopListeningToResize() @@ -820,7 +1015,7 @@ export default class Dashboard extends UIPlugin { document.removeEventListener('click', this.recordIfFocusedOnUppyRecently) if (this.opts.inline) { - this.el.removeEventListener('keydown', this.handleKeyDownInInline) + this.el!.removeEventListener('keydown', this.handleKeyDownInInline) } if (this.opts.autoOpenFileEditor) { @@ -828,22 +1023,23 @@ export default class Dashboard extends UIPlugin { } } - superFocusOnEachUpdate = () => { - const isFocusInUppy = this.el.contains(document.activeElement) + private superFocusOnEachUpdate = () => { + const isFocusInUppy = this.el!.contains(document.activeElement) // When focus is lost on the page (== focus is on body for most browsers, or focus is null for IE11) - const isFocusNowhere = document.activeElement === document.body || document.activeElement === null + const isFocusNowhere = + document.activeElement === document.body || + document.activeElement === null const isInformerHidden = this.uppy.getState().info.length === 0 const isModal = !this.opts.inline if ( // If update is connected to showing the Informer - let the screen reader calmly read it. - isInformerHidden - && ( - // If we are in a modal - always superfocus without concern for other elements - // on the page (user is unlikely to want to interact with the rest of the page) - isModal + isInformerHidden && + // If we are in a modal - always superfocus without concern for other elements + // on the page (user is unlikely to want to interact with the rest of the page) + (isModal || // If we are already inside of Uppy, or - || isFocusInUppy + isFocusInUppy || // If we are not focused on anything BUT we have already, at least once, focused on uppy // 1. We focus when isFocusNowhere, because when the element we were focused // on disappears (e.g. an overlay), - focus gets lost. If user is typing @@ -853,8 +1049,7 @@ export default class Dashboard extends UIPlugin { // [Practical check] Without '&& this.ifFocusedOnUppyRecently', in Safari, in inline mode, // when file is uploading, - navigate via tab to the checkbox, // try to press space multiple times. Focus will jump to Uppy. - || (isFocusNowhere && this.ifFocusedOnUppyRecently) - ) + (isFocusNowhere && this.ifFocusedOnUppyRecently)) ) { this.superFocus(this.el, this.getPluginState().activeOverlayType) } else { @@ -862,7 +1057,7 @@ export default class Dashboard extends UIPlugin { } } - afterUpdate = () => { + readonly afterUpdate = (): void => { if (this.opts.disabled && !this.dashboardIsDisabled) { this.disableInteractiveElements(true) return @@ -875,13 +1070,13 @@ export default class Dashboard extends UIPlugin { this.superFocusOnEachUpdate() } - saveFileCard = (meta, fileID) => { + saveFileCard = (meta: M, fileID: string): void => { this.uppy.setFileMeta(fileID, meta) this.toggleFileCard(false, fileID) } - #attachRenderFunctionToTarget = (target) => { - const plugin = this.uppy.getPlugin(target.id) + #attachRenderFunctionToTarget = (target: Target): TargetWithRender => { + const plugin = this.uppy.getPlugin(target.id) as any return { ...target, icon: plugin.icon || this.opts.defaultPickerIcon, @@ -889,8 +1084,10 @@ export default class Dashboard extends UIPlugin { } } - #isTargetSupported = (target) => { - const plugin = this.uppy.getPlugin(target.id) + #isTargetSupported = (target: Target) => { + const plugin = this.uppy.getPlugin(target.id) as any as { + isSupported?: () => boolean + } // If the plugin does not provide a `supported` check, assume the plugin works everywhere. if (typeof plugin.isSupported !== 'function') { return true @@ -898,25 +1095,28 @@ export default class Dashboard extends UIPlugin { return plugin.isSupported() } - #getAcquirers = memoize((targets) => { + #getAcquirers = memoize((targets: Target[]) => { return targets - .filter(target => target.type === 'acquirer' && this.#isTargetSupported(target)) + .filter( + (target) => + target.type === 'acquirer' && this.#isTargetSupported(target), + ) .map(this.#attachRenderFunctionToTarget) }) - #getProgressIndicators = memoize((targets) => { + #getProgressIndicators = memoize((targets: Target[]) => { return targets - .filter(target => target.type === 'progressindicator') + .filter((target) => target.type === 'progressindicator') .map(this.#attachRenderFunctionToTarget) }) - #getEditors = memoize((targets) => { + #getEditors = memoize((targets: Target[]) => { return targets - .filter(target => target.type === 'editor') + .filter((target) => target.type === 'editor') .map(this.#attachRenderFunctionToTarget) }) - render = (state) => { + render = (state: State): JSX.Element => { const pluginState = this.getPluginState() const { files, capabilities, allowNewUpload } = state const { @@ -945,10 +1145,15 @@ export default class Dashboard extends UIPlugin { theme = this.opts.theme } - if (['files', 'folders', 'both'].indexOf(this.opts.fileManagerSelectionType) < 0) { + if ( + ['files', 'folders', 'both'].indexOf(this.opts.fileManagerSelectionType) < + 0 + ) { this.opts.fileManagerSelectionType = 'files' // eslint-disable-next-line no-console - console.warn(`Unsupported option for "fileManagerSelectionType". Using default of "${this.opts.fileManagerSelectionType}".`) + console.warn( + `Unsupported option for "fileManagerSelectionType". Using default of "${this.opts.fileManagerSelectionType}".`, + ) } return DashboardUI({ @@ -1047,9 +1252,12 @@ export default class Dashboard extends UIPlugin { plugins.forEach((pluginID) => { const plugin = this.uppy.getPlugin(pluginID) if (plugin) { - plugin.mount(this, plugin) + ;(plugin as any).mount(this, plugin) } else { - this.uppy.log(`[Uppy] Dashboard could not find plugin '${pluginID}', make sure to uppy.use() the plugins you are specifying`, 'warning') + this.uppy.log( + `[Uppy] Dashboard could not find plugin '${pluginID}', make sure to uppy.use() the plugins you are specifying`, + 'warning', + ) } }) } @@ -1058,28 +1266,28 @@ export default class Dashboard extends UIPlugin { this.uppy.iteratePlugins(this.#addSupportedPluginIfNoTarget) } - #addSupportedPluginIfNoTarget = (plugin) => { + #addSupportedPluginIfNoTarget = (plugin?: UnknownPlugin) => { // Only these types belong on the Dashboard, // we wouldn’t want to try and mount Compressor or Tus, for example. const typesAllowed = ['acquirer', 'editor'] if (plugin && !plugin.opts?.target && typesAllowed.includes(plugin.type)) { const pluginAlreadyAdded = this.getPluginState().targets.some( - installedPlugin => plugin.id === installedPlugin.id, + (installedPlugin) => plugin.id === installedPlugin.id, ) if (!pluginAlreadyAdded) { - plugin.mount(this, plugin) + ;(plugin as any).mount(this, plugin) } } } - install = () => { + install = (): void => { // Set default state for Dashboard this.setPluginState({ isHidden: true, fileCardFor: null, activeOverlayType: null, showAddFilesPanel: false, - activePickerPanel: false, + activePickerPanel: undefined, showFileEditor: false, metaFields: this.opts.metaFields, targets: [], @@ -1090,12 +1298,20 @@ export default class Dashboard extends UIPlugin { const { inline, closeAfterFinish } = this.opts if (inline && closeAfterFinish) { - throw new Error('[Dashboard] `closeAfterFinish: true` cannot be used on an inline Dashboard, because an inline Dashboard cannot be closed at all. Either set `inline: false`, or disable the `closeAfterFinish` option.') + throw new Error( + '[Dashboard] `closeAfterFinish: true` cannot be used on an inline Dashboard, because an inline Dashboard cannot be closed at all. Either set `inline: false`, or disable the `closeAfterFinish` option.', + ) } const { allowMultipleUploads, allowMultipleUploadBatches } = this.uppy.opts - if ((allowMultipleUploads || allowMultipleUploadBatches) && closeAfterFinish) { - this.uppy.log('[Dashboard] When using `closeAfterFinish`, we recommended setting the `allowMultipleUploadBatches` option to `false` in the Uppy constructor. See https://uppy.io/docs/uppy/#allowMultipleUploads-true', 'warning') + if ( + (allowMultipleUploads || allowMultipleUploadBatches) && + closeAfterFinish + ) { + this.uppy.log( + '[Dashboard] When using `closeAfterFinish`, we recommended setting the `allowMultipleUploadBatches` option to `false` in the Uppy constructor. See https://uppy.io/docs/uppy/#allowMultipleUploads-true', + 'warning', + ) } const { target } = this.opts @@ -1139,16 +1355,20 @@ export default class Dashboard extends UIPlugin { } // Dark Mode / theme - this.darkModeMediaQuery = (typeof window !== 'undefined' && window.matchMedia) - ? window.matchMedia('(prefers-color-scheme: dark)') + this.darkModeMediaQuery = + typeof window !== 'undefined' && window.matchMedia ? + window.matchMedia('(prefers-color-scheme: dark)') : null - const isDarkModeOnFromTheStart = this.darkModeMediaQuery ? this.darkModeMediaQuery.matches : false - this.uppy.log(`[Dashboard] Dark mode is ${isDarkModeOnFromTheStart ? 'on' : 'off'}`) + const isDarkModeOnFromTheStart = + this.darkModeMediaQuery ? this.darkModeMediaQuery.matches : false + this.uppy.log( + `[Dashboard] Dark mode is ${isDarkModeOnFromTheStart ? 'on' : 'off'}`, + ) this.setDarkModeCapability(isDarkModeOnFromTheStart) if (this.opts.theme === 'auto') { - this.darkModeMediaQuery.addListener(this.handleSystemDarkModeChange) + this.darkModeMediaQuery?.addListener(this.handleSystemDarkModeChange) } this.#addSpecifiedPluginsFromOptions() @@ -1156,7 +1376,7 @@ export default class Dashboard extends UIPlugin { this.initEvents() } - uninstall = () => { + uninstall = (): void => { if (!this.opts.disableInformer) { const informer = this.uppy.getPlugin(`${this.id}:Informer`) // Checking if this plugin exists, in case it was removed by uppy-core @@ -1177,11 +1397,11 @@ export default class Dashboard extends UIPlugin { const plugins = this.opts.plugins || [] plugins.forEach((pluginID) => { const plugin = this.uppy.getPlugin(pluginID) - if (plugin) plugin.unmount() + if (plugin) (plugin as any).unmount() }) if (this.opts.theme === 'auto') { - this.darkModeMediaQuery.removeListener(this.handleSystemDarkModeChange) + this.darkModeMediaQuery?.removeListener(this.handleSystemDarkModeChange) } if (this.opts.disablePageScrollWhenModalOpen) { diff --git a/packages/@uppy/dashboard/src/components/AddFiles.jsx b/packages/@uppy/dashboard/src/components/AddFiles.jsx deleted file mode 100644 index c9a547a2dc..0000000000 --- a/packages/@uppy/dashboard/src/components/AddFiles.jsx +++ /dev/null @@ -1,325 +0,0 @@ -import { h, Component, Fragment } from 'preact' - -class AddFiles extends Component { - triggerFileInputClick = () => { - this.fileInput.click() - } - - triggerFolderInputClick = () => { - this.folderInput.click() - } - - triggerVideoCameraInputClick = () => { - this.mobileVideoFileInput.click() - } - - triggerPhotoCameraInputClick = () => { - this.mobilePhotoFileInput.click() - } - - onFileInputChange = (event) => { - this.props.handleInputChange(event) - - // We clear the input after a file is selected, because otherwise - // change event is not fired in Chrome and Safari when a file - // with the same name is selected. - // ___Why not use value="" on instead? - // Because if we use that method of clearing the input, - // Chrome will not trigger change if we drop the same file twice (Issue #768). - event.target.value = null // eslint-disable-line no-param-reassign - } - - renderHiddenInput = (isFolder, refCallback) => { - return ( - - ) - } - - renderHiddenCameraInput = (type, nativeCameraFacingMode, refCallback) => { - const typeToAccept = { photo: 'image/*', video: 'video/*' } - const accept = typeToAccept[type] - - return ( - - ) - } - - renderMyDeviceAcquirer = () => { - return ( -
- -
- ) - } - - renderPhotoCamera = () => { - return ( -
- -
- ) - } - - renderVideoCamera = () => { - return ( -
- -
- ) - } - - renderBrowseButton = (text, onClickFn) => { - const numberOfAcquirers = this.props.acquirers.length - return ( - - ) - } - - renderDropPasteBrowseTagline = (numberOfAcquirers) => { - const browseFiles = this.renderBrowseButton(this.props.i18n('browseFiles'), this.triggerFileInputClick) - const browseFolders = this.renderBrowseButton(this.props.i18n('browseFolders'), this.triggerFolderInputClick) - - // in order to keep the i18n CamelCase and options lower (as are defaults) we will want to transform a lower - // to Camel - const lowerFMSelectionType = this.props.fileManagerSelectionType - const camelFMSelectionType = lowerFMSelectionType.charAt(0).toUpperCase() + lowerFMSelectionType.slice(1) - - return ( -
- { - // eslint-disable-next-line no-nested-ternary - this.props.disableLocalFiles ? this.props.i18n('importFiles') - : numberOfAcquirers > 0 - ? this.props.i18nArray(`dropPasteImport${camelFMSelectionType}`, { browseFiles, browseFolders, browse: browseFiles }) - : this.props.i18nArray(`dropPaste${camelFMSelectionType}`, { browseFiles, browseFolders, browse: browseFiles }) - } -
- ) - } - - [Symbol.for('uppy test: disable unused locale key warning')] () { - // Those are actually used in `renderDropPasteBrowseTagline` method. - this.props.i18nArray('dropPasteBoth') - this.props.i18nArray('dropPasteFiles') - this.props.i18nArray('dropPasteFolders') - this.props.i18nArray('dropPasteImportBoth') - this.props.i18nArray('dropPasteImportFiles') - this.props.i18nArray('dropPasteImportFolders') - } - - renderAcquirer = (acquirer) => { - return ( -
- -
- ) - } - - renderAcquirers = (acquirers) => { - // Group last two buttons, so we don’t end up with - // just one button on a new line - const acquirersWithoutLastTwo = [...acquirers] - const lastTwoAcquirers = acquirersWithoutLastTwo.splice(acquirers.length - 2, acquirers.length) - - return ( - <> - {acquirersWithoutLastTwo.map((acquirer) => this.renderAcquirer(acquirer))} - - {lastTwoAcquirers.map((acquirer) => this.renderAcquirer(acquirer))} - - - ) - } - - renderSourcesList = (acquirers, disableLocalFiles) => { - const { showNativePhotoCameraButton, showNativeVideoCameraButton } = this.props - - let list = [] - - const myDeviceKey = 'myDevice' - - if (!disableLocalFiles) list.push({ key: myDeviceKey, elements: this.renderMyDeviceAcquirer() }) - if (showNativePhotoCameraButton) list.push({ key: 'nativePhotoCameraButton', elements: this.renderPhotoCamera() }) - if (showNativeVideoCameraButton) list.push({ key: 'nativePhotoCameraButton', elements: this.renderVideoCamera() }) - list.push(...acquirers.map((acquirer) => ({ key: acquirer.id, elements: this.renderAcquirer(acquirer) }))) - - // doesn't make sense to show only a lonely "My Device" - const hasOnlyMyDevice = list.length === 1 && list[0].key === myDeviceKey - if (hasOnlyMyDevice) list = [] - - // Group last two buttons, so we don’t end up with - // just one button on a new line - const listWithoutLastTwo = [...list] - const lastTwo = listWithoutLastTwo.splice(list.length - 2, list.length) - - const renderList = (l) => l.map(({ key, elements }) => {elements}) - - return ( - <> - {this.renderDropPasteBrowseTagline(list.length)} - -
- {renderList(listWithoutLastTwo)} - - - {renderList(lastTwo)} - -
- - ) - } - - renderPoweredByUppy () { - const { i18nArray } = this.props - - const uppyBranding = ( - - - Uppy - - ) - - const linkText = i18nArray('poweredBy', { uppy: uppyBranding }) - - return ( - - {linkText} - - ) - } - - render () { - const { - showNativePhotoCameraButton, - showNativeVideoCameraButton, - nativeCameraFacingMode, - } = this.props - - return ( -
- {this.renderHiddenInput(false, (ref) => { this.fileInput = ref })} - {this.renderHiddenInput(true, (ref) => { this.folderInput = ref })} - {showNativePhotoCameraButton && this.renderHiddenCameraInput('photo', nativeCameraFacingMode, (ref) => { this.mobilePhotoFileInput = ref })} - {showNativeVideoCameraButton && this.renderHiddenCameraInput('video', nativeCameraFacingMode, (ref) => { this.mobileVideoFileInput = ref })} - {this.renderSourcesList(this.props.acquirers, this.props.disableLocalFiles)} -
- {this.props.note &&
{this.props.note}
} - {this.props.proudlyDisplayPoweredByUppy && this.renderPoweredByUppy(this.props)} -
-
- ) - } -} - -export default AddFiles diff --git a/packages/@uppy/dashboard/src/components/AddFiles.tsx b/packages/@uppy/dashboard/src/components/AddFiles.tsx new file mode 100644 index 0000000000..da2d93d5e9 --- /dev/null +++ b/packages/@uppy/dashboard/src/components/AddFiles.tsx @@ -0,0 +1,450 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck Typing this file requires more work, skipping it to unblock the rest of the transition. + +/* eslint-disable react/destructuring-assignment */ +import { h, Component, Fragment, type ComponentChild } from 'preact' + +type $TSFixMe = any + +class AddFiles extends Component { + fileInput: $TSFixMe + + folderInput: $TSFixMe + + mobilePhotoFileInput: $TSFixMe + + mobileVideoFileInput: $TSFixMe + + private triggerFileInputClick = () => { + this.fileInput.click() + } + + private triggerFolderInputClick = () => { + this.folderInput.click() + } + + private triggerVideoCameraInputClick = () => { + this.mobileVideoFileInput.click() + } + + private triggerPhotoCameraInputClick = () => { + this.mobilePhotoFileInput.click() + } + + private onFileInputChange = (event: $TSFixMe) => { + this.props.handleInputChange(event) + + // We clear the input after a file is selected, because otherwise + // change event is not fired in Chrome and Safari when a file + // with the same name is selected. + // ___Why not use value="" on instead? + // Because if we use that method of clearing the input, + // Chrome will not trigger change if we drop the same file twice (Issue #768). + event.target.value = null // eslint-disable-line no-param-reassign + } + + private renderHiddenInput = (isFolder: $TSFixMe, refCallback: $TSFixMe) => { + return ( + + ) + } + + private renderHiddenCameraInput = ( + type: $TSFixMe, + nativeCameraFacingMode: $TSFixMe, + refCallback: $TSFixMe, + ) => { + const typeToAccept = { photo: 'image/*', video: 'video/*' } + const accept = typeToAccept[type] + + return ( + + ) + } + + private renderMyDeviceAcquirer = () => { + return ( +
+ +
+ ) + } + + private renderPhotoCamera = () => { + return ( +
+ +
+ ) + } + + private renderVideoCamera = () => { + return ( +
+ +
+ ) + } + + private renderBrowseButton = (text: $TSFixMe, onClickFn: $TSFixMe) => { + const numberOfAcquirers = this.props.acquirers.length + return ( + + ) + } + + private renderDropPasteBrowseTagline = (numberOfAcquirers: $TSFixMe) => { + const browseFiles = this.renderBrowseButton( + this.props.i18n('browseFiles'), + this.triggerFileInputClick, + ) + const browseFolders = this.renderBrowseButton( + this.props.i18n('browseFolders'), + this.triggerFolderInputClick, + ) + + // in order to keep the i18n CamelCase and options lower (as are defaults) we will want to transform a lower + // to Camel + const lowerFMSelectionType = this.props.fileManagerSelectionType + const camelFMSelectionType = + lowerFMSelectionType.charAt(0).toUpperCase() + + lowerFMSelectionType.slice(1) + + return ( +
+ { + // eslint-disable-next-line no-nested-ternary + this.props.disableLocalFiles ? + this.props.i18n('importFiles') + : numberOfAcquirers > 0 ? + this.props.i18nArray(`dropPasteImport${camelFMSelectionType}`, { + browseFiles, + browseFolders, + browse: browseFiles, + }) + : this.props.i18nArray(`dropPaste${camelFMSelectionType}`, { + browseFiles, + browseFolders, + browse: browseFiles, + }) + + } +
+ ) + } + + private [Symbol.for('uppy test: disable unused locale key warning')]() { + // Those are actually used in `renderDropPasteBrowseTagline` method. + this.props.i18nArray('dropPasteBoth') + this.props.i18nArray('dropPasteFiles') + this.props.i18nArray('dropPasteFolders') + this.props.i18nArray('dropPasteImportBoth') + this.props.i18nArray('dropPasteImportFiles') + this.props.i18nArray('dropPasteImportFolders') + } + + private renderAcquirer = (acquirer: $TSFixMe) => { + return ( +
+ +
+ ) + } + + private renderAcquirers = (acquirers: $TSFixMe) => { + // Group last two buttons, so we don’t end up with + // just one button on a new line + const acquirersWithoutLastTwo = [...acquirers] + const lastTwoAcquirers = acquirersWithoutLastTwo.splice( + acquirers.length - 2, + acquirers.length, + ) + + return ( + <> + {acquirersWithoutLastTwo.map((acquirer) => + this.renderAcquirer(acquirer), + )} + + {lastTwoAcquirers.map((acquirer) => this.renderAcquirer(acquirer))} + + + ) + } + + private renderSourcesList = ( + acquirers: $TSFixMe, + disableLocalFiles: $TSFixMe, + ) => { + const { showNativePhotoCameraButton, showNativeVideoCameraButton } = + this.props + + let list = [] + + const myDeviceKey = 'myDevice' + + if (!disableLocalFiles) + list.push({ key: myDeviceKey, elements: this.renderMyDeviceAcquirer() }) + if (showNativePhotoCameraButton) + list.push({ + key: 'nativePhotoCameraButton', + elements: this.renderPhotoCamera(), + }) + if (showNativeVideoCameraButton) + list.push({ + key: 'nativePhotoCameraButton', + elements: this.renderVideoCamera(), + }) + list.push( + ...acquirers.map((acquirer: $TSFixMe) => ({ + key: acquirer.id, + elements: this.renderAcquirer(acquirer), + })), + ) + + // doesn't make sense to show only a lonely "My Device" + const hasOnlyMyDevice = list.length === 1 && list[0].key === myDeviceKey + if (hasOnlyMyDevice) list = [] + + // Group last two buttons, so we don’t end up with + // just one button on a new line + const listWithoutLastTwo = [...list] + const lastTwo = listWithoutLastTwo.splice(list.length - 2, list.length) + + const renderList = (l: $TSFixMe) => + l.map(({ key, elements }: $TSFixMe) => ( + {elements} + )) + + return ( + <> + {this.renderDropPasteBrowseTagline(list.length)} + +
+ {renderList(listWithoutLastTwo)} + + + {renderList(lastTwo)} + +
+ + ) + } + + private renderPoweredByUppy() { + const { i18nArray } = this.props as $TSFixMe + + const uppyBranding = ( + + + Uppy + + ) + + const linkText = i18nArray('poweredBy', { uppy: uppyBranding }) + + return ( + + {linkText} + + ) + } + + render(): ComponentChild { + const { + showNativePhotoCameraButton, + showNativeVideoCameraButton, + nativeCameraFacingMode, + } = this.props + + return ( +
+ {this.renderHiddenInput(false, (ref: $TSFixMe) => { + this.fileInput = ref + })} + {this.renderHiddenInput(true, (ref: $TSFixMe) => { + this.folderInput = ref + })} + {showNativePhotoCameraButton && + this.renderHiddenCameraInput( + 'photo', + nativeCameraFacingMode, + (ref: $TSFixMe) => { + this.mobilePhotoFileInput = ref + }, + )} + {showNativeVideoCameraButton && + this.renderHiddenCameraInput( + 'video', + nativeCameraFacingMode, + (ref: $TSFixMe) => { + this.mobileVideoFileInput = ref + }, + )} + {this.renderSourcesList( + this.props.acquirers, + this.props.disableLocalFiles, + )} +
+ {this.props.note && ( +
{this.props.note}
+ )} + {this.props.proudlyDisplayPoweredByUppy && + this.renderPoweredByUppy(this.props)} +
+
+ ) + } +} + +export default AddFiles diff --git a/packages/@uppy/dashboard/src/components/AddFilesPanel.jsx b/packages/@uppy/dashboard/src/components/AddFilesPanel.tsx similarity index 71% rename from packages/@uppy/dashboard/src/components/AddFilesPanel.jsx rename to packages/@uppy/dashboard/src/components/AddFilesPanel.tsx index 1dbf032754..f0faea74af 100644 --- a/packages/@uppy/dashboard/src/components/AddFilesPanel.jsx +++ b/packages/@uppy/dashboard/src/components/AddFilesPanel.tsx @@ -1,8 +1,11 @@ +/* eslint-disable react/destructuring-assignment */ import { h } from 'preact' import classNames from 'classnames' -import AddFiles from './AddFiles.jsx' +import AddFiles from './AddFiles.tsx' -const AddFilesPanel = (props) => { +type $TSFixMe = any + +const AddFilesPanel = (props: $TSFixMe): $TSFixMe => { return (
{ aria-hidden={!props.showAddFilesPanel} >
-
+
{props.i18n('addingMoreFiles')}
- ) : null} + : null}
@@ -126,9 +140,19 @@ export default function Dashboard (props) { {numberOfFilesForRecovery && (
-
)} - {showFileList ? ( - - ) : ( - // eslint-disable-next-line react/jsx-props-no-spreading - - )} + { + showFileList ? + + // eslint-disable-next-line react/jsx-props-no-spreading + : + } {/* eslint-disable-next-line react/jsx-props-no-spreading */} - {props.showAddFilesPanel ? : null} + {props.showAddFilesPanel ? + + : null} {/* eslint-disable-next-line react/jsx-props-no-spreading */} - {props.fileCardFor ? : null} + {props.fileCardFor ? + + : null} {/* eslint-disable-next-line react/jsx-props-no-spreading */} - {props.activePickerPanel ? : null} + {props.activePickerPanel ? + + : null} {/* eslint-disable-next-line react/jsx-props-no-spreading */} - {props.showFileEditor ? : null} + {props.showFileEditor ? + + : null}
- {props.progressindicators.map((target) => { + {props.progressindicators.map((target: $TSFixMe) => { return props.uppy.getPlugin(target.id).render(props.state) })}
diff --git a/packages/@uppy/dashboard/src/components/EditorPanel.jsx b/packages/@uppy/dashboard/src/components/EditorPanel.tsx similarity index 70% rename from packages/@uppy/dashboard/src/components/EditorPanel.jsx rename to packages/@uppy/dashboard/src/components/EditorPanel.tsx index dae46e19e8..59c3f1ffdc 100644 --- a/packages/@uppy/dashboard/src/components/EditorPanel.jsx +++ b/packages/@uppy/dashboard/src/components/EditorPanel.tsx @@ -1,7 +1,10 @@ +/* eslint-disable react/destructuring-assignment */ import { h } from 'preact' import classNames from 'classnames' -function EditorPanel (props) { +type $TSFixMe = any + +function EditorPanel(props: $TSFixMe): JSX.Element { const file = props.files[props.fileCardFor] const handleCancel = () => { @@ -17,9 +20,17 @@ function EditorPanel (props) { id="uppy-DashboardContent-panel--editor" >
-
+
{props.i18nArray('editing', { - file: {file.meta ? file.meta.name : file.name}, + file: ( + + {file.meta ? file.meta.name : file.name} + + ), })}
- {props.editors.map((target) => { + {props.editors.map((target: $TSFixMe) => { return props.uppy.getPlugin(target.id).render(props.state) })}
diff --git a/packages/@uppy/dashboard/src/components/FileCard/RenderMetaFields.jsx b/packages/@uppy/dashboard/src/components/FileCard/RenderMetaFields.jsx deleted file mode 100644 index 97984e5d8a..0000000000 --- a/packages/@uppy/dashboard/src/components/FileCard/RenderMetaFields.jsx +++ /dev/null @@ -1,46 +0,0 @@ -import { h } from 'preact' - -export default function RenderMetaFields (props) { - const { - computedMetaFields, - requiredMetaFields, - updateMeta, - form, - formState, - } = props - - const fieldCSSClasses = { - text: 'uppy-u-reset uppy-c-textInput uppy-Dashboard-FileCard-input', - } - - return computedMetaFields.map((field) => { - const id = `uppy-Dashboard-FileCard-input-${field.id}` - const required = requiredMetaFields.includes(field.id) - return ( -
- - {field.render !== undefined - ? field.render({ - value: formState[field.id], - onChange: (newVal) => updateMeta(newVal, field.id), - fieldCSSClasses, - required, - form: form.id, - }, h) - : ( - updateMeta(ev.target.value, field.id)} - data-uppy-super-focusable - /> - )} -
- ) - }) -} diff --git a/packages/@uppy/dashboard/src/components/FileCard/RenderMetaFields.tsx b/packages/@uppy/dashboard/src/components/FileCard/RenderMetaFields.tsx new file mode 100644 index 0000000000..0c8f942fa9 --- /dev/null +++ b/packages/@uppy/dashboard/src/components/FileCard/RenderMetaFields.tsx @@ -0,0 +1,54 @@ +import { h } from 'preact' + +type $TSFixMe = any + +export default function RenderMetaFields(props: $TSFixMe): JSX.Element { + const { + computedMetaFields, + requiredMetaFields, + updateMeta, + form, + formState, + } = props + + const fieldCSSClasses = { + text: 'uppy-u-reset uppy-c-textInput uppy-Dashboard-FileCard-input', + } + + return computedMetaFields.map((field: $TSFixMe) => { + const id = `uppy-Dashboard-FileCard-input-${field.id}` + const required = requiredMetaFields.includes(field.id) + return ( +
+ + {field.render !== undefined ? + field.render( + { + value: formState[field.id], + onChange: (newVal: $TSFixMe) => updateMeta(newVal, field.id), + fieldCSSClasses, + required, + form: form.id, + }, + h, + ) + : + updateMeta((ev.target as HTMLInputElement).value, field.id) + } + data-uppy-super-focusable + /> + } +
+ ) + }) +} diff --git a/packages/@uppy/dashboard/src/components/FileCard/index.jsx b/packages/@uppy/dashboard/src/components/FileCard/index.tsx similarity index 75% rename from packages/@uppy/dashboard/src/components/FileCard/index.jsx rename to packages/@uppy/dashboard/src/components/FileCard/index.tsx index e334f50b41..d7d8c59bbd 100644 --- a/packages/@uppy/dashboard/src/components/FileCard/index.jsx +++ b/packages/@uppy/dashboard/src/components/FileCard/index.tsx @@ -2,12 +2,14 @@ import { h } from 'preact' import { useEffect, useState, useCallback } from 'preact/hooks' import classNames from 'classnames' import { nanoid } from 'nanoid/non-secure' -import getFileTypeIcon from '../../utils/getFileTypeIcon.jsx' -import ignoreEvent from '../../utils/ignoreEvent.js' -import FilePreview from '../FilePreview.jsx' -import RenderMetaFields from './RenderMetaFields.jsx' +import getFileTypeIcon from '../../utils/getFileTypeIcon.tsx' +import ignoreEvent from '../../utils/ignoreEvent.ts' +import FilePreview from '../FilePreview.tsx' +import RenderMetaFields from './RenderMetaFields.tsx' -export default function FileCard (props) { +type $TSFixMe = any + +export default function FileCard(props: $TSFixMe): JSX.Element { const { files, fileCardFor, @@ -23,8 +25,8 @@ export default function FileCard (props) { } = props const getMetaFields = () => { - return typeof metaFields === 'function' - ? metaFields(files[fileCardFor]) + return typeof metaFields === 'function' ? + metaFields(files[fileCardFor]) : metaFields } @@ -32,19 +34,22 @@ export default function FileCard (props) { const computedMetaFields = getMetaFields() ?? [] const showEditButton = canEditFile(file) - const storedMetaData = {} - computedMetaFields.forEach((field) => { + const storedMetaData: Record = {} + computedMetaFields.forEach((field: $TSFixMe) => { storedMetaData[field.id] = file.meta[field.id] ?? '' }) const [formState, setFormState] = useState(storedMetaData) - const handleSave = useCallback((ev) => { - ev.preventDefault() - saveFileCard(formState, fileCardFor) - }, [saveFileCard, formState, fileCardFor]) + const handleSave = useCallback( + (ev: $TSFixMe) => { + ev.preventDefault() + saveFileCard(formState, fileCardFor) + }, + [saveFileCard, formState, fileCardFor], + ) - const updateMeta = (newVal, name) => { + const updateMeta = (newVal: $TSFixMe, name: $TSFixMe) => { setFormState({ ...formState, [name]: newVal, @@ -81,9 +86,17 @@ export default function FileCard (props) { onPaste={ignoreEvent} >
-
+
{i18nArray('editing', { - file: {file.meta ? file.meta.name : file.name}, + file: ( + + {file.meta ? file.meta.name : file.name} + + ), })}
- )} + )}
diff --git a/packages/@uppy/dashboard/src/components/FileItem/Buttons/index.jsx b/packages/@uppy/dashboard/src/components/FileItem/Buttons/index.tsx similarity index 63% rename from packages/@uppy/dashboard/src/components/FileItem/Buttons/index.jsx rename to packages/@uppy/dashboard/src/components/FileItem/Buttons/index.tsx index 0f23814d8e..8d6f288e6c 100644 --- a/packages/@uppy/dashboard/src/components/FileItem/Buttons/index.jsx +++ b/packages/@uppy/dashboard/src/components/FileItem/Buttons/index.tsx @@ -1,17 +1,19 @@ -import { h } from 'preact' -import copyToClipboard from '../../../utils/copyToClipboard.js' +import { h, type ComponentChild } from 'preact' +import copyToClipboard from '../../../utils/copyToClipboard.ts' -function EditButton ({ +type $TSFixMe = any + +function EditButton({ file, uploadInProgressOrComplete, metaFields, canEditFile, i18n, onClick, -}) { +}: $TSFixMe) { if ( - (!uploadInProgressOrComplete && metaFields && metaFields.length > 0) - || (!uploadInProgressOrComplete && canEditFile(file)) + (!uploadInProgressOrComplete && metaFields && metaFields.length > 0) || + (!uploadInProgressOrComplete && canEditFile(file)) ) { return ( @@ -34,7 +49,7 @@ function EditButton ({ return null } -function RemoveButton ({ i18n, onClick, file }) { +function RemoveButton({ i18n, onClick, file }: $TSFixMe) { return ( ) } -const copyLinkToClipboard = (event, props) => { - copyToClipboard(props.file.uploadURL, props.i18n('copyLinkToClipboardFallback')) +const copyLinkToClipboard = (event: $TSFixMe, props: $TSFixMe) => { + copyToClipboard( + props.file.uploadURL, + props.i18n('copyLinkToClipboardFallback'), + ) .then(() => { props.uppy.log('Link copied to clipboard.') props.uppy.info(props.i18n('copyLinkToClipboardSuccess'), 'info', 3000) @@ -62,7 +90,7 @@ const copyLinkToClipboard = (event, props) => { .then(() => event.target.focus({ preventScroll: true })) } -function CopyLinkButton (props) { +function CopyLinkButton(props: $TSFixMe) { const { i18n } = props return ( @@ -73,14 +101,21 @@ function CopyLinkButton (props) { title={i18n('copyLink')} onClick={(event) => copyLinkToClipboard(event, props)} > -
) } diff --git a/packages/@uppy/dashboard/src/components/FileItem/FileInfo/index.jsx b/packages/@uppy/dashboard/src/components/FileItem/FileInfo/index.tsx similarity index 70% rename from packages/@uppy/dashboard/src/components/FileItem/FileInfo/index.jsx rename to packages/@uppy/dashboard/src/components/FileItem/FileInfo/index.tsx index 3d32b3c896..f721b59572 100644 --- a/packages/@uppy/dashboard/src/components/FileItem/FileInfo/index.jsx +++ b/packages/@uppy/dashboard/src/components/FileItem/FileInfo/index.tsx @@ -1,12 +1,15 @@ -import { h, Fragment } from 'preact' +/* eslint-disable react/destructuring-assignment */ +import { h, Fragment, type ComponentChild } from 'preact' import prettierBytes from '@transloadit/prettier-bytes' import truncateString from '@uppy/utils/lib/truncateString' -import MetaErrorMessage from '../MetaErrorMessage.jsx' +import MetaErrorMessage from '../MetaErrorMessage.tsx' -const renderFileName = (props) => { +type $TSFixMe = any + +const renderFileName = (props: $TSFixMe) => { const { author, name } = props.file.meta - function getMaxNameLength () { + function getMaxNameLength() { if (props.isSingleFile && props.containerHeight >= 350) { return 90 } @@ -29,7 +32,7 @@ const renderFileName = (props) => { ) } -const renderAuthor = (props) => { +const renderAuthor = (props: $TSFixMe) => { const { author } = props.file.meta const providerName = props.file.remote?.providerName const dot = `\u00B7` @@ -47,37 +50,39 @@ const renderAuthor = (props) => { > {truncateString(author.name, 13)} - {providerName ? ( + {providerName ? <> {` ${dot} `} {providerName} {` ${dot} `} - ) : null} + : null}
) } -const renderFileSize = (props) => props.file.size && ( -
- {prettierBytes(props.file.size)} -
-) +const renderFileSize = (props: $TSFixMe) => + props.file.size && ( +
+ {prettierBytes(props.file.size)} +
+ ) -const ReSelectButton = (props) => props.file.isGhost && ( - - {' \u2022 '} - - -) +const ReSelectButton = (props: $TSFixMe) => + props.file.isGhost && ( + + {' \u2022 '} + + + ) -const ErrorButton = ({ file, onClick }) => { +const ErrorButton = ({ file, onClick }: $TSFixMe) => { if (file.error) { return (
@@ -125,7 +133,14 @@ export default function FileProgress (props) { return ( // eslint-disable-next-line react/jsx-props-no-spreading -
{i18n('missingRequiredMetaFields', { smart_count: missingRequiredMetaFields.length, fields: metaFieldsString, - })} - {' '} + })}{' '} - ) : ( -
- )} + :
} -
+
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
- {allowNewUpload ? ( + {allowNewUpload ? - ) : ( -
- )} + :
}
) } diff --git a/packages/@uppy/dashboard/src/components/Slide.jsx b/packages/@uppy/dashboard/src/components/Slide.jsx deleted file mode 100644 index b28065a255..0000000000 --- a/packages/@uppy/dashboard/src/components/Slide.jsx +++ /dev/null @@ -1,86 +0,0 @@ -import { cloneElement, toChildArray } from 'preact' -import { useEffect, useState, useRef } from 'preact/hooks' -import classNames from 'classnames' - -const transitionName = 'uppy-transition-slideDownUp' -const duration = 250 - -/** - * Vertical slide transition. - * - * This can take a _single_ child component, which _must_ accept a `className` prop. - * - * Currently this is specific to the `uppy-transition-slideDownUp` transition, - * but it should be simple to extend this for any type of single-element - * transition by setting the CSS name and duration as props. - */ -function Slide ({ children }) { - const [cachedChildren, setCachedChildren] = useState(null); - const [className, setClassName] = useState(''); - const enterTimeoutRef = useRef(); - const leaveTimeoutRef = useRef(); - const animationFrameRef = useRef(); - - const handleEnterTransition = () => { - setClassName(`${transitionName}-enter`); - - cancelAnimationFrame(animationFrameRef.current); - clearTimeout(leaveTimeoutRef.current); - leaveTimeoutRef.current = undefined; - - animationFrameRef.current = requestAnimationFrame(() => { - setClassName(`${transitionName}-enter ${transitionName}-enter-active`); - - enterTimeoutRef.current = setTimeout(() => { - setClassName(''); - }, duration); - }); - }; - - const handleLeaveTransition = () => { - setClassName(`${transitionName}-leave`); - - cancelAnimationFrame(animationFrameRef.current); - clearTimeout(enterTimeoutRef.current); - enterTimeoutRef.current = undefined; - - animationFrameRef.current = requestAnimationFrame(() => { - setClassName(`${transitionName}-leave ${transitionName}-leave-active`); - - leaveTimeoutRef.current = setTimeout(() => { - setCachedChildren(null); - setClassName(''); - }, duration); - }); - }; - - useEffect(() => { - const child = toChildArray(children)[0]; - if (cachedChildren === child) return; - - if (child && !cachedChildren) { - handleEnterTransition(); - } else if (cachedChildren && !child && !leaveTimeoutRef.current) { - handleLeaveTransition(); - } - - setCachedChildren(child); - }, [children, cachedChildren]); // Dependency array to trigger effect on children change - - - useEffect(() => { - return () => { - clearTimeout(enterTimeoutRef.current); - clearTimeout(leaveTimeoutRef.current); - cancelAnimationFrame(animationFrameRef.current); - }; - }, []); // Cleanup useEffect - - if (!cachedChildren) return null; - - return cloneElement(cachedChildren, { - className: classNames(className, cachedChildren.props.className), - }); -}; - -export default Slide diff --git a/packages/@uppy/dashboard/src/components/Slide.tsx b/packages/@uppy/dashboard/src/components/Slide.tsx new file mode 100644 index 0000000000..cca46963bc --- /dev/null +++ b/packages/@uppy/dashboard/src/components/Slide.tsx @@ -0,0 +1,96 @@ +import { + cloneElement, + toChildArray, + type VNode, + type ComponentChildren, +} from 'preact' +import { useEffect, useState, useRef } from 'preact/hooks' +import classNames from 'classnames' + +const transitionName = 'uppy-transition-slideDownUp' +const duration = 250 + +/** + * Vertical slide transition. + * + * This can take a _single_ child component, which _must_ accept a `className` prop. + * + * Currently this is specific to the `uppy-transition-slideDownUp` transition, + * but it should be simple to extend this for any type of single-element + * transition by setting the CSS name and duration as props. + */ +function Slide({ + children, +}: { + children: ComponentChildren +}): JSX.Element | null { + const [cachedChildren, setCachedChildren] = useState | null>(null) + const [className, setClassName] = useState('') + const enterTimeoutRef = useRef>() + const leaveTimeoutRef = useRef>() + const animationFrameRef = useRef>() + + const handleEnterTransition = () => { + setClassName(`${transitionName}-enter`) + + cancelAnimationFrame(animationFrameRef.current!) + clearTimeout(leaveTimeoutRef.current) + leaveTimeoutRef.current = undefined + + animationFrameRef.current = requestAnimationFrame(() => { + setClassName(`${transitionName}-enter ${transitionName}-enter-active`) + + enterTimeoutRef.current = setTimeout(() => { + setClassName('') + }, duration) + }) + } + + const handleLeaveTransition = () => { + setClassName(`${transitionName}-leave`) + + cancelAnimationFrame(animationFrameRef.current!) + clearTimeout(enterTimeoutRef.current) + enterTimeoutRef.current = undefined + + animationFrameRef.current = requestAnimationFrame(() => { + setClassName(`${transitionName}-leave ${transitionName}-leave-active`) + + leaveTimeoutRef.current = setTimeout(() => { + setCachedChildren(null) + setClassName('') + }, duration) + }) + } + + useEffect(() => { + const child = toChildArray(children)[0] as VNode + if (cachedChildren === child) return + + if (child && !cachedChildren) { + handleEnterTransition() + } else if (cachedChildren && !child && !leaveTimeoutRef.current) { + handleLeaveTransition() + } + + setCachedChildren(child) + }, [children, cachedChildren]) // Dependency array to trigger effect on children change + + useEffect(() => { + return () => { + clearTimeout(enterTimeoutRef.current) + clearTimeout(leaveTimeoutRef.current) + cancelAnimationFrame(animationFrameRef.current!) + } + }, []) // Cleanup useEffect + + if (!cachedChildren) return null + + return cloneElement(cachedChildren, { + className: classNames(className, cachedChildren.props.className), + }) +} + +export default Slide diff --git a/packages/@uppy/dashboard/src/index.js b/packages/@uppy/dashboard/src/index.js deleted file mode 100644 index 6c74a32596..0000000000 --- a/packages/@uppy/dashboard/src/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './Dashboard.jsx' diff --git a/packages/@uppy/dashboard/src/index.test.js b/packages/@uppy/dashboard/src/index.test.ts similarity index 77% rename from packages/@uppy/dashboard/src/index.test.js rename to packages/@uppy/dashboard/src/index.test.ts index 862c3ab7db..d584cb927a 100644 --- a/packages/@uppy/dashboard/src/index.test.js +++ b/packages/@uppy/dashboard/src/index.test.ts @@ -1,19 +1,29 @@ import { afterAll, beforeAll, describe, it, expect } from 'vitest' -import Core from '@uppy/core' +import Core, { UIPlugin } from '@uppy/core' import StatusBarPlugin from '@uppy/status-bar' import GoogleDrivePlugin from '@uppy/google-drive' +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore untyped import WebcamPlugin from '@uppy/webcam' +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore untyped import Url from '@uppy/url' import resizeObserverPolyfill from 'resize-observer-polyfill' -import DashboardPlugin from '../lib/index.js' +import DashboardPlugin from './index.ts' + +type $TSFixMe = any describe('Dashboard', () => { beforeAll(() => { - globalThis.ResizeObserver = resizeObserverPolyfill.default || resizeObserverPolyfill + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore we're touching globals for the test + globalThis.ResizeObserver = + (resizeObserverPolyfill as any).default || resizeObserverPolyfill }) afterAll(() => { + // @ts-expect-error we're touching globals for the test delete globalThis.ResizeObserver }) @@ -48,7 +58,10 @@ describe('Dashboard', () => { inline: true, target: 'body', }) - core.use(GoogleDrivePlugin, { target: DashboardPlugin, companionUrl: 'https://fake.uppy.io/' }) + core.use(GoogleDrivePlugin, { + target: DashboardPlugin as $TSFixMe, + companionUrl: 'https://fake.uppy.io/', + }) }).not.toThrow() core.close() @@ -75,12 +88,15 @@ describe('Dashboard', () => { core.use(DashboardPlugin, { inline: false }) core.use(WebcamPlugin) - const dashboardPlugins = core.getState().plugins['Dashboard'].targets + const dashboardPlugins = core.getState().plugins['Dashboard']! + .targets as UIPlugin[] // two built-in plugins + these ones below expect(dashboardPlugins.length).toEqual(4) expect(dashboardPlugins.some((plugin) => plugin.id === 'Url')).toEqual(true) - expect(dashboardPlugins.some((plugin) => plugin.id === 'Webcam')).toEqual(true) + expect(dashboardPlugins.some((plugin) => plugin.id === 'Webcam')).toEqual( + true, + ) core.close() }) @@ -92,12 +108,15 @@ describe('Dashboard', () => { core.use(DashboardPlugin, { inline: false }) core.use(WebcamPlugin, { target: 'body' }) - const dashboardPlugins = core.getState().plugins['Dashboard'].targets + const dashboardPlugins = core.getState().plugins['Dashboard']! + .targets as UIPlugin[] // two built-in plugins + these ones below expect(dashboardPlugins.length).toEqual(3) expect(dashboardPlugins.some((plugin) => plugin.id === 'Url')).toEqual(true) - expect(dashboardPlugins.some((plugin) => plugin.id === 'Webcam')).toEqual(false) + expect(dashboardPlugins.some((plugin) => plugin.id === 'Webcam')).toEqual( + false, + ) core.close() }) @@ -109,13 +128,11 @@ describe('Dashboard', () => { target: 'body', }) - core.getPlugin('Dashboard').setOptions({ + core.getPlugin('Dashboard')!.setOptions({ width: 300, }) - expect( - core.getPlugin('Dashboard').opts.width, - ).toEqual(300) + expect(core.getPlugin('Dashboard')!.opts.width).toEqual(300) }) it('should use updated locale from Core, when it’s set via Core’s setOptions()', () => { @@ -133,16 +150,14 @@ describe('Dashboard', () => { }, }) - expect( - core.getPlugin('Dashboard').i18n('myDevice'), - ).toEqual('Май дивайс') + expect(core.getPlugin('Dashboard')!.i18n('myDevice')).toEqual('Май дивайс') }) it('should accept a callback as `metaFields` option', () => { const core = new Core() expect(() => { core.use(DashboardPlugin, { - metaFields: (file) => { + metaFields: (file: any) => { const fields = [{ id: 'name', name: 'File name' }] if (file.type.startsWith('image/')) { fields.push({ id: 'location', name: 'Photo Location' }) diff --git a/packages/@uppy/dashboard/src/index.ts b/packages/@uppy/dashboard/src/index.ts new file mode 100644 index 0000000000..9355137dc9 --- /dev/null +++ b/packages/@uppy/dashboard/src/index.ts @@ -0,0 +1 @@ +export { default } from './Dashboard.tsx' diff --git a/packages/@uppy/dashboard/src/locale.js b/packages/@uppy/dashboard/src/locale.ts similarity index 100% rename from packages/@uppy/dashboard/src/locale.js rename to packages/@uppy/dashboard/src/locale.ts diff --git a/packages/@uppy/dashboard/src/utils/copyToClipboard.test.js b/packages/@uppy/dashboard/src/utils/copyToClipboard.test.ts similarity index 80% rename from packages/@uppy/dashboard/src/utils/copyToClipboard.test.js rename to packages/@uppy/dashboard/src/utils/copyToClipboard.test.ts index 328ee49588..df8e9d9a5e 100644 --- a/packages/@uppy/dashboard/src/utils/copyToClipboard.test.js +++ b/packages/@uppy/dashboard/src/utils/copyToClipboard.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import copyToClipboard from './copyToClipboard.js' +import copyToClipboard from './copyToClipboard.ts' describe('copyToClipboard', () => { it.skip('should copy the specified text to the clipboard', () => { diff --git a/packages/@uppy/dashboard/src/utils/copyToClipboard.js b/packages/@uppy/dashboard/src/utils/copyToClipboard.ts similarity index 82% rename from packages/@uppy/dashboard/src/utils/copyToClipboard.js rename to packages/@uppy/dashboard/src/utils/copyToClipboard.ts index 6f738730bc..6720cec9b7 100644 --- a/packages/@uppy/dashboard/src/utils/copyToClipboard.js +++ b/packages/@uppy/dashboard/src/utils/copyToClipboard.ts @@ -8,8 +8,14 @@ * @param {string} fallbackString * @returns {Promise} */ -export default function copyToClipboard (textToCopy, fallbackString = 'Copy the URL below') { - return new Promise((resolve) => { + +type $TSFixMe = any + +export default function copyToClipboard( + textToCopy: $TSFixMe, + fallbackString = 'Copy the URL below', +): $TSFixMe { + return new Promise((resolve) => { const textArea = document.createElement('textarea') textArea.setAttribute('style', { position: 'fixed', @@ -22,13 +28,13 @@ export default function copyToClipboard (textToCopy, fallbackString = 'Copy the outline: 'none', boxShadow: 'none', background: 'transparent', - }) + } as $TSFixMe as string) textArea.value = textToCopy document.body.appendChild(textArea) textArea.select() - const magicCopyFailed = () => { + const magicCopyFailed = (cause?: unknown) => { document.body.removeChild(textArea) // eslint-disable-next-line no-alert window.prompt(fallbackString, textToCopy) diff --git a/packages/@uppy/dashboard/src/utils/createSuperFocus.test.js b/packages/@uppy/dashboard/src/utils/createSuperFocus.test.ts similarity index 86% rename from packages/@uppy/dashboard/src/utils/createSuperFocus.test.js rename to packages/@uppy/dashboard/src/utils/createSuperFocus.test.ts index f30f1bc22d..1230dbf471 100644 --- a/packages/@uppy/dashboard/src/utils/createSuperFocus.test.js +++ b/packages/@uppy/dashboard/src/utils/createSuperFocus.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import createSuperFocus from './createSuperFocus.js' +import createSuperFocus from './createSuperFocus.ts' describe('createSuperFocus', () => { // superFocus.cancel() is used in dashboard diff --git a/packages/@uppy/dashboard/src/utils/createSuperFocus.js b/packages/@uppy/dashboard/src/utils/createSuperFocus.ts similarity index 86% rename from packages/@uppy/dashboard/src/utils/createSuperFocus.js rename to packages/@uppy/dashboard/src/utils/createSuperFocus.ts index 6c7d178134..9ea865748c 100644 --- a/packages/@uppy/dashboard/src/utils/createSuperFocus.js +++ b/packages/@uppy/dashboard/src/utils/createSuperFocus.ts @@ -1,6 +1,10 @@ import debounce from 'lodash/debounce.js' +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore untyped import FOCUSABLE_ELEMENTS from '@uppy/utils/lib/FOCUSABLE_ELEMENTS' -import getActiveOverlayEl from './getActiveOverlayEl.js' +import getActiveOverlayEl from './getActiveOverlayEl.ts' + +type $TSFixMe = any /* Focuses on some element in the currently topmost overlay. @@ -12,10 +16,10 @@ import getActiveOverlayEl from './getActiveOverlayEl.js' 2. If there are no [data-uppy-super-focusable] elements yet (or ever) - focuses on the first focusable element, but switches focus if superfocusable elements appear on next render. */ -export default function createSuperFocus () { +export default function createSuperFocus(): $TSFixMe { let lastFocusWasOnSuperFocusableEl = false - const superFocus = (dashboardEl, activeOverlayType) => { + const superFocus = (dashboardEl: $TSFixMe, activeOverlayType: $TSFixMe) => { const overlayEl = getActiveOverlayEl(dashboardEl, activeOverlayType) const isFocusInOverlay = overlayEl.contains(document.activeElement) @@ -24,7 +28,9 @@ export default function createSuperFocus () { // [Practical check] without this line, typing in the search input in googledrive overlay won't work. if (isFocusInOverlay && lastFocusWasOnSuperFocusableEl) return - const superFocusableEl = overlayEl.querySelector('[data-uppy-super-focusable]') + const superFocusableEl = overlayEl.querySelector( + '[data-uppy-super-focusable]', + ) // If we are already in the topmost overlay, AND there are no super focusable elements yet, - leave focus up to the user. // [Practical check] without this line, if you are in an empty folder in google drive, and something's uploading in the // bg, - focus will be jumping to Done all the time. diff --git a/packages/@uppy/dashboard/src/utils/getActiveOverlayEl.js b/packages/@uppy/dashboard/src/utils/getActiveOverlayEl.js deleted file mode 100644 index 7a73ee5b25..0000000000 --- a/packages/@uppy/dashboard/src/utils/getActiveOverlayEl.js +++ /dev/null @@ -1,11 +0,0 @@ -/** - * @returns {HTMLElement} - either dashboard element, or the overlay that's most on top - */ -export default function getActiveOverlayEl (dashboardEl, activeOverlayType) { - if (activeOverlayType) { - const overlayEl = dashboardEl.querySelector(`[data-uppy-paneltype="${activeOverlayType}"]`) - // if an overlay is already mounted - if (overlayEl) return overlayEl - } - return dashboardEl -} diff --git a/packages/@uppy/dashboard/src/utils/getActiveOverlayEl.ts b/packages/@uppy/dashboard/src/utils/getActiveOverlayEl.ts new file mode 100644 index 0000000000..2adc29b95d --- /dev/null +++ b/packages/@uppy/dashboard/src/utils/getActiveOverlayEl.ts @@ -0,0 +1,18 @@ +type $TSFixMe = any + +/** + * @returns {HTMLElement} - either dashboard element, or the overlay that's most on top + */ +export default function getActiveOverlayEl( + dashboardEl: $TSFixMe, + activeOverlayType: $TSFixMe, +): $TSFixMe { + if (activeOverlayType) { + const overlayEl = dashboardEl.querySelector( + `[data-uppy-paneltype="${activeOverlayType}"]`, + ) + // if an overlay is already mounted + if (overlayEl) return overlayEl + } + return dashboardEl +} diff --git a/packages/@uppy/dashboard/src/utils/getFileTypeIcon.jsx b/packages/@uppy/dashboard/src/utils/getFileTypeIcon.jsx deleted file mode 100644 index ad5a9445c1..0000000000 --- a/packages/@uppy/dashboard/src/utils/getFileTypeIcon.jsx +++ /dev/null @@ -1,127 +0,0 @@ -import { h } from 'preact' - -function iconImage () { - return ( - - ) -} - -function iconAudio () { - return ( - - ) -} - -function iconVideo () { - return ( - - ) -} - -function iconPDF () { - return ( - - ) -} - -function iconArchive () { - return ( - - ) -} - -function iconFile () { - return ( - - ) -} - -function iconText () { - return ( - - ) -} - -export default function getIconByMime (fileType) { - const defaultChoice = { - color: '#838999', - icon: iconFile(), - } - - if (!fileType) return defaultChoice - - const fileTypeGeneral = fileType.split('/')[0] - const fileTypeSpecific = fileType.split('/')[1] - - // Text - if (fileTypeGeneral === 'text') { - return { - color: '#5a5e69', - icon: iconText(), - } - } - - // Image - if (fileTypeGeneral === 'image') { - return { - color: '#686de0', - icon: iconImage(), - } - } - - // Audio - if (fileTypeGeneral === 'audio') { - return { - color: '#068dbb', - icon: iconAudio(), - } - } - - // Video - if (fileTypeGeneral === 'video') { - return { - color: '#19af67', - icon: iconVideo(), - } - } - - // PDF - if (fileTypeGeneral === 'application' && fileTypeSpecific === 'pdf') { - return { - color: '#e25149', - icon: iconPDF(), - } - } - - // Archive - const archiveTypes = ['zip', 'x-7z-compressed', 'x-rar-compressed', 'x-tar', 'x-gzip', 'x-apple-diskimage'] - if (fileTypeGeneral === 'application' && archiveTypes.indexOf(fileTypeSpecific) !== -1) { - return { - color: '#00C469', - icon: iconArchive(), - } - } - - return defaultChoice -} diff --git a/packages/@uppy/dashboard/src/utils/getFileTypeIcon.tsx b/packages/@uppy/dashboard/src/utils/getFileTypeIcon.tsx new file mode 100644 index 0000000000..51ddd39d4c --- /dev/null +++ b/packages/@uppy/dashboard/src/utils/getFileTypeIcon.tsx @@ -0,0 +1,212 @@ +import { h } from 'preact' + +type $TSFixMe = any + +function iconImage() { + return ( + + ) +} + +function iconAudio() { + return ( + + ) +} + +function iconVideo() { + return ( + + ) +} + +function iconPDF() { + return ( + + ) +} + +function iconArchive() { + return ( + + ) +} + +function iconFile() { + return ( + + ) +} + +function iconText() { + return ( + + ) +} + +export default function getIconByMime(fileType: $TSFixMe): $TSFixMe { + const defaultChoice = { + color: '#838999', + icon: iconFile(), + } + + if (!fileType) return defaultChoice + + const fileTypeGeneral = fileType.split('/')[0] + const fileTypeSpecific = fileType.split('/')[1] + + // Text + if (fileTypeGeneral === 'text') { + return { + color: '#5a5e69', + icon: iconText(), + } + } + + // Image + if (fileTypeGeneral === 'image') { + return { + color: '#686de0', + icon: iconImage(), + } + } + + // Audio + if (fileTypeGeneral === 'audio') { + return { + color: '#068dbb', + icon: iconAudio(), + } + } + + // Video + if (fileTypeGeneral === 'video') { + return { + color: '#19af67', + icon: iconVideo(), + } + } + + // PDF + if (fileTypeGeneral === 'application' && fileTypeSpecific === 'pdf') { + return { + color: '#e25149', + icon: iconPDF(), + } + } + + // Archive + const archiveTypes = [ + 'zip', + 'x-7z-compressed', + 'x-rar-compressed', + 'x-tar', + 'x-gzip', + 'x-apple-diskimage', + ] + if ( + fileTypeGeneral === 'application' && + archiveTypes.indexOf(fileTypeSpecific) !== -1 + ) { + return { + color: '#00C469', + icon: iconArchive(), + } + } + + return defaultChoice +} diff --git a/packages/@uppy/dashboard/src/utils/ignoreEvent.js b/packages/@uppy/dashboard/src/utils/ignoreEvent.ts similarity index 77% rename from packages/@uppy/dashboard/src/utils/ignoreEvent.js rename to packages/@uppy/dashboard/src/utils/ignoreEvent.ts index 85d7bd8456..9a4973bb64 100644 --- a/packages/@uppy/dashboard/src/utils/ignoreEvent.js +++ b/packages/@uppy/dashboard/src/utils/ignoreEvent.ts @@ -3,10 +3,11 @@ // draging UI elements or pasting anything into any field triggers those events — // Url treats them as URLs that need to be imported -function ignoreEvent (ev) { +type $TSFixMe = any + +function ignoreEvent(ev: $TSFixMe): void { const { tagName } = ev.target - if (tagName === 'INPUT' - || tagName === 'TEXTAREA') { + if (tagName === 'INPUT' || tagName === 'TEXTAREA') { ev.stopPropagation() return } diff --git a/packages/@uppy/dashboard/src/utils/trapFocus.js b/packages/@uppy/dashboard/src/utils/trapFocus.ts similarity index 70% rename from packages/@uppy/dashboard/src/utils/trapFocus.js rename to packages/@uppy/dashboard/src/utils/trapFocus.ts index 66353c22cd..84248498c0 100644 --- a/packages/@uppy/dashboard/src/utils/trapFocus.js +++ b/packages/@uppy/dashboard/src/utils/trapFocus.ts @@ -1,8 +1,12 @@ import toArray from '@uppy/utils/lib/toArray' +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore untyped import FOCUSABLE_ELEMENTS from '@uppy/utils/lib/FOCUSABLE_ELEMENTS' -import getActiveOverlayEl from './getActiveOverlayEl.js' +import getActiveOverlayEl from './getActiveOverlayEl.ts' -function focusOnFirstNode (event, nodes) { +type $TSFixMe = any + +function focusOnFirstNode(event: $TSFixMe, nodes: $TSFixMe) { const node = nodes[0] if (node) { node.focus() @@ -10,7 +14,7 @@ function focusOnFirstNode (event, nodes) { } } -function focusOnLastNode (event, nodes) { +function focusOnLastNode(event: $TSFixMe, nodes: $TSFixMe) { const node = nodes[nodes.length - 1] if (node) { node.focus() @@ -24,13 +28,19 @@ function focusOnLastNode (event, nodes) { // active overlay! // [Practical check] if we use (focusedItemIndex === -1), instagram provider in firefox will never get focus on its pics // in the
    . -function isFocusInOverlay (activeOverlayEl) { +function isFocusInOverlay(activeOverlayEl: $TSFixMe) { return activeOverlayEl.contains(document.activeElement) } -function trapFocus (event, activeOverlayType, dashboardEl) { +function trapFocus( + event: $TSFixMe, + activeOverlayType: $TSFixMe, + dashboardEl: $TSFixMe, +): void { const activeOverlayEl = getActiveOverlayEl(dashboardEl, activeOverlayType) - const focusableNodes = toArray(activeOverlayEl.querySelectorAll(FOCUSABLE_ELEMENTS)) + const focusableNodes = toArray( + activeOverlayEl.querySelectorAll(FOCUSABLE_ELEMENTS), + ) const focusedItemIndex = focusableNodes.indexOf(document.activeElement) @@ -40,21 +50,28 @@ function trapFocus (event, activeOverlayType, dashboardEl) { // plugins will try to focus on some important element as it loads. if (!isFocusInOverlay(activeOverlayEl)) { focusOnFirstNode(event, focusableNodes) - // If we pressed shift + tab, and we're on the first element of a modal + // If we pressed shift + tab, and we're on the first element of a modal } else if (event.shiftKey && focusedItemIndex === 0) { focusOnLastNode(event, focusableNodes) - // If we pressed tab, and we're on the last element of the modal - } else if (!event.shiftKey && focusedItemIndex === focusableNodes.length - 1) { + // If we pressed tab, and we're on the last element of the modal + } else if ( + !event.shiftKey && + focusedItemIndex === focusableNodes.length - 1 + ) { focusOnFirstNode(event, focusableNodes) } } // Traps focus inside of the currently open overlay (e.g. Dashboard, or e.g. Instagram), // never lets focus disappear from the modal. -export { trapFocus as forModal } +export { trapFocus as forModal } // Traps focus inside of the currently open overlay, unless overlay is null - then let the user tab away. -export function forInline (event, activeOverlayType, dashboardEl) { +export function forInline( + event: $TSFixMe, + activeOverlayType: $TSFixMe, + dashboardEl: $TSFixMe, +): void { // ___When we're in the bare 'Drop files here, paste, browse or import from' screen if (activeOverlayType === null) { // Do nothing and let the browser handle it, user can tab away from Uppy to other elements on the page diff --git a/packages/@uppy/dashboard/tsconfig.build.json b/packages/@uppy/dashboard/tsconfig.build.json new file mode 100644 index 0000000000..e1c431130b --- /dev/null +++ b/packages/@uppy/dashboard/tsconfig.build.json @@ -0,0 +1,60 @@ +{ + "extends": "../../../tsconfig.shared", + "compilerOptions": { + "noImplicitAny": false, + "outDir": "./lib", + "paths": { + "@uppy/informer": ["../informer/src/index.js"], + "@uppy/informer/lib/*": ["../informer/src/*"], + "@uppy/provider-views": ["../provider-views/src/index.js"], + "@uppy/provider-views/lib/*": ["../provider-views/src/*"], + "@uppy/status-bar": ["../status-bar/src/index.js"], + "@uppy/status-bar/lib/*": ["../status-bar/src/*"], + "@uppy/thumbnail-generator": ["../thumbnail-generator/src/index.js"], + "@uppy/thumbnail-generator/lib/*": ["../thumbnail-generator/src/*"], + "@uppy/utils/lib/*": ["../utils/src/*"], + "@uppy/core": ["../core/src/index.js"], + "@uppy/core/lib/*": ["../core/src/*"], + "@uppy/google-drive": ["../google-drive/src/index.js"], + "@uppy/google-drive/lib/*": ["../google-drive/src/*"], + "@uppy/url": ["../url/src/index.js"], + "@uppy/url/lib/*": ["../url/src/*"], + "@uppy/webcam": ["../webcam/src/index.js"], + "@uppy/webcam/lib/*": ["../webcam/src/*"] + }, + "resolveJsonModule": false, + "rootDir": "./src", + "skipLibCheck": true + }, + "include": ["./src/**/*.*"], + "exclude": ["./src/**/*.test.ts"], + "references": [ + { + "path": "../informer/tsconfig.build.json" + }, + { + "path": "../provider-views/tsconfig.build.json" + }, + { + "path": "../status-bar/tsconfig.build.json" + }, + { + "path": "../thumbnail-generator/tsconfig.build.json" + }, + { + "path": "../utils/tsconfig.build.json" + }, + { + "path": "../core/tsconfig.build.json" + }, + { + "path": "../google-drive/tsconfig.build.json" + }, + { + "path": "../url/tsconfig.build.json" + }, + { + "path": "../webcam/tsconfig.build.json" + } + ] +} diff --git a/packages/@uppy/dashboard/tsconfig.json b/packages/@uppy/dashboard/tsconfig.json new file mode 100644 index 0000000000..46cc2296b0 --- /dev/null +++ b/packages/@uppy/dashboard/tsconfig.json @@ -0,0 +1,56 @@ +{ + "extends": "../../../tsconfig.shared", + "compilerOptions": { + "emitDeclarationOnly": false, + "noEmit": true, + "paths": { + "@uppy/informer": ["../informer/src/index.js"], + "@uppy/informer/lib/*": ["../informer/src/*"], + "@uppy/provider-views": ["../provider-views/src/index.js"], + "@uppy/provider-views/lib/*": ["../provider-views/src/*"], + "@uppy/status-bar": ["../status-bar/src/index.js"], + "@uppy/status-bar/lib/*": ["../status-bar/src/*"], + "@uppy/thumbnail-generator": ["../thumbnail-generator/src/index.js"], + "@uppy/thumbnail-generator/lib/*": ["../thumbnail-generator/src/*"], + "@uppy/utils/lib/*": ["../utils/src/*"], + "@uppy/core": ["../core/src/index.js"], + "@uppy/core/lib/*": ["../core/src/*"], + "@uppy/google-drive": ["../google-drive/src/index.js"], + "@uppy/google-drive/lib/*": ["../google-drive/src/*"], + "@uppy/url": ["../url/src/index.js"], + "@uppy/url/lib/*": ["../url/src/*"], + "@uppy/webcam": ["../webcam/src/index.js"], + "@uppy/webcam/lib/*": ["../webcam/src/*"], + }, + }, + "include": ["./package.json", "./src/**/*.*"], + "references": [ + { + "path": "../informer/tsconfig.build.json", + }, + { + "path": "../provider-views/tsconfig.build.json", + }, + { + "path": "../status-bar/tsconfig.build.json", + }, + { + "path": "../thumbnail-generator/tsconfig.build.json", + }, + { + "path": "../utils/tsconfig.build.json", + }, + { + "path": "../core/tsconfig.build.json", + }, + { + "path": "../google-drive/tsconfig.build.json", + }, + { + "path": "../url/tsconfig.build.json", + }, + { + "path": "../webcam/tsconfig.build.json", + }, + ], +} From 2a618c935efcaf4b44850c4db233cc80bd0a3335 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 Mar 2024 14:05:54 +0100 Subject: [PATCH 05/27] Bump webpack-dev-middleware from 5.3.3 to 5.3.4 (#5013) Bumps [webpack-dev-middleware](https://github.com/webpack/webpack-dev-middleware) from 5.3.3 to 5.3.4. - [Release notes](https://github.com/webpack/webpack-dev-middleware/releases) - [Changelog](https://github.com/webpack/webpack-dev-middleware/blob/v5.3.4/CHANGELOG.md) - [Commits](https://github.com/webpack/webpack-dev-middleware/compare/v5.3.3...v5.3.4) --- updated-dependencies: - dependency-name: webpack-dev-middleware dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 76726e322e..0829013460 100644 --- a/yarn.lock +++ b/yarn.lock @@ -31950,8 +31950,8 @@ __metadata: linkType: hard "webpack-dev-middleware@npm:^5.3.1": - version: 5.3.3 - resolution: "webpack-dev-middleware@npm:5.3.3" + version: 5.3.4 + resolution: "webpack-dev-middleware@npm:5.3.4" dependencies: colorette: ^2.0.10 memfs: ^3.4.3 @@ -31960,7 +31960,7 @@ __metadata: schema-utils: ^4.0.0 peerDependencies: webpack: ^4.0.0 || ^5.0.0 - checksum: dd332cc6da61222c43d25e5a2155e23147b777ff32fdf1f1a0a8777020c072fbcef7756360ce2a13939c3f534c06b4992a4d659318c4a7fe2c0530b52a8a6621 + checksum: 90cf3e27d0714c1a745454a1794f491b7076434939340605b9ee8718ba2b85385b120939754e9fdbd6569811e749dee53eec319e0d600e70e0b0baffd8e3fb13 languageName: node linkType: hard From 9692f258db2650434ad353643da667b64d1ce5a5 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Fri, 22 Mar 2024 16:16:01 +0200 Subject: [PATCH 06/27] add missing exports (#5014) --- packages/@uppy/audio/src/Audio.tsx | 2 +- packages/@uppy/audio/src/index.ts | 1 + packages/@uppy/dashboard/src/Dashboard.tsx | 2 +- packages/@uppy/dashboard/src/index.ts | 1 + packages/@uppy/drop-target/src/index.ts | 2 +- packages/@uppy/webcam/src/Webcam.tsx | 2 +- packages/@uppy/webcam/src/index.ts | 1 + 7 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/@uppy/audio/src/Audio.tsx b/packages/@uppy/audio/src/Audio.tsx index 735306c09c..fa02d5e715 100644 --- a/packages/@uppy/audio/src/Audio.tsx +++ b/packages/@uppy/audio/src/Audio.tsx @@ -17,7 +17,7 @@ import locale from './locale.ts' // @ts-ignore We don't want TS to generate types for the package.json import packageJson from '../package.json' -interface AudioOptions extends UIPluginOptions { +export interface AudioOptions extends UIPluginOptions { showAudioSourceDropdown?: boolean } interface AudioState { diff --git a/packages/@uppy/audio/src/index.ts b/packages/@uppy/audio/src/index.ts index 6a6a2e2764..178ea5b00d 100644 --- a/packages/@uppy/audio/src/index.ts +++ b/packages/@uppy/audio/src/index.ts @@ -1 +1,2 @@ export { default } from './Audio.tsx' +export type { AudioOptions } from './Audio.tsx' diff --git a/packages/@uppy/dashboard/src/Dashboard.tsx b/packages/@uppy/dashboard/src/Dashboard.tsx index f0ccb7483e..b434b37290 100644 --- a/packages/@uppy/dashboard/src/Dashboard.tsx +++ b/packages/@uppy/dashboard/src/Dashboard.tsx @@ -112,7 +112,7 @@ interface DashboardState { [key: string]: unknown } -interface DashboardOptions +export interface DashboardOptions extends UIPluginOptions { animateOpenClose?: boolean browserBackButtonClose?: boolean diff --git a/packages/@uppy/dashboard/src/index.ts b/packages/@uppy/dashboard/src/index.ts index 9355137dc9..9f28d9d36c 100644 --- a/packages/@uppy/dashboard/src/index.ts +++ b/packages/@uppy/dashboard/src/index.ts @@ -1 +1,2 @@ export { default } from './Dashboard.tsx' +export type { DashboardOptions } from './Dashboard.tsx' diff --git a/packages/@uppy/drop-target/src/index.ts b/packages/@uppy/drop-target/src/index.ts index 038446d409..54614cfb59 100644 --- a/packages/@uppy/drop-target/src/index.ts +++ b/packages/@uppy/drop-target/src/index.ts @@ -8,7 +8,7 @@ import toArray from '@uppy/utils/lib/toArray' // @ts-ignore We don't want TS to generate types for the package.json import packageJson from '../package.json' -interface DropTargetOptions extends PluginOpts { +export interface DropTargetOptions extends PluginOpts { target?: HTMLElement | string | null onDrop?: (event: DragEvent) => void onDragOver?: (event: DragEvent) => void diff --git a/packages/@uppy/webcam/src/Webcam.tsx b/packages/@uppy/webcam/src/Webcam.tsx index 4b4ba259f5..4c990d2af5 100644 --- a/packages/@uppy/webcam/src/Webcam.tsx +++ b/packages/@uppy/webcam/src/Webcam.tsx @@ -59,7 +59,7 @@ function isModeAvailable(modes: T[], mode: unknown): mode is T { return modes.includes(mode as T) } -interface WebcamOptions +export interface WebcamOptions extends UIPluginOptions { target?: PluginTarget onBeforeSnapshot?: () => Promise diff --git a/packages/@uppy/webcam/src/index.ts b/packages/@uppy/webcam/src/index.ts index 25e83f3adc..0a8151b553 100644 --- a/packages/@uppy/webcam/src/index.ts +++ b/packages/@uppy/webcam/src/index.ts @@ -1 +1,2 @@ export { default } from './Webcam.tsx' +export type { WebcamOptions } from './Webcam.tsx' From 68af8a3c0f96a0fc37f6b2aa844df81e6f218fb4 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Fri, 22 Mar 2024 18:32:55 +0200 Subject: [PATCH 07/27] @uppy/core: fix some type errors (#5015) --- packages/@uppy/core/src/BasePlugin.ts | 10 +++++----- packages/@uppy/core/src/Restricter.ts | 2 +- packages/@uppy/core/src/Uppy.ts | 14 +++++++------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/@uppy/core/src/BasePlugin.ts b/packages/@uppy/core/src/BasePlugin.ts index 53bb06df8b..5f6c70be0c 100644 --- a/packages/@uppy/core/src/BasePlugin.ts +++ b/packages/@uppy/core/src/BasePlugin.ts @@ -49,17 +49,17 @@ export default class BasePlugin< opts: Opts - id: string + id!: string defaultLocale: OptionalPluralizeLocale - i18n: I18n + i18n!: I18n - i18nArray: Translator['translateArray'] + i18nArray!: Translator['translateArray'] - type: string + type!: string - VERSION: string + VERSION!: string constructor(uppy: Uppy, opts?: Opts) { this.uppy = uppy diff --git a/packages/@uppy/core/src/Restricter.ts b/packages/@uppy/core/src/Restricter.ts index 4c692301d6..d591646617 100644 --- a/packages/@uppy/core/src/Restricter.ts +++ b/packages/@uppy/core/src/Restricter.ts @@ -41,7 +41,7 @@ const defaultOptions = { class RestrictionError extends Error { isUserFacing: boolean - file: UppyFile + file!: UppyFile constructor( message: string, diff --git a/packages/@uppy/core/src/Uppy.ts b/packages/@uppy/core/src/Uppy.ts index ae7f812356..f813b22421 100644 --- a/packages/@uppy/core/src/Uppy.ts +++ b/packages/@uppy/core/src/Uppy.ts @@ -379,7 +379,7 @@ export class Uppy { defaultLocale: Locale - locale: Locale + locale!: Locale // The user optionally passes in options, but we set defaults for missing options. // We consider all options present after the contructor has run. @@ -387,9 +387,9 @@ export class Uppy { store: NonNullableUppyOptions['store'] - i18n: I18n + i18n!: I18n - i18nArray: Translator['translateArray'] + i18nArray!: Translator['translateArray'] scheduledAutoProceed: ReturnType | null = null @@ -839,7 +839,7 @@ export class Uppy { try { this.#restricter.validate(files, [file]) } catch (err) { - return err + return err as any } return null } @@ -1030,7 +1030,7 @@ export class Uppy { nextFilesState[newFile.id] = newFile validFilesToAdd.push(newFile) } catch (err) { - errors.push(err) + errors.push(err as any) } } @@ -1042,7 +1042,7 @@ export class Uppy { validFilesToAdd, ) } catch (err) { - errors.push(err) + errors.push(err as any) // If we have any aggregate error, don't allow adding this batch return { @@ -2146,7 +2146,7 @@ export class Uppy { * Start an upload for all the files that are not currently being uploaded. */ upload(): Promise> | undefined> { - if (!this.#plugins.uploader?.length) { + if (!this.#plugins['uploader']?.length) { this.log('No uploader type plugins are used', 'warning') } From 1aac94df2c4b91b3d8a94679b01e23969cb493d8 Mon Sep 17 00:00:00 2001 From: Merlijn Vos Date: Mon, 25 Mar 2024 11:27:21 +0100 Subject: [PATCH 08/27] Make `allowedMetaFields` consistent (#5011) Co-authored-by: Antoine du Hamel --- packages/@uppy/aws-s3-multipart/src/index.ts | 23 +++++++++++-------- packages/@uppy/tus/src/index.ts | 13 ++++++----- packages/@uppy/utils/package.json | 3 ++- .../@uppy/utils/src/getAllowedMetaFields.ts | 14 +++++++++++ packages/@uppy/xhr-upload/src/index.ts | 19 +++++++-------- 5 files changed, 44 insertions(+), 28 deletions(-) create mode 100644 packages/@uppy/utils/src/getAllowedMetaFields.ts diff --git a/packages/@uppy/aws-s3-multipart/src/index.ts b/packages/@uppy/aws-s3-multipart/src/index.ts index 94836509c4..50307ba116 100644 --- a/packages/@uppy/aws-s3-multipart/src/index.ts +++ b/packages/@uppy/aws-s3-multipart/src/index.ts @@ -13,7 +13,7 @@ import { filterFilesToEmitUploadStarted, } from '@uppy/utils/lib/fileFilters' import { createAbortError } from '@uppy/utils/lib/AbortController' - +import getAllowedMetaFields from '@uppy/utils/lib/getAllowedMetaFields' import MultipartUploader from './MultipartUploader.ts' import { throwIfAborted } from './utils.ts' import type { @@ -280,7 +280,7 @@ type RequestClientOptions = Partial< > interface _AwsS3MultipartOptions extends PluginOpts, RequestClientOptions { - allowedMetaFields?: string[] | null + allowedMetaFields?: string[] | boolean limit?: number retryDelays?: number[] | null } @@ -299,9 +299,7 @@ export type AwsS3MultipartOptions< ) const defaultOptions = { - // TODO: null here means “include all”, [] means include none. - // This is inconsistent with @uppy/aws-s3 and @uppy/transloadit - allowedMetaFields: null, + allowedMetaFields: true, limit: 6, getTemporarySecurityCredentials: false as any, shouldUseMultipart: ((file: UppyFile) => @@ -487,10 +485,11 @@ export default class AwsS3Multipart< this.assertHost('createMultipartUpload') throwIfAborted(signal) - const metadata = getAllowedMetadata({ - meta: file.meta, - allowedMetaFields: this.opts.allowedMetaFields, - }) + const allowedMetaFields = getAllowedMetaFields( + this.opts.allowedMetaFields, + file.meta, + ) + const metadata = getAllowedMetadata({ meta: file.meta, allowedMetaFields }) return this.#client .post( @@ -653,9 +652,13 @@ export default class AwsS3Multipart< ): Promise { const { meta } = file const { type, name: filename } = meta + const allowedMetaFields = getAllowedMetaFields( + this.opts.allowedMetaFields, + file.meta, + ) const metadata = getAllowedMetadata({ meta, - allowedMetaFields: this.opts.allowedMetaFields, + allowedMetaFields, querify: true, }) diff --git a/packages/@uppy/tus/src/index.ts b/packages/@uppy/tus/src/index.ts index 9f0072d799..ca553c932f 100644 --- a/packages/@uppy/tus/src/index.ts +++ b/packages/@uppy/tus/src/index.ts @@ -17,6 +17,7 @@ import { import type { Meta, Body, UppyFile } from '@uppy/utils/lib/UppyFile' import type { Uppy } from '@uppy/core' import type { RequestClient } from '@uppy/companion-client' +import getAllowedMetaFields from '@uppy/utils/lib/getAllowedMetaFields' import getFingerprint from './getFingerprint.ts' // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -56,7 +57,7 @@ export interface TusOpts ) => boolean retryDelays?: number[] withCredentials?: boolean - allowedMetaFields?: string[] + allowedMetaFields?: boolean | string[] rateLimitedQueue?: RateLimitedQueue } @@ -92,6 +93,7 @@ const defaultOptions = { limit: 20, retryDelays: tusDefaultOptions.retryDelays, withCredentials: false, + allowedMetaFields: true, } satisfies Partial> type Opts = DefinePluginOpts< @@ -427,11 +429,10 @@ export default class Tus extends BasePlugin< // and we also don't care about the type specifically here, // we just want to pass the meta fields along. const meta: Record = {} - const allowedMetaFields = - Array.isArray(opts.allowedMetaFields) ? - opts.allowedMetaFields - // Send along all fields by default. - : Object.keys(file.meta) + const allowedMetaFields = getAllowedMetaFields( + opts.allowedMetaFields, + file.meta, + ) allowedMetaFields.forEach((item) => { // tus type definition for metadata only accepts `Record` // but in reality (at runtime) it accepts `Record` diff --git a/packages/@uppy/utils/package.json b/packages/@uppy/utils/package.json index 2fe686eb86..06e7a1a287 100644 --- a/packages/@uppy/utils/package.json +++ b/packages/@uppy/utils/package.json @@ -69,7 +69,8 @@ "./lib/CompanionClientProvider": "./lib/CompanionClientProvider.js", "./lib/FileProgress": "./lib/FileProgress.js", "./src/microtip.scss": "./src/microtip.scss", - "./lib/UserFacingApiError": "./lib/UserFacingApiError.js" + "./lib/UserFacingApiError": "./lib/UserFacingApiError.js", + "./lib/getAllowedMetaFields": "./lib/getAllowedMetaFields.js" }, "dependencies": { "lodash": "^4.17.21", diff --git a/packages/@uppy/utils/src/getAllowedMetaFields.ts b/packages/@uppy/utils/src/getAllowedMetaFields.ts new file mode 100644 index 0000000000..e888d0252b --- /dev/null +++ b/packages/@uppy/utils/src/getAllowedMetaFields.ts @@ -0,0 +1,14 @@ +import type { Meta } from './UppyFile' + +export default function getAllowedMetaFields( + fields: string[] | boolean, + meta: M, +): string[] { + if (fields === true) { + return Object.keys(meta) + } + if (Array.isArray(fields)) { + return fields + } + return [] +} diff --git a/packages/@uppy/xhr-upload/src/index.ts b/packages/@uppy/xhr-upload/src/index.ts index 00b9e24318..392ed8ac37 100644 --- a/packages/@uppy/xhr-upload/src/index.ts +++ b/packages/@uppy/xhr-upload/src/index.ts @@ -20,6 +20,7 @@ import { // @ts-ignore We don't want TS to generate types for the package.json import type { Meta, Body, UppyFile } from '@uppy/utils/lib/UppyFile' import type { State, Uppy } from '@uppy/core' +import getAllowedMetaFields from '@uppy/utils/lib/getAllowedMetaFields' // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore We don't want TS to generate types for the package.json import packageJson from '../package.json' @@ -61,7 +62,7 @@ export interface XhrUploadOpts ) => boolean getResponseData?: (body: string, xhr: XMLHttpRequest) => B getResponseError?: (body: string, xhr: XMLHttpRequest) => Error | NetworkError - allowedMetaFields?: string[] | null + allowedMetaFields?: string[] | boolean bundle?: boolean responseUrlFieldName?: string } @@ -105,7 +106,7 @@ const defaultOptions = { formData: true, fieldName: 'file', method: 'post', - allowedMetaFields: null, + allowedMetaFields: true, responseUrlFieldName: 'url', bundle: false, headers: {}, @@ -240,10 +241,7 @@ export default class XHRUpload< meta: State['meta'], opts: Opts, ): void { - const allowedMetaFields = - Array.isArray(opts.allowedMetaFields) ? - opts.allowedMetaFields - : Object.keys(meta) // Send along all fields by default. + const allowedMetaFields = getAllowedMetaFields(opts.allowedMetaFields, meta) allowedMetaFields.forEach((item) => { const value = meta[item] @@ -560,11 +558,10 @@ export default class XHRUpload< #getCompanionClientArgs(file: UppyFile) { const opts = this.getOptions(file) - const allowedMetaFields = - Array.isArray(opts.allowedMetaFields) ? - opts.allowedMetaFields - // Send along all fields by default. - : Object.keys(file.meta) + const allowedMetaFields = getAllowedMetaFields( + opts.allowedMetaFields, + file.meta, + ) return { ...file.remote?.body, protocol: 'multipart', From 01f2428b41554e7d376829609201e16bf1e0a84c Mon Sep 17 00:00:00 2001 From: Chris Grigg Date: Mon, 25 Mar 2024 13:31:09 -0400 Subject: [PATCH 09/27] @uppy/dashboard: add new `autoOpen` option (#5001) * Add autoOpenView option that defaults to meta * - deprecate `autoOpenFileEditor`, use `autoOpen` instead * tests - account for the case where `opts` are undefined * Update packages/@uppy/dashboard/src/Dashboard.jsx Co-authored-by: Merlijn Vos * types - properly deprecate option `autoOpenFileEditor` * everywhere - rename "fileEditor" => "imageEditor" * - refactor `props` passing * types - copypaste types * Dashboard.tsx - autoOpen: `false` => `null` * change the default value too --------- Co-authored-by: Evgenia Karunus Co-authored-by: Merlijn Vos --- packages/@uppy/dashboard/src/Dashboard.tsx | 25 ++++-- packages/@uppy/dashboard/types/index.d.ts | 2 + packages/@uppy/react/src/DashboardModal.js | 88 ++-------------------- 3 files changed, 28 insertions(+), 87 deletions(-) diff --git a/packages/@uppy/dashboard/src/Dashboard.tsx b/packages/@uppy/dashboard/src/Dashboard.tsx index b434b37290..704e181ac1 100644 --- a/packages/@uppy/dashboard/src/Dashboard.tsx +++ b/packages/@uppy/dashboard/src/Dashboard.tsx @@ -150,6 +150,8 @@ export interface DashboardOptions theme?: 'auto' | 'dark' | 'light' trigger?: string width?: string | number + autoOpen?: 'metaEditor' | 'imageEditor' | null + /** @deprecated use option autoOpen instead */ autoOpenFileEditor?: boolean disabled?: boolean disableLocalFiles?: boolean @@ -195,6 +197,7 @@ const defaultOptions = { showNativePhotoCameraButton: false, showNativeVideoCameraButton: false, theme: 'light', + autoOpen: null, autoOpenFileEditor: false, disabled: false, disableLocalFiles: false, @@ -243,7 +246,17 @@ export default class Dashboard extends UIPlugin< private removeDragOverClassTimeout: ReturnType constructor(uppy: Uppy, opts?: DashboardOptions) { - super(uppy, { ...defaultOptions, ...opts }) + // support for the legacy `autoOpenFileEditor` option, + // TODO: we can remove this code when we update the Uppy major version + let autoOpen: DashboardOptions['autoOpen'] + if (!opts) { + autoOpen = null + } else if (opts.autoOpen === undefined) { + autoOpen = opts.autoOpenFileEditor ? 'imageEditor' : null + } else { + autoOpen = opts.autoOpen + } + super(uppy, { ...defaultOptions, ...opts, autoOpen }) this.id = this.opts.id || 'Dashboard' this.title = 'Dashboard' this.type = 'orchestrator' @@ -939,11 +952,11 @@ export default class Dashboard extends UIPlugin< const { metaFields } = this.getPluginState() const isMetaEditorEnabled = metaFields && metaFields.length > 0 - const isFileEditorEnabled = this.canEditFile(firstFile) + const isImageEditorEnabled = this.canEditFile(firstFile) - if (isMetaEditorEnabled) { + if (isMetaEditorEnabled && this.opts.autoOpen === 'metaEditor') { this.toggleFileCard(true, firstFile.id) - } else if (isFileEditorEnabled) { + } else if (isImageEditorEnabled && this.opts.autoOpen === 'imageEditor') { this.openFileEditor(firstFile) } } @@ -985,7 +998,7 @@ export default class Dashboard extends UIPlugin< this.el!.addEventListener('keydown', this.handleKeyDownInInline) } - if (this.opts.autoOpenFileEditor) { + if (this.opts.autoOpen) { this.uppy.on('files-added', this.#openFileEditorWhenFilesAdded) } } @@ -1018,7 +1031,7 @@ export default class Dashboard extends UIPlugin< this.el!.removeEventListener('keydown', this.handleKeyDownInInline) } - if (this.opts.autoOpenFileEditor) { + if (this.opts.autoOpen) { this.uppy.off('files-added', this.#openFileEditorWhenFilesAdded) } } diff --git a/packages/@uppy/dashboard/types/index.d.ts b/packages/@uppy/dashboard/types/index.d.ts index ef4a94ed94..b21982222e 100644 --- a/packages/@uppy/dashboard/types/index.d.ts +++ b/packages/@uppy/dashboard/types/index.d.ts @@ -65,6 +65,8 @@ export interface DashboardOptions extends Options { theme?: 'auto' | 'dark' | 'light' trigger?: string width?: string | number + autoOpen?: 'metaEditor' | 'imageEditor' | null + /** @deprecated use option autoOpen instead */ autoOpenFileEditor?: boolean disabled?: boolean disableLocalFiles?: boolean diff --git a/packages/@uppy/react/src/DashboardModal.js b/packages/@uppy/react/src/DashboardModal.js index 24c6482fe9..d11cfbacfc 100644 --- a/packages/@uppy/react/src/DashboardModal.js +++ b/packages/@uppy/react/src/DashboardModal.js @@ -37,90 +37,15 @@ class DashboardModal extends Component { } installPlugin () { - const { - id = 'react:DashboardModal', - uppy, - target, - open, - onRequestClose, - closeModalOnClickOutside, - disablePageScrollWhenModalOpen, - inline, - plugins, // eslint-disable-line no-shadow - width, - height, - showProgressDetails, - note, - metaFields, // eslint-disable-line no-shadow - proudlyDisplayPoweredByUppy, - autoOpenFileEditor, - animateOpenClose, - browserBackButtonClose, - closeAfterFinish, - disableStatusBar, - disableInformer, - disableThumbnailGenerator, - disableLocalFiles, - disabled, - hideCancelButton, - hidePauseResumeButton, - hideProgressAfterFinish, - hideRetryButton, - hideUploadButton, - showLinkToFileUploadResult, - showRemoveButtonAfterComplete, - showSelectedFiles, - waitForThumbnailsBeforeUpload, - fileManagerSelectionType, - theme, - thumbnailType, - thumbnailWidth, - locale, // eslint-disable-line no-shadow - } = this.props + const { id='react:DashboardModal', target=this.container, open, onRequestClose, uppy } = this.props const options = { + ...this.props, id, target, - closeModalOnClickOutside, - disablePageScrollWhenModalOpen, - inline, - plugins, - width, - height, - showProgressDetails, - note, - metaFields, - proudlyDisplayPoweredByUppy, - autoOpenFileEditor, - animateOpenClose, - browserBackButtonClose, - closeAfterFinish, - disableStatusBar, - disableInformer, - disableThumbnailGenerator, - disableLocalFiles, - disabled, - hideCancelButton, - hidePauseResumeButton, - hideProgressAfterFinish, - hideRetryButton, - hideUploadButton, - showLinkToFileUploadResult, - showRemoveButtonAfterComplete, - showSelectedFiles, - waitForThumbnailsBeforeUpload, - fileManagerSelectionType, - theme, - thumbnailType, - thumbnailWidth, - locale, onRequestCloseModal: onRequestClose, } - - if (!options.target) { - options.target = this.container - } - delete options.uppy + uppy.use(DashboardPlugin, options) this.plugin = uppy.getPlugin(options.id) @@ -146,6 +71,7 @@ class DashboardModal extends Component { } } +/* eslint-disable react/no-unused-prop-types */ DashboardModal.propTypes = { uppy: uppyPropType.isRequired, target: typeof window !== 'undefined' ? PropTypes.instanceOf(window.HTMLElement) : PropTypes.any, @@ -161,7 +87,7 @@ DashboardModal.propTypes = { note: PropTypes.string, metaFields, proudlyDisplayPoweredByUppy: PropTypes.bool, - autoOpenFileEditor: PropTypes.bool, + autoOpen: PropTypes.oneOf(['imageEditor', 'metaEditor', null]), animateOpenClose: PropTypes.bool, browserBackButtonClose: PropTypes.bool, closeAfterFinish: PropTypes.bool, @@ -186,7 +112,7 @@ DashboardModal.propTypes = { thumbnailWidth: PropTypes.number, locale, } -// Must be kept in sync with @uppy/dashboard/src/Dashboard.jsx. +// Must be kept in sync with @uppy/dashboard/src/Dashboard.tsx. DashboardModal.defaultProps = { metaFields: [], plugins: [], @@ -217,7 +143,7 @@ DashboardModal.defaultProps = { showRemoveButtonAfterComplete: false, browserBackButtonClose: false, theme: 'light', - autoOpenFileEditor: false, + autoOpen: false, disabled: false, disableLocalFiles: false, From c1fb7fd323d87970f705978a09a18f959a698b89 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Tue, 26 Mar 2024 16:07:09 +0100 Subject: [PATCH 10/27] @uppy/dashboard: refine option types (#5022) --- packages/@uppy/dashboard/src/Dashboard.tsx | 78 ++++++++++++++-------- 1 file changed, 49 insertions(+), 29 deletions(-) diff --git a/packages/@uppy/dashboard/src/Dashboard.tsx b/packages/@uppy/dashboard/src/Dashboard.tsx index 704e181ac1..727a285cb3 100644 --- a/packages/@uppy/dashboard/src/Dashboard.tsx +++ b/packages/@uppy/dashboard/src/Dashboard.tsx @@ -112,61 +112,75 @@ interface DashboardState { [key: string]: unknown } -export interface DashboardOptions - extends UIPluginOptions { +export interface DashboardModalOptions { + inline?: false animateOpenClose?: boolean browserBackButtonClose?: boolean closeAfterFinish?: boolean - singleFileFullScreen?: boolean closeModalOnClickOutside?: boolean - disableInformer?: boolean disablePageScrollWhenModalOpen?: boolean - disableStatusBar?: boolean - disableThumbnailGenerator?: boolean +} + +export interface DashboardInlineOptions { + inline: true + height?: string | number - thumbnailWidth?: number - thumbnailHeight?: number - thumbnailType?: string - nativeCameraFacingMode?: ConstrainDOMString - waitForThumbnailsBeforeUpload?: boolean + width?: string | number +} + +interface DashboardMiscOptions + extends UIPluginOptions { + autoOpen?: 'metaEditor' | 'imageEditor' | null + /** @deprecated use option autoOpen instead */ + autoOpenFileEditor?: boolean defaultPickerIcon?: typeof defaultPickerIcon + disabled?: boolean + disableInformer?: boolean + disableLocalFiles?: boolean + disableStatusBar?: boolean + disableThumbnailGenerator?: boolean + doneButtonHandler?: () => void + fileManagerSelectionType?: 'files' | 'folders' | 'both' hideCancelButton?: boolean hidePauseResumeButton?: boolean hideProgressAfterFinish?: boolean hideRetryButton?: boolean hideUploadButton?: boolean - inline?: boolean metaFields?: MetaField[] | ((file: UppyFile) => MetaField[]) + nativeCameraFacingMode?: ConstrainDOMString note?: string | null + onDragLeave?: (event: DragEvent) => void + onDragOver?: (event: DragEvent) => void + onDrop?: (event: DragEvent) => void + onRequestCloseModal?: () => void plugins?: string[] - fileManagerSelectionType?: 'files' | 'folders' | 'both' proudlyDisplayPoweredByUppy?: boolean showLinkToFileUploadResult?: boolean - showProgressDetails?: boolean - showSelectedFiles?: boolean - showRemoveButtonAfterComplete?: boolean showNativePhotoCameraButton?: boolean showNativeVideoCameraButton?: boolean + showProgressDetails?: boolean + showRemoveButtonAfterComplete?: boolean + showSelectedFiles?: boolean + singleFileFullScreen?: boolean theme?: 'auto' | 'dark' | 'light' + thumbnailHeight?: number + thumbnailType?: string + thumbnailWidth?: number trigger?: string - width?: string | number - autoOpen?: 'metaEditor' | 'imageEditor' | null - /** @deprecated use option autoOpen instead */ - autoOpenFileEditor?: boolean - disabled?: boolean - disableLocalFiles?: boolean - onRequestCloseModal?: () => void - doneButtonHandler?: () => void - onDragOver?: (event: DragEvent) => void - onDragLeave?: (event: DragEvent) => void - onDrop?: (event: DragEvent) => void + waitForThumbnailsBeforeUpload?: boolean } +export type DashboardOptions< + M extends Meta, + B extends Body, +> = DashboardMiscOptions & + (DashboardModalOptions | DashboardInlineOptions) + // set default options, must be kept in sync with packages/@uppy/react/src/DashboardModal.js const defaultOptions = { target: 'body', metaFields: [], - inline: false, + inline: false as boolean, width: 750, height: 550, thumbnailWidth: 280, @@ -213,7 +227,13 @@ const defaultOptions = { * Dashboard UI with previews, metadata editing, tabs for various services and more */ export default class Dashboard extends UIPlugin< - DefinePluginOpts, keyof typeof defaultOptions>, + DefinePluginOpts< + // The options object inside the class is not the discriminated union but and intersection of the different subtypes. + DashboardMiscOptions & + Omit & + Omit & { inline?: boolean }, + keyof typeof defaultOptions + >, M, B, DashboardState From 6673b7787771194c52f0cb451bb1f3f69f8af71d Mon Sep 17 00:00:00 2001 From: Merlijn Vos Date: Tue, 26 Mar 2024 16:24:21 +0100 Subject: [PATCH 11/27] @uppy/remote-sources: migrate to TS (#5020) --- packages/@uppy/remote-sources/.npmignore | 1 + packages/@uppy/remote-sources/src/index.js | 76 ------------- .../src/{index.test.js => index.test.ts} | 19 +++- packages/@uppy/remote-sources/src/index.ts | 105 ++++++++++++++++++ .../@uppy/remote-sources/tsconfig.build.json | 71 ++++++++++++ packages/@uppy/remote-sources/tsconfig.json | 67 +++++++++++ 6 files changed, 259 insertions(+), 80 deletions(-) create mode 100644 packages/@uppy/remote-sources/.npmignore delete mode 100644 packages/@uppy/remote-sources/src/index.js rename packages/@uppy/remote-sources/src/{index.test.js => index.test.ts} (61%) create mode 100644 packages/@uppy/remote-sources/src/index.ts create mode 100644 packages/@uppy/remote-sources/tsconfig.build.json create mode 100644 packages/@uppy/remote-sources/tsconfig.json diff --git a/packages/@uppy/remote-sources/.npmignore b/packages/@uppy/remote-sources/.npmignore new file mode 100644 index 0000000000..6c816673f0 --- /dev/null +++ b/packages/@uppy/remote-sources/.npmignore @@ -0,0 +1 @@ +tsconfig.* diff --git a/packages/@uppy/remote-sources/src/index.js b/packages/@uppy/remote-sources/src/index.js deleted file mode 100644 index 1a18dfea8d..0000000000 --- a/packages/@uppy/remote-sources/src/index.js +++ /dev/null @@ -1,76 +0,0 @@ -import { BasePlugin } from '@uppy/core' -import Dropbox from '@uppy/dropbox' -import GoogleDrive from '@uppy/google-drive' -import Instagram from '@uppy/instagram' -import Facebook from '@uppy/facebook' -import OneDrive from '@uppy/onedrive' -import Box from '@uppy/box' -import Unsplash from '@uppy/unsplash' -import Url from '@uppy/url' -import Zoom from '@uppy/zoom' - -import packageJson from '../package.json' - -const availablePlugins = { - // Using a null-prototype object to avoid prototype pollution. - __proto__: null, - Box, - Dropbox, - Facebook, - GoogleDrive, - Instagram, - OneDrive, - Unsplash, - Url, - Zoom, -} - -export default class RemoteSources extends BasePlugin { - static VERSION = packageJson.version - - #installedPlugins = new Set() - - constructor (uppy, opts) { - super(uppy, opts) - this.id = this.opts.id || 'RemoteSources' - this.type = 'preset' - - const defaultOptions = { - sources: Object.keys(availablePlugins), - } - this.opts = { ...defaultOptions, ...opts } - - if (this.opts.companionUrl == null) { - throw new Error('Please specify companionUrl for RemoteSources to work, see https://uppy.io/docs/remote-sources#companionUrl') - } - } - - setOptions (newOpts) { - this.uninstall() - super.setOptions(newOpts) - this.install() - } - - install () { - this.opts.sources.forEach((pluginId) => { - const optsForRemoteSourcePlugin = { ...this.opts, sources: undefined } - const plugin = availablePlugins[pluginId] - if (plugin == null) { - const pluginNames = Object.keys(availablePlugins) - const formatter = new Intl.ListFormat('en', { style: 'long', type: 'disjunction' }) - throw new Error(`Invalid plugin: "${pluginId}" is not one of: ${formatter.format(pluginNames)}.`) - } - this.uppy.use(plugin, optsForRemoteSourcePlugin) - // `plugin` is a class, but we want to track the instance object - // so we have to do `getPlugin` here. - this.#installedPlugins.add(this.uppy.getPlugin(pluginId)) - }) - } - - uninstall () { - for (const plugin of this.#installedPlugins) { - this.uppy.removePlugin(plugin) - } - this.#installedPlugins.clear() - } -} diff --git a/packages/@uppy/remote-sources/src/index.test.js b/packages/@uppy/remote-sources/src/index.test.ts similarity index 61% rename from packages/@uppy/remote-sources/src/index.test.js rename to packages/@uppy/remote-sources/src/index.test.ts index aecae499ed..b99ae2af89 100644 --- a/packages/@uppy/remote-sources/src/index.test.js +++ b/packages/@uppy/remote-sources/src/index.test.ts @@ -2,14 +2,17 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest' import resizeObserverPolyfill from 'resize-observer-polyfill' import Core from '@uppy/core' import Dashboard from '@uppy/dashboard' -import RemoteSources from './index.js' +import RemoteSources from './index.ts' describe('RemoteSources', () => { beforeAll(() => { - globalThis.ResizeObserver = resizeObserverPolyfill.default || resizeObserverPolyfill + globalThis.ResizeObserver = + // @ts-expect-error .default is fine + resizeObserverPolyfill.default || resizeObserverPolyfill }) afterAll(() => { + // @ts-expect-error delete does not have to be conditional delete globalThis.ResizeObserver }) @@ -25,8 +28,13 @@ describe('RemoteSources', () => { expect(() => { const core = new Core() core.use(Dashboard) + // @ts-expect-error companionUrl is missing core.use(RemoteSources, { sources: ['Webcam'] }) - }).toThrow(new Error('Please specify companionUrl for RemoteSources to work, see https://uppy.io/docs/remote-sources#companionUrl')) + }).toThrow( + new Error( + 'Please specify companionUrl for RemoteSources to work, see https://uppy.io/docs/remote-sources#companionUrl', + ), + ) }) it('should throw when trying to use a plugin which is not included in RemoteSources', () => { @@ -35,8 +43,11 @@ describe('RemoteSources', () => { core.use(Dashboard) core.use(RemoteSources, { companionUrl: 'https://example.com', + // @ts-expect-error test invalid sources: ['Webcam'], }) - }).toThrow('Invalid plugin: "Webcam" is not one of: Box, Dropbox, Facebook, GoogleDrive, Instagram, OneDrive, Unsplash, Url, or Zoom.') + }).toThrow( + 'Invalid plugin: "Webcam" is not one of: Box, Dropbox, Facebook, GoogleDrive, Instagram, OneDrive, Unsplash, Url, or Zoom.', + ) }) }) diff --git a/packages/@uppy/remote-sources/src/index.ts b/packages/@uppy/remote-sources/src/index.ts new file mode 100644 index 0000000000..4b54c802c3 --- /dev/null +++ b/packages/@uppy/remote-sources/src/index.ts @@ -0,0 +1,105 @@ +import { + BasePlugin, + Uppy, + type UIPluginOptions, + type UnknownProviderPlugin, +} from '@uppy/core' +import Dropbox from '@uppy/dropbox' +import GoogleDrive from '@uppy/google-drive' +import Instagram from '@uppy/instagram' +import Facebook from '@uppy/facebook' +import OneDrive from '@uppy/onedrive' +import Box from '@uppy/box' +import Unsplash from '@uppy/unsplash' +import Url from '@uppy/url' +import Zoom from '@uppy/zoom' + +import type { DefinePluginOpts } from '@uppy/core/lib/BasePlugin' +import type { Body, Meta } from '../../utils/src/UppyFile' +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore We don't want TS to generate types for the package.json +import packageJson from '../package.json' + +const availablePlugins = { + // Using a null-prototype object to avoid prototype pollution. + __proto__: null, + Box, + Dropbox, + Facebook, + GoogleDrive, + Instagram, + OneDrive, + Unsplash, + Url, + Zoom, +} + +export interface RemoteSourcesOptions extends UIPluginOptions { + sources?: Array> + companionUrl: string +} + +const defaultOptions = { + sources: Object.keys(availablePlugins) as Array< + keyof Omit + >, +} satisfies Partial + +type Opts = DefinePluginOpts + +export default class RemoteSources< + M extends Meta, + B extends Body, +> extends BasePlugin { + static VERSION = packageJson.version + + #installedPlugins: Set> = new Set() + + constructor(uppy: Uppy, opts: RemoteSourcesOptions) { + super(uppy, { ...defaultOptions, ...opts }) + this.id = this.opts.id || 'RemoteSources' + this.type = 'preset' + + if (this.opts.companionUrl == null) { + throw new Error( + 'Please specify companionUrl for RemoteSources to work, see https://uppy.io/docs/remote-sources#companionUrl', + ) + } + } + + setOptions(newOpts: Partial): void { + this.uninstall() + super.setOptions(newOpts) + this.install() + } + + install(): void { + this.opts.sources.forEach((pluginId) => { + const optsForRemoteSourcePlugin = { ...this.opts, sources: undefined } + const plugin = availablePlugins[pluginId] + if (plugin == null) { + const pluginNames = Object.keys(availablePlugins) + const formatter = new Intl.ListFormat('en', { + style: 'long', + type: 'disjunction', + }) + throw new Error( + `Invalid plugin: "${pluginId}" is not one of: ${formatter.format(pluginNames)}.`, + ) + } + this.uppy.use(plugin, optsForRemoteSourcePlugin) + // `plugin` is a class, but we want to track the instance object + // so we have to do `getPlugin` here. + this.#installedPlugins.add( + this.uppy.getPlugin(pluginId) as UnknownProviderPlugin, + ) + }) + } + + uninstall(): void { + for (const plugin of this.#installedPlugins) { + this.uppy.removePlugin(plugin) + } + this.#installedPlugins.clear() + } +} diff --git a/packages/@uppy/remote-sources/tsconfig.build.json b/packages/@uppy/remote-sources/tsconfig.build.json new file mode 100644 index 0000000000..97b71eb43c --- /dev/null +++ b/packages/@uppy/remote-sources/tsconfig.build.json @@ -0,0 +1,71 @@ +{ + "extends": "../../../tsconfig.shared", + "compilerOptions": { + "noImplicitAny": false, + "outDir": "./lib", + "paths": { + "@uppy/box": ["../box/src/index.js"], + "@uppy/box/lib/*": ["../box/src/*"], + "@uppy/dashboard": ["../dashboard/src/index.js"], + "@uppy/dashboard/lib/*": ["../dashboard/src/*"], + "@uppy/dropbox": ["../dropbox/src/index.js"], + "@uppy/dropbox/lib/*": ["../dropbox/src/*"], + "@uppy/facebook": ["../facebook/src/index.js"], + "@uppy/facebook/lib/*": ["../facebook/src/*"], + "@uppy/google-drive": ["../google-drive/src/index.js"], + "@uppy/google-drive/lib/*": ["../google-drive/src/*"], + "@uppy/instagram": ["../instagram/src/index.js"], + "@uppy/instagram/lib/*": ["../instagram/src/*"], + "@uppy/onedrive": ["../onedrive/src/index.js"], + "@uppy/onedrive/lib/*": ["../onedrive/src/*"], + "@uppy/unsplash": ["../unsplash/src/index.js"], + "@uppy/unsplash/lib/*": ["../unsplash/src/*"], + "@uppy/url": ["../url/src/index.js"], + "@uppy/url/lib/*": ["../url/src/*"], + "@uppy/zoom": ["../zoom/src/index.js"], + "@uppy/zoom/lib/*": ["../zoom/src/*"], + "@uppy/core": ["../core/src/index.js"], + "@uppy/core/lib/*": ["../core/src/*"] + }, + "resolveJsonModule": false, + "rootDir": "./src", + "skipLibCheck": true + }, + "include": ["./src/**/*.*"], + "exclude": ["./src/**/*.test.ts"], + "references": [ + { + "path": "../box/tsconfig.build.json" + }, + { + "path": "../dashboard/tsconfig.build.json" + }, + { + "path": "../dropbox/tsconfig.build.json" + }, + { + "path": "../facebook/tsconfig.build.json" + }, + { + "path": "../google-drive/tsconfig.build.json" + }, + { + "path": "../instagram/tsconfig.build.json" + }, + { + "path": "../onedrive/tsconfig.build.json" + }, + { + "path": "../unsplash/tsconfig.build.json" + }, + { + "path": "../url/tsconfig.build.json" + }, + { + "path": "../zoom/tsconfig.build.json" + }, + { + "path": "../core/tsconfig.build.json" + } + ] +} diff --git a/packages/@uppy/remote-sources/tsconfig.json b/packages/@uppy/remote-sources/tsconfig.json new file mode 100644 index 0000000000..8a51722f0b --- /dev/null +++ b/packages/@uppy/remote-sources/tsconfig.json @@ -0,0 +1,67 @@ +{ + "extends": "../../../tsconfig.shared", + "compilerOptions": { + "emitDeclarationOnly": false, + "noEmit": true, + "paths": { + "@uppy/box": ["../box/src/index.js"], + "@uppy/box/lib/*": ["../box/src/*"], + "@uppy/dashboard": ["../dashboard/src/index.js"], + "@uppy/dashboard/lib/*": ["../dashboard/src/*"], + "@uppy/dropbox": ["../dropbox/src/index.js"], + "@uppy/dropbox/lib/*": ["../dropbox/src/*"], + "@uppy/facebook": ["../facebook/src/index.js"], + "@uppy/facebook/lib/*": ["../facebook/src/*"], + "@uppy/google-drive": ["../google-drive/src/index.js"], + "@uppy/google-drive/lib/*": ["../google-drive/src/*"], + "@uppy/instagram": ["../instagram/src/index.js"], + "@uppy/instagram/lib/*": ["../instagram/src/*"], + "@uppy/onedrive": ["../onedrive/src/index.js"], + "@uppy/onedrive/lib/*": ["../onedrive/src/*"], + "@uppy/unsplash": ["../unsplash/src/index.js"], + "@uppy/unsplash/lib/*": ["../unsplash/src/*"], + "@uppy/url": ["../url/src/index.js"], + "@uppy/url/lib/*": ["../url/src/*"], + "@uppy/zoom": ["../zoom/src/index.js"], + "@uppy/zoom/lib/*": ["../zoom/src/*"], + "@uppy/core": ["../core/src/index.js"], + "@uppy/core/lib/*": ["../core/src/*"], + }, + }, + "include": ["./package.json", "./src/**/*.*"], + "references": [ + { + "path": "../box/tsconfig.build.json", + }, + { + "path": "../dashboard/tsconfig.build.json", + }, + { + "path": "../dropbox/tsconfig.build.json", + }, + { + "path": "../facebook/tsconfig.build.json", + }, + { + "path": "../google-drive/tsconfig.build.json", + }, + { + "path": "../instagram/tsconfig.build.json", + }, + { + "path": "../onedrive/tsconfig.build.json", + }, + { + "path": "../unsplash/tsconfig.build.json", + }, + { + "path": "../url/tsconfig.build.json", + }, + { + "path": "../zoom/tsconfig.build.json", + }, + { + "path": "../core/tsconfig.build.json", + }, + ], +} From 0c10dda9274e3185e25f14e1fba1c95406ecaf91 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Tue, 26 Mar 2024 22:56:00 +0100 Subject: [PATCH 12/27] @uppy/status-bar: refine type of private variables (#5025) It seems to be required by newer versions of TS. --- packages/@uppy/status-bar/src/StatusBar.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/@uppy/status-bar/src/StatusBar.tsx b/packages/@uppy/status-bar/src/StatusBar.tsx index 4290381211..2eaaa3245a 100644 --- a/packages/@uppy/status-bar/src/StatusBar.tsx +++ b/packages/@uppy/status-bar/src/StatusBar.tsx @@ -80,13 +80,13 @@ export default class StatusBar extends UIPlugin< > { static VERSION = packageJson.version - #lastUpdateTime: ReturnType + #lastUpdateTime!: ReturnType - #previousUploadedBytes: number | null + #previousUploadedBytes!: number | null - #previousSpeed: number | null + #previousSpeed!: number | null - #previousETA: number | null + #previousETA!: number | null constructor(uppy: Uppy, opts?: StatusBarOptions) { super(uppy, { ...defaultOptions, ...opts }) From 3623ed9da438c8d239d61b947ce9bc42315a46ba Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Tue, 26 Mar 2024 22:56:13 +0100 Subject: [PATCH 13/27] @uppy/drag-drop: refine type of private variables (#5026) --- packages/@uppy/drag-drop/src/DragDrop.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/@uppy/drag-drop/src/DragDrop.tsx b/packages/@uppy/drag-drop/src/DragDrop.tsx index a04ca85953..d53381d140 100644 --- a/packages/@uppy/drag-drop/src/DragDrop.tsx +++ b/packages/@uppy/drag-drop/src/DragDrop.tsx @@ -45,9 +45,9 @@ export default class DragDrop extends UIPlugin< // Check for browser dragDrop support private isDragDropSupported = isDragDropSupported() - private removeDragOverClassTimeout: ReturnType + private removeDragOverClassTimeout!: ReturnType - private fileInputRef: HTMLInputElement + private fileInputRef!: HTMLInputElement constructor(uppy: Uppy, opts?: DragDropOptions) { super(uppy, { @@ -79,7 +79,7 @@ export default class DragDrop extends UIPlugin< try { this.uppy.addFiles(descriptors) } catch (err) { - this.uppy.log(err) + this.uppy.log(err as any) } } From 203d9a43de1d035ee1071aea9fedc43c32e1de5a Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Tue, 26 Mar 2024 22:56:47 +0100 Subject: [PATCH 14/27] @uppy/dashboard: refine type of private variables (#5027) --- packages/@uppy/dashboard/src/Dashboard.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/@uppy/dashboard/src/Dashboard.tsx b/packages/@uppy/dashboard/src/Dashboard.tsx index 727a285cb3..8d435b9e45 100644 --- a/packages/@uppy/dashboard/src/Dashboard.tsx +++ b/packages/@uppy/dashboard/src/Dashboard.tsx @@ -240,7 +240,7 @@ export default class Dashboard extends UIPlugin< > { static VERSION = packageJson.version - #disabledNodes: HTMLElement[] | null + #disabledNodes!: HTMLElement[] | null private modalName = `uppy-Dashboard-${nanoid()}` @@ -248,22 +248,22 @@ export default class Dashboard extends UIPlugin< private ifFocusedOnUppyRecently = false - private dashboardIsDisabled: boolean + private dashboardIsDisabled!: boolean - private savedScrollPosition: number + private savedScrollPosition!: number - private savedActiveElement: HTMLElement + private savedActiveElement!: HTMLElement - private resizeObserver: ResizeObserver + private resizeObserver!: ResizeObserver - private darkModeMediaQuery: MediaQueryList | null + private darkModeMediaQuery!: MediaQueryList | null // Timeouts - private makeDashboardInsidesVisibleAnywayTimeout: ReturnType< + private makeDashboardInsidesVisibleAnywayTimeout!: ReturnType< typeof setTimeout > - private removeDragOverClassTimeout: ReturnType + private removeDragOverClassTimeout!: ReturnType constructor(uppy: Uppy, opts?: DashboardOptions) { // support for the legacy `autoOpenFileEditor` option, @@ -600,7 +600,7 @@ export default class Dashboard extends UIPlugin< try { this.uppy.addFiles(descriptors) } catch (err) { - this.uppy.log(err) + this.uppy.log(err as any) } } From fdf47e5c8e35d151d583674f673d692528eadf10 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Tue, 26 Mar 2024 22:59:27 +0100 Subject: [PATCH 15/27] @uppy/core: refine type of private variables (#5028) --- packages/@uppy/core/src/UIPlugin.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/@uppy/core/src/UIPlugin.ts b/packages/@uppy/core/src/UIPlugin.ts index e2d61291cb..39f4e4029b 100644 --- a/packages/@uppy/core/src/UIPlugin.ts +++ b/packages/@uppy/core/src/UIPlugin.ts @@ -45,15 +45,15 @@ class UIPlugin< B extends Body, PluginState extends Record = Record, > extends BasePlugin { - #updateUI: (state: Partial>) => void + #updateUI!: (state: Partial>) => void - isTargetDOMEl: boolean + isTargetDOMEl!: boolean - el: HTMLElement | null + el!: HTMLElement | null parent: unknown - title: string + title!: string getTargetPlugin( target: PluginTarget, // eslint-disable-line no-use-before-define From 462e8c61bdd873a43467ed11c4619dc7a1136bff Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Wed, 27 Mar 2024 11:03:34 +0100 Subject: [PATCH 16/27] @uppy/react: refactor to TS (#5012) --- .eslintrc.js | 1 + packages/@uppy/core/src/Uppy.ts | 2 +- packages/@uppy/react/.npmignore | 1 + packages/@uppy/react/src/Dashboard.js | 69 ------ packages/@uppy/react/src/Dashboard.ts | 109 ++++++++++ packages/@uppy/react/src/DashboardModal.js | 157 -------------- packages/@uppy/react/src/DashboardModal.ts | 196 ++++++++++++++++++ packages/@uppy/react/src/DragDrop.js | 93 --------- packages/@uppy/react/src/DragDrop.ts | 99 +++++++++ packages/@uppy/react/src/FileInput.js | 73 ------- packages/@uppy/react/src/FileInput.ts | 89 ++++++++ packages/@uppy/react/src/ProgressBar.js | 76 ------- packages/@uppy/react/src/ProgressBar.ts | 90 ++++++++ packages/@uppy/react/src/StatusBar.js | 101 --------- packages/@uppy/react/src/StatusBar.ts | 114 ++++++++++ packages/@uppy/react/src/Wrapper.js | 57 ----- packages/@uppy/react/src/Wrapper.ts | 59 ++++++ .../src/{getHTMLProps.js => getHTMLProps.ts} | 10 +- packages/@uppy/react/src/index.js | 7 - packages/@uppy/react/src/index.ts | 7 + .../react/src/nonHtmlPropsHaveChanged.js | 3 - .../react/src/nonHtmlPropsHaveChanged.ts | 7 + .../react/src/{propTypes.js => propTypes.ts} | 35 +--- .../react/src/{useUppy.js => useUppy.ts} | 15 +- packages/@uppy/react/tsconfig.build.json | 50 +++++ packages/@uppy/react/tsconfig.json | 46 ++++ .../react/{src => types}/CommonTypes.d.ts | 0 .../@uppy/react/{src => types}/Dashboard.d.ts | 0 .../react/{src => types}/DashboardModal.d.ts | 0 .../@uppy/react/{src => types}/DragDrop.d.ts | 0 .../@uppy/react/{src => types}/FileInput.d.ts | 0 .../react/{src => types}/ProgressBar.d.ts | 0 .../@uppy/react/{src => types}/StatusBar.d.ts | 0 packages/@uppy/react/types/index.d.ts | 14 +- .../@uppy/react/{src => types}/useUppy.d.ts | 0 35 files changed, 896 insertions(+), 684 deletions(-) create mode 100644 packages/@uppy/react/.npmignore delete mode 100644 packages/@uppy/react/src/Dashboard.js create mode 100644 packages/@uppy/react/src/Dashboard.ts delete mode 100644 packages/@uppy/react/src/DashboardModal.js create mode 100644 packages/@uppy/react/src/DashboardModal.ts delete mode 100644 packages/@uppy/react/src/DragDrop.js create mode 100644 packages/@uppy/react/src/DragDrop.ts delete mode 100644 packages/@uppy/react/src/FileInput.js create mode 100644 packages/@uppy/react/src/FileInput.ts delete mode 100644 packages/@uppy/react/src/ProgressBar.js create mode 100644 packages/@uppy/react/src/ProgressBar.ts delete mode 100644 packages/@uppy/react/src/StatusBar.js create mode 100644 packages/@uppy/react/src/StatusBar.ts delete mode 100644 packages/@uppy/react/src/Wrapper.js create mode 100644 packages/@uppy/react/src/Wrapper.ts rename packages/@uppy/react/src/{getHTMLProps.js => getHTMLProps.ts} (95%) delete mode 100644 packages/@uppy/react/src/index.js create mode 100644 packages/@uppy/react/src/index.ts delete mode 100644 packages/@uppy/react/src/nonHtmlPropsHaveChanged.js create mode 100644 packages/@uppy/react/src/nonHtmlPropsHaveChanged.ts rename packages/@uppy/react/src/{propTypes.js => propTypes.ts} (52%) rename packages/@uppy/react/src/{useUppy.js => useUppy.ts} (50%) create mode 100644 packages/@uppy/react/tsconfig.build.json create mode 100644 packages/@uppy/react/tsconfig.json rename packages/@uppy/react/{src => types}/CommonTypes.d.ts (100%) rename packages/@uppy/react/{src => types}/Dashboard.d.ts (100%) rename packages/@uppy/react/{src => types}/DashboardModal.d.ts (100%) rename packages/@uppy/react/{src => types}/DragDrop.d.ts (100%) rename packages/@uppy/react/{src => types}/FileInput.d.ts (100%) rename packages/@uppy/react/{src => types}/ProgressBar.d.ts (100%) rename packages/@uppy/react/{src => types}/StatusBar.d.ts (100%) rename packages/@uppy/react/{src => types}/useUppy.d.ts (100%) diff --git a/.eslintrc.js b/.eslintrc.js index 74c9677e37..993b124f46 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -93,6 +93,7 @@ module.exports = { 'react/prefer-stateless-function': 'error', 'react/sort-comp': 'error', 'react/style-prop-object': 'error', + 'react/static-property-placement': 'off', // accessibility 'jsx-a11y/alt-text': 'error', diff --git a/packages/@uppy/core/src/Uppy.ts b/packages/@uppy/core/src/Uppy.ts index f813b22421..4be7aa2578 100644 --- a/packages/@uppy/core/src/Uppy.ts +++ b/packages/@uppy/core/src/Uppy.ts @@ -54,7 +54,7 @@ type Processor = ( uploadID: string, ) => Promise | void -type FileRemoveReason = 'user' | 'cancel-all' +type FileRemoveReason = 'user' | 'cancel-all' | 'unmount' type LogLevel = 'info' | 'warning' | 'error' | 'success' diff --git a/packages/@uppy/react/.npmignore b/packages/@uppy/react/.npmignore new file mode 100644 index 0000000000..6c816673f0 --- /dev/null +++ b/packages/@uppy/react/.npmignore @@ -0,0 +1 @@ +tsconfig.* diff --git a/packages/@uppy/react/src/Dashboard.js b/packages/@uppy/react/src/Dashboard.js deleted file mode 100644 index 5c8c9f38af..0000000000 --- a/packages/@uppy/react/src/Dashboard.js +++ /dev/null @@ -1,69 +0,0 @@ -import { createElement as h, Component } from 'react' -import DashboardPlugin from '@uppy/dashboard' -import { dashboard as basePropTypes } from './propTypes.js' -import getHTMLProps from './getHTMLProps.js' -import nonHtmlPropsHaveChanged from './nonHtmlPropsHaveChanged.js' - -/** - * React Component that renders a Dashboard for an Uppy instance. This component - * renders the Dashboard inline, so you can put it anywhere you want. - */ - -class Dashboard extends Component { - componentDidMount () { - this.installPlugin() - } - - componentDidUpdate (prevProps) { - // eslint-disable-next-line react/destructuring-assignment - if (prevProps.uppy !== this.props.uppy) { - this.uninstallPlugin(prevProps) - this.installPlugin() - } else if (nonHtmlPropsHaveChanged(this.props, prevProps)) { - const options = { ...this.props, target: this.container } - delete options.uppy - this.plugin.setOptions(options) - } - } - - componentWillUnmount () { - this.uninstallPlugin() - } - - installPlugin () { - const { uppy } = this.props - const options = { - id: 'react:Dashboard', - ...this.props, - target: this.container, - } - delete options.uppy - uppy.use(DashboardPlugin, options) - - this.plugin = uppy.getPlugin(options.id) - } - - uninstallPlugin (props = this.props) { - const { uppy } = props - - uppy.removePlugin(this.plugin) - } - - render () { - return h('div', { - className: 'uppy-Container', - ref: (container) => { - this.container = container - }, - ...getHTMLProps(this.props), - }) - } -} - -Dashboard.propTypes = basePropTypes - -Dashboard.defaultProps = { - inline: true, -} - -export default Dashboard diff --git a/packages/@uppy/react/src/Dashboard.ts b/packages/@uppy/react/src/Dashboard.ts new file mode 100644 index 0000000000..40c707b0d8 --- /dev/null +++ b/packages/@uppy/react/src/Dashboard.ts @@ -0,0 +1,109 @@ +import { createElement as h, Component } from 'react' +import PropTypes from 'prop-types' +import type { UnknownPlugin, Uppy } from '@uppy/core' +import DashboardPlugin from '@uppy/dashboard' +import type { Body, Meta } from '@uppy/utils/lib/UppyFile' +import type { DashboardOptions } from '@uppy/dashboard' +import { + locale, + uppy as uppyPropType, + plugins, + metaFields, + cssSize, +} from './propTypes.ts' +import getHTMLProps from './getHTMLProps.ts' +import nonHtmlPropsHaveChanged from './nonHtmlPropsHaveChanged.ts' + +type DashboardInlineOptions = Omit< + DashboardOptions & { inline: true }, + 'inline' +> & + React.BaseHTMLAttributes + +export interface DashboardProps + extends DashboardInlineOptions { + uppy: Uppy +} + +/** + * React Component that renders a Dashboard for an Uppy instance. This component + * renders the Dashboard inline, so you can put it anywhere you want. + */ + +class Dashboard extends Component< + DashboardProps +> { + static propsTypes = { + uppy: uppyPropType, + disableInformer: PropTypes.bool, + disableStatusBar: PropTypes.bool, + disableThumbnailGenerator: PropTypes.bool, + height: cssSize, + hideProgressAfterFinish: PropTypes.bool, + hideUploadButton: PropTypes.bool, + locale, + metaFields, + note: PropTypes.string, + plugins, + proudlyDisplayPoweredByUppy: PropTypes.bool, + showProgressDetails: PropTypes.bool, + width: cssSize, + // pass-through to ThumbnailGenerator + thumbnailType: PropTypes.string, + thumbnailWidth: PropTypes.number, + } + + private container: HTMLElement + + private plugin: UnknownPlugin + + componentDidMount(): void { + this.installPlugin() + } + + componentDidUpdate(prevProps: Dashboard['props']): void { + // eslint-disable-next-line react/destructuring-assignment + if (prevProps.uppy !== this.props.uppy) { + this.uninstallPlugin(prevProps) + this.installPlugin() + } else if (nonHtmlPropsHaveChanged(this.props, prevProps)) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { uppy, ...options } = { ...this.props, target: this.container } + this.plugin.setOptions(options) + } + } + + componentWillUnmount(): void { + this.uninstallPlugin() + } + + installPlugin(): void { + const { uppy, ...options } = { + id: 'react:Dashboard', + inline: true, + ...this.props, + target: this.container, + } + uppy.use(DashboardPlugin, options) + + this.plugin = uppy.getPlugin(options.id)! + } + + uninstallPlugin(props = this.props): void { + const { uppy } = props + + uppy.removePlugin(this.plugin) + } + + render(): JSX.Element { + return h('div', { + className: 'uppy-Container', + ref: (container: HTMLElement): void => { + this.container = container + }, + ...getHTMLProps(this.props), + }) + } +} + +export default Dashboard diff --git a/packages/@uppy/react/src/DashboardModal.js b/packages/@uppy/react/src/DashboardModal.js deleted file mode 100644 index d11cfbacfc..0000000000 --- a/packages/@uppy/react/src/DashboardModal.js +++ /dev/null @@ -1,157 +0,0 @@ -import { createElement as h, Component } from 'react' -import PropTypes from 'prop-types' -import DashboardPlugin from '@uppy/dashboard' -import { cssSize, locale, metaFields, plugins, uppy as uppyPropType } from './propTypes.js' -import getHTMLProps from './getHTMLProps.js' -import nonHtmlPropsHaveChanged from './nonHtmlPropsHaveChanged.js' - -/** - * React Component that renders a Dashboard for an Uppy instance in a Modal - * dialog. Visibility of the Modal is toggled using the `open` prop. - */ - -class DashboardModal extends Component { - componentDidMount () { - this.installPlugin() - } - - componentDidUpdate (prevProps) { - const { uppy, open, onRequestClose } = this.props - if (prevProps.uppy !== uppy) { - this.uninstallPlugin(prevProps) - this.installPlugin() - } else if (nonHtmlPropsHaveChanged(this.props, prevProps)) { - const options = { ...this.props, onRequestCloseModal: onRequestClose } - delete options.uppy - this.plugin.setOptions(options) - } - if (prevProps.open && !open) { - this.plugin.closeModal() - } else if (!prevProps.open && open) { - this.plugin.openModal() - } - } - - componentWillUnmount () { - this.uninstallPlugin() - } - - installPlugin () { - const { id='react:DashboardModal', target=this.container, open, onRequestClose, uppy } = this.props - const options = { - ...this.props, - id, - target, - onRequestCloseModal: onRequestClose, - } - delete options.uppy - - uppy.use(DashboardPlugin, options) - - this.plugin = uppy.getPlugin(options.id) - if (open) { - this.plugin.openModal() - } - } - - uninstallPlugin (props = this.props) { - const { uppy } = props - - uppy.removePlugin(this.plugin) - } - - render () { - return h('div', { - className: 'uppy-Container', - ref: (container) => { - this.container = container - }, - ...getHTMLProps(this.props), - }) - } -} - -/* eslint-disable react/no-unused-prop-types */ -DashboardModal.propTypes = { - uppy: uppyPropType.isRequired, - target: typeof window !== 'undefined' ? PropTypes.instanceOf(window.HTMLElement) : PropTypes.any, - open: PropTypes.bool, - onRequestClose: PropTypes.func, - closeModalOnClickOutside: PropTypes.bool, - disablePageScrollWhenModalOpen: PropTypes.bool, - inline: PropTypes.bool, - plugins, - width: cssSize, - height: cssSize, - showProgressDetails: PropTypes.bool, - note: PropTypes.string, - metaFields, - proudlyDisplayPoweredByUppy: PropTypes.bool, - autoOpen: PropTypes.oneOf(['imageEditor', 'metaEditor', null]), - animateOpenClose: PropTypes.bool, - browserBackButtonClose: PropTypes.bool, - closeAfterFinish: PropTypes.bool, - disableStatusBar: PropTypes.bool, - disableInformer: PropTypes.bool, - disableThumbnailGenerator: PropTypes.bool, - disableLocalFiles: PropTypes.bool, - disabled: PropTypes.bool, - hideCancelButton: PropTypes.bool, - hidePauseResumeButton: PropTypes.bool, - hideProgressAfterFinish: PropTypes.bool, - hideRetryButton: PropTypes.bool, - hideUploadButton: PropTypes.bool, - showLinkToFileUploadResult: PropTypes.bool, - showRemoveButtonAfterComplete: PropTypes.bool, - showSelectedFiles: PropTypes.bool, - waitForThumbnailsBeforeUpload: PropTypes.bool, - fileManagerSelectionType: PropTypes.string, - theme: PropTypes.string, - // pass-through to ThumbnailGenerator - thumbnailType: PropTypes.string, - thumbnailWidth: PropTypes.number, - locale, -} -// Must be kept in sync with @uppy/dashboard/src/Dashboard.tsx. -DashboardModal.defaultProps = { - metaFields: [], - plugins: [], - inline: false, - width: 750, - height: 550, - thumbnailWidth: 280, - thumbnailType: 'image/jpeg', - waitForThumbnailsBeforeUpload: false, - showLinkToFileUploadResult: false, - showProgressDetails: false, - hideUploadButton: false, - hideCancelButton: false, - hideRetryButton: false, - hidePauseResumeButton: false, - hideProgressAfterFinish: false, - note: null, - closeModalOnClickOutside: false, - closeAfterFinish: false, - disableStatusBar: false, - disableInformer: false, - disableThumbnailGenerator: false, - disablePageScrollWhenModalOpen: true, - animateOpenClose: true, - fileManagerSelectionType: 'files', - proudlyDisplayPoweredByUppy: true, - showSelectedFiles: true, - showRemoveButtonAfterComplete: false, - browserBackButtonClose: false, - theme: 'light', - autoOpen: false, - disabled: false, - disableLocalFiles: false, - - // extra - open: undefined, - target: undefined, - locale: null, - onRequestClose: undefined, -} - -export default DashboardModal diff --git a/packages/@uppy/react/src/DashboardModal.ts b/packages/@uppy/react/src/DashboardModal.ts new file mode 100644 index 0000000000..e7f54d7246 --- /dev/null +++ b/packages/@uppy/react/src/DashboardModal.ts @@ -0,0 +1,196 @@ +import { createElement as h, Component } from 'react' +import PropTypes from 'prop-types' +import DashboardPlugin, { type DashboardOptions } from '@uppy/dashboard' +import type { Body, Meta } from '@uppy/utils/lib/UppyFile' +import type { Uppy } from '@uppy/core' +import { + cssSize, + locale, + metaFields, + plugins, + uppy as uppyPropType, +} from './propTypes.ts' +import getHTMLProps from './getHTMLProps.ts' +import nonHtmlPropsHaveChanged from './nonHtmlPropsHaveChanged.ts' + +type DashboardInlineOptions = Omit< + DashboardOptions & { inline: false }, + 'inline' | 'onRequestCloseModal' +> & + React.BaseHTMLAttributes + +export interface DashboardModalProps + extends DashboardInlineOptions { + uppy: Uppy + onRequestClose: () => void + open: boolean +} + +/** + * React Component that renders a Dashboard for an Uppy instance in a Modal + * dialog. Visibility of the Modal is toggled using the `open` prop. + */ + +class DashboardModal extends Component< + DashboardModalProps +> { + static propTypes = { + uppy: uppyPropType.isRequired, + target: + typeof window !== 'undefined' ? + PropTypes.instanceOf(window.HTMLElement) + : PropTypes.any, + open: PropTypes.bool, + onRequestClose: PropTypes.func, + closeModalOnClickOutside: PropTypes.bool, + disablePageScrollWhenModalOpen: PropTypes.bool, + plugins, + width: cssSize, + height: cssSize, + showProgressDetails: PropTypes.bool, + note: PropTypes.string, + metaFields, + proudlyDisplayPoweredByUppy: PropTypes.bool, + autoOpenFileEditor: PropTypes.bool, + animateOpenClose: PropTypes.bool, + browserBackButtonClose: PropTypes.bool, + closeAfterFinish: PropTypes.bool, + disableStatusBar: PropTypes.bool, + disableInformer: PropTypes.bool, + disableThumbnailGenerator: PropTypes.bool, + disableLocalFiles: PropTypes.bool, + disabled: PropTypes.bool, + hideCancelButton: PropTypes.bool, + hidePauseResumeButton: PropTypes.bool, + hideProgressAfterFinish: PropTypes.bool, + hideRetryButton: PropTypes.bool, + hideUploadButton: PropTypes.bool, + showLinkToFileUploadResult: PropTypes.bool, + showRemoveButtonAfterComplete: PropTypes.bool, + showSelectedFiles: PropTypes.bool, + waitForThumbnailsBeforeUpload: PropTypes.bool, + fileManagerSelectionType: PropTypes.string, + theme: PropTypes.string, + // pass-through to ThumbnailGenerator + thumbnailType: PropTypes.string, + thumbnailWidth: PropTypes.number, + locale, + } + + // Must be kept in sync with @uppy/dashboard/src/Dashboard.jsx. + static defaultProps = { + metaFields: [], + plugins: [], + width: 750, + height: 550, + thumbnailWidth: 280, + thumbnailType: 'image/jpeg', + waitForThumbnailsBeforeUpload: false, + showLinkToFileUploadResult: false, + showProgressDetails: false, + hideUploadButton: false, + hideCancelButton: false, + hideRetryButton: false, + hidePauseResumeButton: false, + hideProgressAfterFinish: false, + note: null, + closeModalOnClickOutside: false, + closeAfterFinish: false, + disableStatusBar: false, + disableInformer: false, + disableThumbnailGenerator: false, + disablePageScrollWhenModalOpen: true, + animateOpenClose: true, + fileManagerSelectionType: 'files', + proudlyDisplayPoweredByUppy: true, + showSelectedFiles: true, + showRemoveButtonAfterComplete: false, + browserBackButtonClose: false, + theme: 'light', + autoOpenFileEditor: false, + disabled: false, + disableLocalFiles: false, + + // extra + open: undefined, + target: undefined, + locale: null, + onRequestClose: undefined, + } + + private container: HTMLElement + + private plugin: DashboardPlugin + + componentDidMount(): void { + this.installPlugin() + } + + componentDidUpdate(prevProps: DashboardModal['props']): void { + const { uppy, open, onRequestClose } = this.props + if (prevProps.uppy !== uppy) { + this.uninstallPlugin(prevProps) + this.installPlugin() + } else if (nonHtmlPropsHaveChanged(this.props, prevProps)) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars,no-shadow + const { uppy, ...options } = { + ...this.props, + inline: false, + onRequestCloseModal: onRequestClose, + } + this.plugin.setOptions(options) + } + if (prevProps.open && !open) { + this.plugin.closeModal() + } else if (!prevProps.open && open) { + this.plugin.openModal() + } + } + + componentWillUnmount(): void { + this.uninstallPlugin() + } + + installPlugin(): void { + const { + target = this.container, + open, + onRequestClose, + uppy, + ...rest + } = this.props + const options = { + ...rest, + id: 'react:DashboardModal', + inline: false, + target, + open, + onRequestCloseModal: onRequestClose, + } + + uppy.use(DashboardPlugin, options) + + this.plugin = uppy.getPlugin(options.id) as DashboardPlugin + if (open) { + this.plugin.openModal() + } + } + + uninstallPlugin(props = this.props): void { + const { uppy } = props + + uppy.removePlugin(this.plugin) + } + + render(): JSX.Element { + return h('div', { + className: 'uppy-Container', + ref: (container: HTMLElement) => { + this.container = container + }, + ...getHTMLProps(this.props), + }) + } +} + +export default DashboardModal diff --git a/packages/@uppy/react/src/DragDrop.js b/packages/@uppy/react/src/DragDrop.js deleted file mode 100644 index 581b84d8ea..0000000000 --- a/packages/@uppy/react/src/DragDrop.js +++ /dev/null @@ -1,93 +0,0 @@ -import { createElement as h, Component } from 'react' -import PropTypes from 'prop-types' -import DragDropPlugin from '@uppy/drag-drop' -import * as propTypes from './propTypes.js' -import getHTMLProps from './getHTMLProps.js' -import nonHtmlPropsHaveChanged from './nonHtmlPropsHaveChanged.js' - -/** - * React component that renders an area in which files can be dropped to be - * uploaded. - */ - -class DragDrop extends Component { - componentDidMount () { - this.installPlugin() - } - - componentDidUpdate (prevProps) { - // eslint-disable-next-line react/destructuring-assignment - if (prevProps.uppy !== this.props.uppy) { - this.uninstallPlugin(prevProps) - this.installPlugin() - } else if (nonHtmlPropsHaveChanged(this.props, prevProps)) { - const options = { ...this.props, target: this.container } - delete options.uppy - this.plugin.setOptions(options) - } - } - - componentWillUnmount () { - this.uninstallPlugin() - } - - installPlugin () { - const { - uppy, - locale, - inputName, - width, - height, - note, - } = this.props - const options = { - id: 'react:DragDrop', - locale, - inputName, - width, - height, - note, - target: this.container, - } - delete options.uppy - - uppy.use(DragDropPlugin, options) - - this.plugin = uppy.getPlugin(options.id) - } - - uninstallPlugin (props = this.props) { - const { uppy } = props - - uppy.removePlugin(this.plugin) - } - - render () { - return h('div', { - className: 'uppy-Container', - ref: (container) => { - this.container = container - }, - ...getHTMLProps(this.props), - }) - } -} - -DragDrop.propTypes = { - uppy: propTypes.uppy.isRequired, - locale: propTypes.locale, - inputName: PropTypes.string, - width: PropTypes.string, - height: PropTypes.string, - note: PropTypes.string, -} -// Must be kept in sync with @uppy/drag-drop/src/DragDrop.jsx. -DragDrop.defaultProps = { - locale: null, - inputName: 'files[]', - width: '100%', - height: '100%', - note: null, -} - -export default DragDrop diff --git a/packages/@uppy/react/src/DragDrop.ts b/packages/@uppy/react/src/DragDrop.ts new file mode 100644 index 0000000000..387955ddc0 --- /dev/null +++ b/packages/@uppy/react/src/DragDrop.ts @@ -0,0 +1,99 @@ +import { createElement as h, Component } from 'react' +import PropTypes from 'prop-types' +import type { UnknownPlugin, Uppy } from '@uppy/core' +import DragDropPlugin, { type DragDropOptions } from '@uppy/drag-drop' +import type { Body, Meta } from '@uppy/utils/lib/UppyFile' +import * as propTypes from './propTypes.ts' +import getHTMLProps from './getHTMLProps.ts' +import nonHtmlPropsHaveChanged from './nonHtmlPropsHaveChanged.ts' + +interface DragDropProps + extends DragDropOptions { + uppy: Uppy +} + +/** + * React component that renders an area in which files can be dropped to be + * uploaded. + */ + +class DragDrop extends Component< + DragDropProps +> { + static propTypes = { + uppy: propTypes.uppy.isRequired, + locale: propTypes.locale, + inputName: PropTypes.string, + width: PropTypes.string, + height: PropTypes.string, + note: PropTypes.string, + } + + // Must be kept in sync with @uppy/drag-drop/src/DragDrop.jsx. + static defaultProps = { + locale: null, + inputName: 'files[]', + width: '100%', + height: '100%', + note: null, + } + + private container: HTMLElement + + private plugin: UnknownPlugin + + componentDidMount(): void { + this.installPlugin() + } + + componentDidUpdate(prevProps: DragDrop['props']): void { + // eslint-disable-next-line react/destructuring-assignment + if (prevProps.uppy !== this.props.uppy) { + this.uninstallPlugin(prevProps) + this.installPlugin() + } else if (nonHtmlPropsHaveChanged(this.props, prevProps)) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { uppy, ...options } = { ...this.props, target: this.container } + this.plugin.setOptions(options) + } + } + + componentWillUnmount(): void { + this.uninstallPlugin() + } + + installPlugin(): void { + const { uppy, locale, inputName, width, height, note } = this.props + const options = { + id: 'react:DragDrop', + locale, + inputName, + width, + height, + note, + target: this.container, + } + + uppy.use(DragDropPlugin, options) + + this.plugin = uppy.getPlugin(options.id)! + } + + uninstallPlugin(props = this.props): void { + const { uppy } = props + + uppy.removePlugin(this.plugin) + } + + render(): JSX.Element { + return h('div', { + className: 'uppy-Container', + ref: (container: HTMLElement) => { + this.container = container + }, + ...getHTMLProps(this.props), + }) + } +} + +export default DragDrop diff --git a/packages/@uppy/react/src/FileInput.js b/packages/@uppy/react/src/FileInput.js deleted file mode 100644 index dd84137af2..0000000000 --- a/packages/@uppy/react/src/FileInput.js +++ /dev/null @@ -1,73 +0,0 @@ -import { createElement as h, Component } from 'react' -import PropTypes from 'prop-types' -import FileInputPlugin from '@uppy/file-input' -import * as propTypes from './propTypes.js' - -/** - * React component that renders an area in which files can be dropped to be - * uploaded. - */ - -class FileInput extends Component { - componentDidMount () { - this.installPlugin() - } - - componentDidUpdate (prevProps) { - // eslint-disable-next-line react/destructuring-assignment - if (prevProps.uppy !== this.props.uppy) { - this.uninstallPlugin(prevProps) - this.installPlugin() - } - } - - componentWillUnmount () { - this.uninstallPlugin() - } - - installPlugin () { - const { uppy, locale, pretty, inputName } = this.props - const options = { - id: 'react:FileInput', - locale, - pretty, - inputName, - target: this.container, - } - delete options.uppy - - uppy.use(FileInputPlugin, options) - - this.plugin = uppy.getPlugin(options.id) - } - - uninstallPlugin (props = this.props) { - const { uppy } = props - - uppy.removePlugin(this.plugin) - } - - render () { - return h('div', { - className: 'uppy-Container', - ref: (container) => { - this.container = container - }, - }) - } -} - -FileInput.propTypes = { - uppy: propTypes.uppy.isRequired, - locale: propTypes.locale, - pretty: PropTypes.bool, - inputName: PropTypes.string, -} -// Must be kept in sync with @uppy/file-input/src/FileInput.jsx -FileInput.defaultProps = { - locale: undefined, - pretty: true, - inputName: 'files[]', -} - -export default FileInput diff --git a/packages/@uppy/react/src/FileInput.ts b/packages/@uppy/react/src/FileInput.ts new file mode 100644 index 0000000000..5148084f5a --- /dev/null +++ b/packages/@uppy/react/src/FileInput.ts @@ -0,0 +1,89 @@ +import { createElement as h, Component } from 'react' +import PropTypes from 'prop-types' +import type { UnknownPlugin, Uppy } from '@uppy/core' +import FileInputPlugin from '@uppy/file-input' +import type { Body, Meta } from '@uppy/utils/lib/UppyFile' +import type { Locale } from '@uppy/utils/lib/Translator' +import * as propTypes from './propTypes.ts' + +interface FileInputProps { + uppy: Uppy + locale?: Locale + pretty?: boolean + inputName?: string +} + +/** + * React component that renders an area in which files can be dropped to be + * uploaded. + */ + +class FileInput extends Component< + FileInputProps +> { + static propTypes = { + uppy: propTypes.uppy.isRequired, + locale: propTypes.locale, + pretty: PropTypes.bool, + inputName: PropTypes.string, + } + + // Must be kept in sync with @uppy/file-input/src/FileInput.jsx + static defaultProps = { + locale: undefined, + pretty: true, + inputName: 'files[]', + } + + private container: HTMLElement + + private plugin: UnknownPlugin + + componentDidMount(): void { + this.installPlugin() + } + + componentDidUpdate(prevProps: FileInputProps): void { + // eslint-disable-next-line react/destructuring-assignment + if (prevProps.uppy !== this.props.uppy) { + this.uninstallPlugin(prevProps) + this.installPlugin() + } + } + + componentWillUnmount(): void { + this.uninstallPlugin() + } + + installPlugin(): void { + const { uppy, locale, pretty, inputName } = this.props + const options = { + id: 'react:FileInput', + locale, + pretty, + inputName, + target: this.container, + } + + uppy.use(FileInputPlugin, options) + + this.plugin = uppy.getPlugin(options.id)! + } + + uninstallPlugin(props = this.props): void { + const { uppy } = props + + uppy.removePlugin(this.plugin) + } + + render(): JSX.Element { + return h('div', { + className: 'uppy-Container', + ref: (container: HTMLElement) => { + this.container = container + }, + }) + } +} + +export default FileInput diff --git a/packages/@uppy/react/src/ProgressBar.js b/packages/@uppy/react/src/ProgressBar.js deleted file mode 100644 index fefcd6db1f..0000000000 --- a/packages/@uppy/react/src/ProgressBar.js +++ /dev/null @@ -1,76 +0,0 @@ -import { createElement as h, Component } from 'react' -import PropTypes from 'prop-types' -import ProgressBarPlugin from '@uppy/progress-bar' -import { uppy as uppyPropType } from './propTypes.js' -import getHTMLProps from './getHTMLProps.js' -import nonHtmlPropsHaveChanged from './nonHtmlPropsHaveChanged.js' - -/** - * React component that renders a progress bar at the top of the page. - */ - -class ProgressBar extends Component { - componentDidMount () { - this.installPlugin() - } - - componentDidUpdate (prevProps) { - // eslint-disable-next-line react/destructuring-assignment - if (prevProps.uppy !== this.props.uppy) { - this.uninstallPlugin(prevProps) - this.installPlugin() - } else if (nonHtmlPropsHaveChanged(this.props, prevProps)) { - const options = { ...this.props, target: this.container } - delete options.uppy - this.plugin.setOptions(options) - } - } - - componentWillUnmount () { - this.uninstallPlugin() - } - - installPlugin () { - const { uppy, fixed, hideAfterFinish } = this.props - const options = { - id: 'react:ProgressBar', - fixed, - hideAfterFinish, - target: this.container, - } - delete options.uppy - - uppy.use(ProgressBarPlugin, options) - - this.plugin = uppy.getPlugin(options.id) - } - - uninstallPlugin (props = this.props) { - const { uppy } = props - - uppy.removePlugin(this.plugin) - } - - render () { - return h('div', { - className: 'uppy-Container', - ref: (container) => { - this.container = container - }, - ...getHTMLProps(this.props), - }) - } -} - -ProgressBar.propTypes = { - uppy: uppyPropType.isRequired, - fixed: PropTypes.bool, - hideAfterFinish: PropTypes.bool, -} -// Must be kept in sync with @uppy/progress-bar/src/ProgressBar.jsx -ProgressBar.defaultProps = { - fixed: false, - hideAfterFinish: true, -} - -export default ProgressBar diff --git a/packages/@uppy/react/src/ProgressBar.ts b/packages/@uppy/react/src/ProgressBar.ts new file mode 100644 index 0000000000..9879850cdb --- /dev/null +++ b/packages/@uppy/react/src/ProgressBar.ts @@ -0,0 +1,90 @@ +import { createElement as h, Component } from 'react' +import PropTypes from 'prop-types' +import type { UnknownPlugin, Uppy } from '@uppy/core' +import ProgressBarPlugin from '@uppy/progress-bar' +import type { Body, Meta } from '@uppy/utils/lib/UppyFile' +import { uppy as uppyPropType } from './propTypes.ts' +import getHTMLProps from './getHTMLProps.ts' +import nonHtmlPropsHaveChanged from './nonHtmlPropsHaveChanged.ts' + +interface ProgressBarProps { + uppy: Uppy + fixed?: boolean + hideAfterFinish?: boolean +} + +/** + * React component that renders a progress bar at the top of the page. + */ + +class ProgressBar extends Component< + ProgressBarProps +> { + static propTypes = { + uppy: uppyPropType.isRequired, + fixed: PropTypes.bool, + hideAfterFinish: PropTypes.bool, + } + + // Must be kept in sync with @uppy/progress-bar/src/ProgressBar.jsx + static defaultProps = { + fixed: false, + hideAfterFinish: true, + } + + private container: HTMLElement + + private plugin: UnknownPlugin + + componentDidMount(): void { + this.installPlugin() + } + + componentDidUpdate(prevProps: ProgressBar['props']): void { + // eslint-disable-next-line react/destructuring-assignment + if (prevProps.uppy !== this.props.uppy) { + this.uninstallPlugin(prevProps) + this.installPlugin() + } else if (nonHtmlPropsHaveChanged(this.props, prevProps)) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { uppy, ...options } = { ...this.props, target: this.container } + this.plugin.setOptions(options) + } + } + + componentWillUnmount(): void { + this.uninstallPlugin() + } + + installPlugin(): void { + const { uppy, fixed, hideAfterFinish } = this.props + const options = { + id: 'react:ProgressBar', + fixed, + hideAfterFinish, + target: this.container, + } + + uppy.use(ProgressBarPlugin, options) + + this.plugin = uppy.getPlugin(options.id)! + } + + uninstallPlugin(props = this.props): void { + const { uppy } = props + + uppy.removePlugin(this.plugin) + } + + render(): JSX.Element { + return h('div', { + className: 'uppy-Container', + ref: (container: HTMLElement) => { + this.container = container + }, + ...getHTMLProps(this.props), + }) + } +} + +export default ProgressBar diff --git a/packages/@uppy/react/src/StatusBar.js b/packages/@uppy/react/src/StatusBar.js deleted file mode 100644 index 2e48587e8a..0000000000 --- a/packages/@uppy/react/src/StatusBar.js +++ /dev/null @@ -1,101 +0,0 @@ -import { createElement as h, Component } from 'react' -import PropTypes from 'prop-types' -import StatusBarPlugin from '@uppy/status-bar' -import { uppy as uppyPropType } from './propTypes.js' -import getHTMLProps from './getHTMLProps.js' -import nonHtmlPropsHaveChanged from './nonHtmlPropsHaveChanged.js' - -/** - * React component that renders a status bar containing upload progress and speed, - * processing progress and pause/resume/cancel controls. - */ - -class StatusBar extends Component { - componentDidMount () { - this.installPlugin() - } - - componentDidUpdate (prevProps) { - // eslint-disable-next-line react/destructuring-assignment - if (prevProps.uppy !== this.props.uppy) { - this.uninstallPlugin(prevProps) - this.installPlugin() - } else if (nonHtmlPropsHaveChanged(this.props, prevProps)) { - const options = { ...this.props, target: this.container } - delete options.uppy - this.plugin.setOptions(options) - } - } - - componentWillUnmount () { - this.uninstallPlugin() - } - - installPlugin () { - const { - uppy, - hideUploadButton, - hideRetryButton, - hidePauseResumeButton, - hideCancelButton, - showProgressDetails, - hideAfterFinish, - doneButtonHandler, - } = this.props - const options = { - id: 'react:StatusBar', - hideUploadButton, - hideRetryButton, - hidePauseResumeButton, - hideCancelButton, - showProgressDetails, - hideAfterFinish, - doneButtonHandler, - target: this.container, - } - delete options.uppy - - uppy.use(StatusBarPlugin, options) - - this.plugin = uppy.getPlugin(options.id) - } - - uninstallPlugin (props = this.props) { - const { uppy } = props - - uppy.removePlugin(this.plugin) - } - - render () { - return h('div', { - className: 'uppy-Container', - ref: (container) => { - this.container = container - }, - ...getHTMLProps(this.props), - }) - } -} - -StatusBar.propTypes = { - uppy: uppyPropType.isRequired, - hideUploadButton: PropTypes.bool, - hideRetryButton: PropTypes.bool, - hidePauseResumeButton: PropTypes.bool, - hideCancelButton: PropTypes.bool, - showProgressDetails: PropTypes.bool, - hideAfterFinish: PropTypes.bool, - doneButtonHandler: PropTypes.func, -} -// Must be kept in sync with @uppy/status-bar/src/StatusBar.jsx. -StatusBar.defaultProps = { - hideUploadButton: false, - hideRetryButton: false, - hidePauseResumeButton: false, - hideCancelButton: false, - showProgressDetails: false, - hideAfterFinish: true, - doneButtonHandler: null, -} - -export default StatusBar diff --git a/packages/@uppy/react/src/StatusBar.ts b/packages/@uppy/react/src/StatusBar.ts new file mode 100644 index 0000000000..0271773d2b --- /dev/null +++ b/packages/@uppy/react/src/StatusBar.ts @@ -0,0 +1,114 @@ +import { createElement as h, Component } from 'react' +import PropTypes from 'prop-types' +import type { UnknownPlugin, Uppy } from '@uppy/core' +import StatusBarPlugin, { type StatusBarOptions } from '@uppy/status-bar' +import type { Body, Meta } from '@uppy/utils/lib/UppyFile' +import { uppy as uppyPropType } from './propTypes.ts' +import getHTMLProps from './getHTMLProps.ts' +import nonHtmlPropsHaveChanged from './nonHtmlPropsHaveChanged.ts' + +interface StatusBarProps + extends StatusBarOptions { + uppy: Uppy +} + +/** + * React component that renders a status bar containing upload progress and speed, + * processing progress and pause/resume/cancel controls. + */ + +class StatusBar extends Component< + StatusBarProps +> { + static propTypes = { + uppy: uppyPropType.isRequired, + hideUploadButton: PropTypes.bool, + hideRetryButton: PropTypes.bool, + hidePauseResumeButton: PropTypes.bool, + hideCancelButton: PropTypes.bool, + showProgressDetails: PropTypes.bool, + hideAfterFinish: PropTypes.bool, + doneButtonHandler: PropTypes.func, + } + + // Must be kept in sync with @uppy/status-bar/src/StatusBar.jsx. + static defaultProps = { + hideUploadButton: false, + hideRetryButton: false, + hidePauseResumeButton: false, + hideCancelButton: false, + showProgressDetails: false, + hideAfterFinish: true, + doneButtonHandler: null, + } + + private container: HTMLElement + + private plugin: UnknownPlugin + + componentDidMount(): void { + this.installPlugin() + } + + componentDidUpdate(prevProps: StatusBar['props']): void { + // eslint-disable-next-line react/destructuring-assignment + if (prevProps.uppy !== this.props.uppy) { + this.uninstallPlugin(prevProps) + this.installPlugin() + } else if (nonHtmlPropsHaveChanged(this.props, prevProps)) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { uppy, ...options } = { ...this.props, target: this.container } + this.plugin.setOptions(options) + } + } + + componentWillUnmount(): void { + this.uninstallPlugin() + } + + installPlugin(): void { + const { + uppy, + hideUploadButton, + hideRetryButton, + hidePauseResumeButton, + hideCancelButton, + showProgressDetails, + hideAfterFinish, + doneButtonHandler, + } = this.props + const options = { + id: 'react:StatusBar', + hideUploadButton, + hideRetryButton, + hidePauseResumeButton, + hideCancelButton, + showProgressDetails, + hideAfterFinish, + doneButtonHandler, + target: this.container, + } + + uppy.use(StatusBarPlugin, options) + + this.plugin = uppy.getPlugin(options.id)! + } + + uninstallPlugin(props = this.props): void { + const { uppy } = props + + uppy.removePlugin(this.plugin) + } + + render(): JSX.Element { + return h('div', { + className: 'uppy-Container', + ref: (container: HTMLElement) => { + this.container = container + }, + ...getHTMLProps(this.props), + }) + } +} + +export default StatusBar diff --git a/packages/@uppy/react/src/Wrapper.js b/packages/@uppy/react/src/Wrapper.js deleted file mode 100644 index 438484a18d..0000000000 --- a/packages/@uppy/react/src/Wrapper.js +++ /dev/null @@ -1,57 +0,0 @@ -import { createElement as h, Component } from 'react' -import PropTypes from 'prop-types' -import { uppy as uppyPropType } from './propTypes.js' - -class UppyWrapper extends Component { - constructor (props) { - super(props) - - this.refContainer = this.refContainer.bind(this) - } - - componentDidMount () { - this.installPlugin() - } - - componentDidUpdate (prevProps) { - const { uppy } = this.props - if (prevProps.uppy !== uppy) { - this.uninstallPlugin(prevProps) - this.installPlugin() - } - } - - componentWillUnmount () { - this.uninstallPlugin() - } - - installPlugin () { - const { plugin, uppy } = this.props - const pluginObj = uppy - .getPlugin(plugin) - - pluginObj.mount(this.container, pluginObj) - } - - uninstallPlugin ({ uppy } = this.props) { - const { plugin } = this.props - uppy - .getPlugin(plugin) - .unmount() - } - - refContainer (container) { - this.container = container - } - - render () { - return h('div', { className: 'uppy-Container', ref: this.refContainer }) - } -} - -UppyWrapper.propTypes = { - uppy: uppyPropType.isRequired, - plugin: PropTypes.string.isRequired, -} - -export default UppyWrapper diff --git a/packages/@uppy/react/src/Wrapper.ts b/packages/@uppy/react/src/Wrapper.ts new file mode 100644 index 0000000000..a933ec0b17 --- /dev/null +++ b/packages/@uppy/react/src/Wrapper.ts @@ -0,0 +1,59 @@ +import { createElement as h, Component } from 'react' +import type { Body, Meta } from '@uppy/utils/lib/UppyFile' +import type { UIPlugin, Uppy } from '@uppy/core' +import PropTypes from 'prop-types' +import { uppy as uppyPropType } from './propTypes.ts' + +interface UppyWrapperProps { + uppy: Uppy + plugin: string +} + +class UppyWrapper extends Component< + UppyWrapperProps +> { + static propTypes = { + uppy: uppyPropType.isRequired, + plugin: PropTypes.string.isRequired, + } + + private container: HTMLDivElement + + componentDidMount(): void { + this.installPlugin() + } + + componentDidUpdate(prevProps: UppyWrapperProps): void { + const { uppy } = this.props + if (prevProps.uppy !== uppy) { + this.uninstallPlugin(prevProps) + this.installPlugin() + } + } + + componentWillUnmount(): void { + this.uninstallPlugin() + } + + private refContainer = (container: UppyWrapper['container']) => { + this.container = container + } + + installPlugin(): void { + const { plugin, uppy } = this.props + const pluginObj = uppy.getPlugin(plugin) as UIPlugin + + pluginObj.mount(this.container, pluginObj) + } + + uninstallPlugin({ uppy } = this.props): void { + const { plugin } = this.props + ;(uppy.getPlugin(plugin) as UIPlugin).unmount() + } + + render(): ReturnType { + return h('div', { className: 'uppy-Container', ref: this.refContainer }) + } +} + +export default UppyWrapper diff --git a/packages/@uppy/react/src/getHTMLProps.js b/packages/@uppy/react/src/getHTMLProps.ts similarity index 95% rename from packages/@uppy/react/src/getHTMLProps.js rename to packages/@uppy/react/src/getHTMLProps.ts index 8ea159fb72..54af2d7577 100644 --- a/packages/@uppy/react/src/getHTMLProps.js +++ b/packages/@uppy/react/src/getHTMLProps.ts @@ -253,11 +253,15 @@ const reactSupportedHtmlAttr = [ const validHTMLAttribute = /^(aria-|data-)/ -const getHTMLProps = (props) => { +const getHTMLProps = ( + props: Record, +): Record => { // Gets all the React props return Object.fromEntries( - Object.entries(props) - .filter(([key]) => validHTMLAttribute.test(key) || reactSupportedHtmlAttr.includes(key)), + Object.entries(props).filter( + ([key]) => + validHTMLAttribute.test(key) || reactSupportedHtmlAttr.includes(key), + ), ) } diff --git a/packages/@uppy/react/src/index.js b/packages/@uppy/react/src/index.js deleted file mode 100644 index f8c8213a20..0000000000 --- a/packages/@uppy/react/src/index.js +++ /dev/null @@ -1,7 +0,0 @@ -export { default as Dashboard } from './Dashboard.js' -export { default as DashboardModal } from './DashboardModal.js' -export { default as DragDrop } from './DragDrop.js' -export { default as ProgressBar } from './ProgressBar.js' -export { default as StatusBar } from './StatusBar.js' -export { default as FileInput } from './FileInput.js' -export { default as useUppy } from './useUppy.js' diff --git a/packages/@uppy/react/src/index.ts b/packages/@uppy/react/src/index.ts new file mode 100644 index 0000000000..f241b05d83 --- /dev/null +++ b/packages/@uppy/react/src/index.ts @@ -0,0 +1,7 @@ +export { default as Dashboard } from './Dashboard.ts' +export { default as DashboardModal } from './DashboardModal.ts' +export { default as DragDrop } from './DragDrop.ts' +export { default as ProgressBar } from './ProgressBar.ts' +export { default as StatusBar } from './StatusBar.ts' +export { default as FileInput } from './FileInput.ts' +export { default as useUppy } from './useUppy.ts' diff --git a/packages/@uppy/react/src/nonHtmlPropsHaveChanged.js b/packages/@uppy/react/src/nonHtmlPropsHaveChanged.js deleted file mode 100644 index 9aee5a556f..0000000000 --- a/packages/@uppy/react/src/nonHtmlPropsHaveChanged.js +++ /dev/null @@ -1,3 +0,0 @@ -export default function nonHtmlPropsHaveChanged (props, prevProps) { - return Object.keys(props).some(key => !Object.hasOwn(props, key) && props[key] !== prevProps[key]) -} diff --git a/packages/@uppy/react/src/nonHtmlPropsHaveChanged.ts b/packages/@uppy/react/src/nonHtmlPropsHaveChanged.ts new file mode 100644 index 0000000000..179c1625b8 --- /dev/null +++ b/packages/@uppy/react/src/nonHtmlPropsHaveChanged.ts @@ -0,0 +1,7 @@ +export default function nonHtmlPropsHaveChanged< + T extends Record, +>(props: T, prevProps: T): boolean { + return Object.keys(props).some( + (key) => !Object.hasOwn(props, key) && props[key] !== prevProps[key], + ) +} diff --git a/packages/@uppy/react/src/propTypes.js b/packages/@uppy/react/src/propTypes.ts similarity index 52% rename from packages/@uppy/react/src/propTypes.js rename to packages/@uppy/react/src/propTypes.ts index e744a3dd1d..1054423ab5 100644 --- a/packages/@uppy/react/src/propTypes.js +++ b/packages/@uppy/react/src/propTypes.ts @@ -25,37 +25,6 @@ const metaFields = PropTypes.oneOfType([ ]) // A size in pixels (number) or with some other unit (string). -const cssSize = PropTypes.oneOfType([ - PropTypes.string, - PropTypes.number, -]) - -// Common props for dashboardy components (Dashboard and DashboardModal). -const dashboard = { - uppy, - inline: PropTypes.bool, - plugins, - width: cssSize, - height: cssSize, - showProgressDetails: PropTypes.bool, - hideUploadButton: PropTypes.bool, - hideProgressAfterFinish: PropTypes.bool, - note: PropTypes.string, - metaFields, - proudlyDisplayPoweredByUppy: PropTypes.bool, - disableStatusBar: PropTypes.bool, - disableInformer: PropTypes.bool, - disableThumbnailGenerator: PropTypes.bool, - // pass-through to ThumbnailGenerator - thumbnailWidth: PropTypes.number, - locale, -} +const cssSize = PropTypes.oneOfType([PropTypes.string, PropTypes.number]) -export { - uppy, - locale, - dashboard, - plugins, - metaFields, - cssSize, -} +export { uppy, locale, plugins, metaFields, cssSize } diff --git a/packages/@uppy/react/src/useUppy.js b/packages/@uppy/react/src/useUppy.ts similarity index 50% rename from packages/@uppy/react/src/useUppy.js rename to packages/@uppy/react/src/useUppy.ts index 3913833b37..e549d82071 100644 --- a/packages/@uppy/react/src/useUppy.js +++ b/packages/@uppy/react/src/useUppy.ts @@ -1,20 +1,27 @@ import { useEffect, useRef } from 'react' import { Uppy as UppyCore } from '@uppy/core' +import type { Body, Meta } from '@uppy/utils/lib/UppyFile' /** * @deprecated Initialize Uppy outside of the component. */ -export default function useUppy (factory) { +export default function useUppy( + factory: () => UppyCore, +): UppyCore | undefined { if (typeof factory !== 'function') { - throw new TypeError('useUppy: expected a function that returns a new Uppy instance') + throw new TypeError( + 'useUppy: expected a function that returns a new Uppy instance', + ) } - const uppy = useRef(undefined) + const uppy = useRef | undefined>(undefined) if (uppy.current === undefined) { uppy.current = factory() if (!(uppy.current instanceof UppyCore)) { - throw new TypeError(`useUppy: factory function must return an Uppy instance, got ${typeof uppy.current}`) + throw new TypeError( + `useUppy: factory function must return an Uppy instance, got ${typeof uppy.current}`, + ) } } diff --git a/packages/@uppy/react/tsconfig.build.json b/packages/@uppy/react/tsconfig.build.json new file mode 100644 index 0000000000..151f7261b5 --- /dev/null +++ b/packages/@uppy/react/tsconfig.build.json @@ -0,0 +1,50 @@ +{ + "extends": "../../../tsconfig.shared", + "compilerOptions": { + "noImplicitAny": false, + "outDir": "./lib", + "paths": { + "@uppy/utils/lib/*": ["../utils/src/*"], + "@uppy/core": ["../core/src/index.js"], + "@uppy/core/lib/*": ["../core/src/*"], + "@uppy/dashboard": ["../dashboard/src/index.js"], + "@uppy/dashboard/lib/*": ["../dashboard/src/*"], + "@uppy/drag-drop": ["../drag-drop/src/index.js"], + "@uppy/drag-drop/lib/*": ["../drag-drop/src/*"], + "@uppy/file-input": ["../file-input/src/index.js"], + "@uppy/file-input/lib/*": ["../file-input/src/*"], + "@uppy/progress-bar": ["../progress-bar/src/index.js"], + "@uppy/progress-bar/lib/*": ["../progress-bar/src/*"], + "@uppy/status-bar": ["../status-bar/src/index.js"], + "@uppy/status-bar/lib/*": ["../status-bar/src/*"] + }, + "resolveJsonModule": false, + "rootDir": "./src", + "skipLibCheck": true + }, + "include": ["./src/**/*.*"], + "exclude": ["./src/**/*.test.ts"], + "references": [ + { + "path": "../utils/tsconfig.build.json" + }, + { + "path": "../core/tsconfig.build.json" + }, + { + "path": "../dashboard/tsconfig.build.json" + }, + { + "path": "../drag-drop/tsconfig.build.json" + }, + { + "path": "../file-input/tsconfig.build.json" + }, + { + "path": "../progress-bar/tsconfig.build.json" + }, + { + "path": "../status-bar/tsconfig.build.json" + } + ] +} diff --git a/packages/@uppy/react/tsconfig.json b/packages/@uppy/react/tsconfig.json new file mode 100644 index 0000000000..aae4219b7c --- /dev/null +++ b/packages/@uppy/react/tsconfig.json @@ -0,0 +1,46 @@ +{ + "extends": "../../../tsconfig.shared", + "compilerOptions": { + "emitDeclarationOnly": false, + "noEmit": true, + "paths": { + "@uppy/utils/lib/*": ["../utils/src/*"], + "@uppy/core": ["../core/src/index.js"], + "@uppy/core/lib/*": ["../core/src/*"], + "@uppy/dashboard": ["../dashboard/src/index.js"], + "@uppy/dashboard/lib/*": ["../dashboard/src/*"], + "@uppy/drag-drop": ["../drag-drop/src/index.js"], + "@uppy/drag-drop/lib/*": ["../drag-drop/src/*"], + "@uppy/file-input": ["../file-input/src/index.js"], + "@uppy/file-input/lib/*": ["../file-input/src/*"], + "@uppy/progress-bar": ["../progress-bar/src/index.js"], + "@uppy/progress-bar/lib/*": ["../progress-bar/src/*"], + "@uppy/status-bar": ["../status-bar/src/index.js"], + "@uppy/status-bar/lib/*": ["../status-bar/src/*"], + }, + }, + "include": ["./package.json", "./src/**/*.*"], + "references": [ + { + "path": "../utils/tsconfig.build.json", + }, + { + "path": "../core/tsconfig.build.json", + }, + { + "path": "../dashboard/tsconfig.build.json", + }, + { + "path": "../drag-drop/tsconfig.build.json", + }, + { + "path": "../file-input/tsconfig.build.json", + }, + { + "path": "../progress-bar/tsconfig.build.json", + }, + { + "path": "../status-bar/tsconfig.build.json", + }, + ], +} diff --git a/packages/@uppy/react/src/CommonTypes.d.ts b/packages/@uppy/react/types/CommonTypes.d.ts similarity index 100% rename from packages/@uppy/react/src/CommonTypes.d.ts rename to packages/@uppy/react/types/CommonTypes.d.ts diff --git a/packages/@uppy/react/src/Dashboard.d.ts b/packages/@uppy/react/types/Dashboard.d.ts similarity index 100% rename from packages/@uppy/react/src/Dashboard.d.ts rename to packages/@uppy/react/types/Dashboard.d.ts diff --git a/packages/@uppy/react/src/DashboardModal.d.ts b/packages/@uppy/react/types/DashboardModal.d.ts similarity index 100% rename from packages/@uppy/react/src/DashboardModal.d.ts rename to packages/@uppy/react/types/DashboardModal.d.ts diff --git a/packages/@uppy/react/src/DragDrop.d.ts b/packages/@uppy/react/types/DragDrop.d.ts similarity index 100% rename from packages/@uppy/react/src/DragDrop.d.ts rename to packages/@uppy/react/types/DragDrop.d.ts diff --git a/packages/@uppy/react/src/FileInput.d.ts b/packages/@uppy/react/types/FileInput.d.ts similarity index 100% rename from packages/@uppy/react/src/FileInput.d.ts rename to packages/@uppy/react/types/FileInput.d.ts diff --git a/packages/@uppy/react/src/ProgressBar.d.ts b/packages/@uppy/react/types/ProgressBar.d.ts similarity index 100% rename from packages/@uppy/react/src/ProgressBar.d.ts rename to packages/@uppy/react/types/ProgressBar.d.ts diff --git a/packages/@uppy/react/src/StatusBar.d.ts b/packages/@uppy/react/types/StatusBar.d.ts similarity index 100% rename from packages/@uppy/react/src/StatusBar.d.ts rename to packages/@uppy/react/types/StatusBar.d.ts diff --git a/packages/@uppy/react/types/index.d.ts b/packages/@uppy/react/types/index.d.ts index d64be3577f..2fc2c265da 100644 --- a/packages/@uppy/react/types/index.d.ts +++ b/packages/@uppy/react/types/index.d.ts @@ -1,7 +1,7 @@ -export { default as Dashboard } from '../src/Dashboard' -export { default as DashboardModal } from '../src/DashboardModal' -export { default as DragDrop } from '../src/DragDrop' -export { default as ProgressBar } from '../src/ProgressBar' -export { default as StatusBar } from '../src/StatusBar' -export { default as FileInput } from '../src/FileInput' -export { default as useUppy } from '../src/useUppy' +export { default as Dashboard } from './Dashboard' +export { default as DashboardModal } from './DashboardModal' +export { default as DragDrop } from './DragDrop' +export { default as ProgressBar } from './ProgressBar' +export { default as StatusBar } from './StatusBar' +export { default as FileInput } from './FileInput' +export { default as useUppy } from './useUppy' diff --git a/packages/@uppy/react/src/useUppy.d.ts b/packages/@uppy/react/types/useUppy.d.ts similarity index 100% rename from packages/@uppy/react/src/useUppy.d.ts rename to packages/@uppy/react/types/useUppy.d.ts From 619f8ae4e82d0a25eb3141b77220074df6d0a184 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Wed, 27 Mar 2024 11:20:08 +0100 Subject: [PATCH 17/27] @uppy/react: remove `Wrapper.ts` (#5032) --- packages/@uppy/react/src/Wrapper.ts | 59 ----------------------------- 1 file changed, 59 deletions(-) delete mode 100644 packages/@uppy/react/src/Wrapper.ts diff --git a/packages/@uppy/react/src/Wrapper.ts b/packages/@uppy/react/src/Wrapper.ts deleted file mode 100644 index a933ec0b17..0000000000 --- a/packages/@uppy/react/src/Wrapper.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { createElement as h, Component } from 'react' -import type { Body, Meta } from '@uppy/utils/lib/UppyFile' -import type { UIPlugin, Uppy } from '@uppy/core' -import PropTypes from 'prop-types' -import { uppy as uppyPropType } from './propTypes.ts' - -interface UppyWrapperProps { - uppy: Uppy - plugin: string -} - -class UppyWrapper extends Component< - UppyWrapperProps -> { - static propTypes = { - uppy: uppyPropType.isRequired, - plugin: PropTypes.string.isRequired, - } - - private container: HTMLDivElement - - componentDidMount(): void { - this.installPlugin() - } - - componentDidUpdate(prevProps: UppyWrapperProps): void { - const { uppy } = this.props - if (prevProps.uppy !== uppy) { - this.uninstallPlugin(prevProps) - this.installPlugin() - } - } - - componentWillUnmount(): void { - this.uninstallPlugin() - } - - private refContainer = (container: UppyWrapper['container']) => { - this.container = container - } - - installPlugin(): void { - const { plugin, uppy } = this.props - const pluginObj = uppy.getPlugin(plugin) as UIPlugin - - pluginObj.mount(this.container, pluginObj) - } - - uninstallPlugin({ uppy } = this.props): void { - const { plugin } = this.props - ;(uppy.getPlugin(plugin) as UIPlugin).unmount() - } - - render(): ReturnType { - return h('div', { className: 'uppy-Container', ref: this.refContainer }) - } -} - -export default UppyWrapper From 22e815e53803505a03af78aaee2f90ef52db6769 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Wed, 27 Mar 2024 13:07:26 +0100 Subject: [PATCH 18/27] @uppy/status-bar: remove default target (#4970) --- packages/@uppy/status-bar/src/StatusBar.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/@uppy/status-bar/src/StatusBar.tsx b/packages/@uppy/status-bar/src/StatusBar.tsx index 2eaaa3245a..ed22371857 100644 --- a/packages/@uppy/status-bar/src/StatusBar.tsx +++ b/packages/@uppy/status-bar/src/StatusBar.tsx @@ -59,7 +59,6 @@ function getUploadingState( // set default options, must be kept in sync with @uppy/react/src/StatusBar.js const defaultOptions = { - target: 'body', hideUploadButton: false, hideRetryButton: false, hidePauseResumeButton: false, From 0ec6e1173375a1efe1dcb8b4280248bbad5c9d7a Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Wed, 27 Mar 2024 13:08:24 +0100 Subject: [PATCH 19/27] @uppy/progress-bar: remove default target (#4971) --- packages/@uppy/progress-bar/src/ProgressBar.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/@uppy/progress-bar/src/ProgressBar.tsx b/packages/@uppy/progress-bar/src/ProgressBar.tsx index a7230999a3..8f28093eba 100644 --- a/packages/@uppy/progress-bar/src/ProgressBar.tsx +++ b/packages/@uppy/progress-bar/src/ProgressBar.tsx @@ -13,7 +13,6 @@ export interface ProgressBarOptions extends UIPluginOptions { } // set default options, must kept in sync with @uppy/react/src/ProgressBar.js const defaultOptions = { - target: 'body', fixed: false, hideAfterFinish: true, } From 4be043a3060f3d08592ed20514fd7a2874b58633 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Wed, 27 Mar 2024 13:18:11 +0100 Subject: [PATCH 20/27] @uppy/react: remove `prop-types` dependency (#5031) --- packages/@uppy/dashboard/src/Dashboard.tsx | 1 - packages/@uppy/drag-drop/src/DragDrop.tsx | 1 - packages/@uppy/file-input/src/FileInput.tsx | 1 - packages/@uppy/react/package.json | 3 +- packages/@uppy/react/src/Dashboard.ts | 28 ------- packages/@uppy/react/src/DashboardModal.ts | 91 +-------------------- packages/@uppy/react/src/DragDrop.ts | 20 ----- packages/@uppy/react/src/FileInput.ts | 9 -- packages/@uppy/react/src/ProgressBar.ts | 21 +---- packages/@uppy/react/src/StatusBar.ts | 24 ------ packages/@uppy/react/src/propTypes.ts | 30 ------- packages/@uppy/status-bar/src/StatusBar.tsx | 1 - yarn.lock | 3 +- 13 files changed, 7 insertions(+), 226 deletions(-) delete mode 100644 packages/@uppy/react/src/propTypes.ts diff --git a/packages/@uppy/dashboard/src/Dashboard.tsx b/packages/@uppy/dashboard/src/Dashboard.tsx index 8d435b9e45..54fc3d7f2e 100644 --- a/packages/@uppy/dashboard/src/Dashboard.tsx +++ b/packages/@uppy/dashboard/src/Dashboard.tsx @@ -176,7 +176,6 @@ export type DashboardOptions< > = DashboardMiscOptions & (DashboardModalOptions | DashboardInlineOptions) -// set default options, must be kept in sync with packages/@uppy/react/src/DashboardModal.js const defaultOptions = { target: 'body', metaFields: [], diff --git a/packages/@uppy/drag-drop/src/DragDrop.tsx b/packages/@uppy/drag-drop/src/DragDrop.tsx index d53381d140..0cc1003d12 100644 --- a/packages/@uppy/drag-drop/src/DragDrop.tsx +++ b/packages/@uppy/drag-drop/src/DragDrop.tsx @@ -24,7 +24,6 @@ export interface DragDropOptions extends UIPluginOptions { onDrop?: (event: DragEvent) => void } -// Default options, must be kept in sync with @uppy/react/src/DragDrop.js. const defaultOptions = { inputName: 'files[]', width: '100%', diff --git a/packages/@uppy/file-input/src/FileInput.tsx b/packages/@uppy/file-input/src/FileInput.tsx index dadeedd1e2..37c54548ff 100644 --- a/packages/@uppy/file-input/src/FileInput.tsx +++ b/packages/@uppy/file-input/src/FileInput.tsx @@ -14,7 +14,6 @@ export interface FileInputOptions extends UIPluginOptions { pretty?: boolean inputName?: string } -// Default options, must be kept in sync with @uppy/react/src/FileInput.js. const defaultOptions = { pretty: true, inputName: 'files[]', diff --git a/packages/@uppy/react/package.json b/packages/@uppy/react/package.json index ef515bff0d..696f79bc9c 100644 --- a/packages/@uppy/react/package.json +++ b/packages/@uppy/react/package.json @@ -21,8 +21,7 @@ "url": "git+https://github.com/transloadit/uppy.git" }, "dependencies": { - "@uppy/utils": "workspace:^", - "prop-types": "^15.6.1" + "@uppy/utils": "workspace:^" }, "devDependencies": { "@types/react": "^18.0.8", diff --git a/packages/@uppy/react/src/Dashboard.ts b/packages/@uppy/react/src/Dashboard.ts index 40c707b0d8..aded097ecd 100644 --- a/packages/@uppy/react/src/Dashboard.ts +++ b/packages/@uppy/react/src/Dashboard.ts @@ -1,16 +1,8 @@ import { createElement as h, Component } from 'react' -import PropTypes from 'prop-types' import type { UnknownPlugin, Uppy } from '@uppy/core' import DashboardPlugin from '@uppy/dashboard' import type { Body, Meta } from '@uppy/utils/lib/UppyFile' import type { DashboardOptions } from '@uppy/dashboard' -import { - locale, - uppy as uppyPropType, - plugins, - metaFields, - cssSize, -} from './propTypes.ts' import getHTMLProps from './getHTMLProps.ts' import nonHtmlPropsHaveChanged from './nonHtmlPropsHaveChanged.ts' @@ -33,26 +25,6 @@ export interface DashboardProps class Dashboard extends Component< DashboardProps > { - static propsTypes = { - uppy: uppyPropType, - disableInformer: PropTypes.bool, - disableStatusBar: PropTypes.bool, - disableThumbnailGenerator: PropTypes.bool, - height: cssSize, - hideProgressAfterFinish: PropTypes.bool, - hideUploadButton: PropTypes.bool, - locale, - metaFields, - note: PropTypes.string, - plugins, - proudlyDisplayPoweredByUppy: PropTypes.bool, - showProgressDetails: PropTypes.bool, - width: cssSize, - // pass-through to ThumbnailGenerator - thumbnailType: PropTypes.string, - thumbnailWidth: PropTypes.number, - } - private container: HTMLElement private plugin: UnknownPlugin diff --git a/packages/@uppy/react/src/DashboardModal.ts b/packages/@uppy/react/src/DashboardModal.ts index e7f54d7246..c6c947c91e 100644 --- a/packages/@uppy/react/src/DashboardModal.ts +++ b/packages/@uppy/react/src/DashboardModal.ts @@ -1,15 +1,7 @@ import { createElement as h, Component } from 'react' -import PropTypes from 'prop-types' import DashboardPlugin, { type DashboardOptions } from '@uppy/dashboard' import type { Body, Meta } from '@uppy/utils/lib/UppyFile' import type { Uppy } from '@uppy/core' -import { - cssSize, - locale, - metaFields, - plugins, - uppy as uppyPropType, -} from './propTypes.ts' import getHTMLProps from './getHTMLProps.ts' import nonHtmlPropsHaveChanged from './nonHtmlPropsHaveChanged.ts' @@ -22,8 +14,8 @@ type DashboardInlineOptions = Omit< export interface DashboardModalProps extends DashboardInlineOptions { uppy: Uppy - onRequestClose: () => void - open: boolean + onRequestClose?: () => void + open?: boolean } /** @@ -34,87 +26,8 @@ export interface DashboardModalProps class DashboardModal extends Component< DashboardModalProps > { - static propTypes = { - uppy: uppyPropType.isRequired, - target: - typeof window !== 'undefined' ? - PropTypes.instanceOf(window.HTMLElement) - : PropTypes.any, - open: PropTypes.bool, - onRequestClose: PropTypes.func, - closeModalOnClickOutside: PropTypes.bool, - disablePageScrollWhenModalOpen: PropTypes.bool, - plugins, - width: cssSize, - height: cssSize, - showProgressDetails: PropTypes.bool, - note: PropTypes.string, - metaFields, - proudlyDisplayPoweredByUppy: PropTypes.bool, - autoOpenFileEditor: PropTypes.bool, - animateOpenClose: PropTypes.bool, - browserBackButtonClose: PropTypes.bool, - closeAfterFinish: PropTypes.bool, - disableStatusBar: PropTypes.bool, - disableInformer: PropTypes.bool, - disableThumbnailGenerator: PropTypes.bool, - disableLocalFiles: PropTypes.bool, - disabled: PropTypes.bool, - hideCancelButton: PropTypes.bool, - hidePauseResumeButton: PropTypes.bool, - hideProgressAfterFinish: PropTypes.bool, - hideRetryButton: PropTypes.bool, - hideUploadButton: PropTypes.bool, - showLinkToFileUploadResult: PropTypes.bool, - showRemoveButtonAfterComplete: PropTypes.bool, - showSelectedFiles: PropTypes.bool, - waitForThumbnailsBeforeUpload: PropTypes.bool, - fileManagerSelectionType: PropTypes.string, - theme: PropTypes.string, - // pass-through to ThumbnailGenerator - thumbnailType: PropTypes.string, - thumbnailWidth: PropTypes.number, - locale, - } - - // Must be kept in sync with @uppy/dashboard/src/Dashboard.jsx. static defaultProps = { - metaFields: [], - plugins: [], - width: 750, - height: 550, - thumbnailWidth: 280, - thumbnailType: 'image/jpeg', - waitForThumbnailsBeforeUpload: false, - showLinkToFileUploadResult: false, - showProgressDetails: false, - hideUploadButton: false, - hideCancelButton: false, - hideRetryButton: false, - hidePauseResumeButton: false, - hideProgressAfterFinish: false, - note: null, - closeModalOnClickOutside: false, - closeAfterFinish: false, - disableStatusBar: false, - disableInformer: false, - disableThumbnailGenerator: false, - disablePageScrollWhenModalOpen: true, - animateOpenClose: true, - fileManagerSelectionType: 'files', - proudlyDisplayPoweredByUppy: true, - showSelectedFiles: true, - showRemoveButtonAfterComplete: false, - browserBackButtonClose: false, - theme: 'light', - autoOpenFileEditor: false, - disabled: false, - disableLocalFiles: false, - - // extra open: undefined, - target: undefined, - locale: null, onRequestClose: undefined, } diff --git a/packages/@uppy/react/src/DragDrop.ts b/packages/@uppy/react/src/DragDrop.ts index 387955ddc0..d2b2257d2f 100644 --- a/packages/@uppy/react/src/DragDrop.ts +++ b/packages/@uppy/react/src/DragDrop.ts @@ -1,9 +1,7 @@ import { createElement as h, Component } from 'react' -import PropTypes from 'prop-types' import type { UnknownPlugin, Uppy } from '@uppy/core' import DragDropPlugin, { type DragDropOptions } from '@uppy/drag-drop' import type { Body, Meta } from '@uppy/utils/lib/UppyFile' -import * as propTypes from './propTypes.ts' import getHTMLProps from './getHTMLProps.ts' import nonHtmlPropsHaveChanged from './nonHtmlPropsHaveChanged.ts' @@ -20,24 +18,6 @@ interface DragDropProps class DragDrop extends Component< DragDropProps > { - static propTypes = { - uppy: propTypes.uppy.isRequired, - locale: propTypes.locale, - inputName: PropTypes.string, - width: PropTypes.string, - height: PropTypes.string, - note: PropTypes.string, - } - - // Must be kept in sync with @uppy/drag-drop/src/DragDrop.jsx. - static defaultProps = { - locale: null, - inputName: 'files[]', - width: '100%', - height: '100%', - note: null, - } - private container: HTMLElement private plugin: UnknownPlugin diff --git a/packages/@uppy/react/src/FileInput.ts b/packages/@uppy/react/src/FileInput.ts index 5148084f5a..f6f01514ad 100644 --- a/packages/@uppy/react/src/FileInput.ts +++ b/packages/@uppy/react/src/FileInput.ts @@ -1,10 +1,8 @@ import { createElement as h, Component } from 'react' -import PropTypes from 'prop-types' import type { UnknownPlugin, Uppy } from '@uppy/core' import FileInputPlugin from '@uppy/file-input' import type { Body, Meta } from '@uppy/utils/lib/UppyFile' import type { Locale } from '@uppy/utils/lib/Translator' -import * as propTypes from './propTypes.ts' interface FileInputProps { uppy: Uppy @@ -21,13 +19,6 @@ interface FileInputProps { class FileInput extends Component< FileInputProps > { - static propTypes = { - uppy: propTypes.uppy.isRequired, - locale: propTypes.locale, - pretty: PropTypes.bool, - inputName: PropTypes.string, - } - // Must be kept in sync with @uppy/file-input/src/FileInput.jsx static defaultProps = { locale: undefined, diff --git a/packages/@uppy/react/src/ProgressBar.ts b/packages/@uppy/react/src/ProgressBar.ts index 9879850cdb..4b61bbb60f 100644 --- a/packages/@uppy/react/src/ProgressBar.ts +++ b/packages/@uppy/react/src/ProgressBar.ts @@ -1,16 +1,13 @@ import { createElement as h, Component } from 'react' -import PropTypes from 'prop-types' import type { UnknownPlugin, Uppy } from '@uppy/core' -import ProgressBarPlugin from '@uppy/progress-bar' +import ProgressBarPlugin, { type ProgressBarOptions } from '@uppy/progress-bar' import type { Body, Meta } from '@uppy/utils/lib/UppyFile' -import { uppy as uppyPropType } from './propTypes.ts' import getHTMLProps from './getHTMLProps.ts' import nonHtmlPropsHaveChanged from './nonHtmlPropsHaveChanged.ts' -interface ProgressBarProps { +interface ProgressBarProps + extends ProgressBarOptions { uppy: Uppy - fixed?: boolean - hideAfterFinish?: boolean } /** @@ -20,18 +17,6 @@ interface ProgressBarProps { class ProgressBar extends Component< ProgressBarProps > { - static propTypes = { - uppy: uppyPropType.isRequired, - fixed: PropTypes.bool, - hideAfterFinish: PropTypes.bool, - } - - // Must be kept in sync with @uppy/progress-bar/src/ProgressBar.jsx - static defaultProps = { - fixed: false, - hideAfterFinish: true, - } - private container: HTMLElement private plugin: UnknownPlugin diff --git a/packages/@uppy/react/src/StatusBar.ts b/packages/@uppy/react/src/StatusBar.ts index 0271773d2b..1af63e3a96 100644 --- a/packages/@uppy/react/src/StatusBar.ts +++ b/packages/@uppy/react/src/StatusBar.ts @@ -1,9 +1,7 @@ import { createElement as h, Component } from 'react' -import PropTypes from 'prop-types' import type { UnknownPlugin, Uppy } from '@uppy/core' import StatusBarPlugin, { type StatusBarOptions } from '@uppy/status-bar' import type { Body, Meta } from '@uppy/utils/lib/UppyFile' -import { uppy as uppyPropType } from './propTypes.ts' import getHTMLProps from './getHTMLProps.ts' import nonHtmlPropsHaveChanged from './nonHtmlPropsHaveChanged.ts' @@ -20,28 +18,6 @@ interface StatusBarProps class StatusBar extends Component< StatusBarProps > { - static propTypes = { - uppy: uppyPropType.isRequired, - hideUploadButton: PropTypes.bool, - hideRetryButton: PropTypes.bool, - hidePauseResumeButton: PropTypes.bool, - hideCancelButton: PropTypes.bool, - showProgressDetails: PropTypes.bool, - hideAfterFinish: PropTypes.bool, - doneButtonHandler: PropTypes.func, - } - - // Must be kept in sync with @uppy/status-bar/src/StatusBar.jsx. - static defaultProps = { - hideUploadButton: false, - hideRetryButton: false, - hidePauseResumeButton: false, - hideCancelButton: false, - showProgressDetails: false, - hideAfterFinish: true, - doneButtonHandler: null, - } - private container: HTMLElement private plugin: UnknownPlugin diff --git a/packages/@uppy/react/src/propTypes.ts b/packages/@uppy/react/src/propTypes.ts deleted file mode 100644 index 1054423ab5..0000000000 --- a/packages/@uppy/react/src/propTypes.ts +++ /dev/null @@ -1,30 +0,0 @@ -import PropTypes from 'prop-types' -import { Uppy as UppyCore } from '@uppy/core' - -// The `uppy` prop receives the Uppy core instance. -const uppy = PropTypes.instanceOf(UppyCore) - -// A list of plugins to mount inside this component. -const plugins = PropTypes.arrayOf(PropTypes.string) - -// Language strings for this component. -const locale = PropTypes.shape({ - strings: PropTypes.object, // eslint-disable-line react/forbid-prop-types - pluralize: PropTypes.func, -}) - -// List of meta fields for the editor in the Dashboard. -const metaField = PropTypes.shape({ - id: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - placeholder: PropTypes.string, -}) -const metaFields = PropTypes.oneOfType([ - PropTypes.arrayOf(metaField), - PropTypes.func, -]) - -// A size in pixels (number) or with some other unit (string). -const cssSize = PropTypes.oneOfType([PropTypes.string, PropTypes.number]) - -export { uppy, locale, plugins, metaFields, cssSize } diff --git a/packages/@uppy/status-bar/src/StatusBar.tsx b/packages/@uppy/status-bar/src/StatusBar.tsx index ed22371857..5770f2f92d 100644 --- a/packages/@uppy/status-bar/src/StatusBar.tsx +++ b/packages/@uppy/status-bar/src/StatusBar.tsx @@ -57,7 +57,6 @@ function getUploadingState( return state } -// set default options, must be kept in sync with @uppy/react/src/StatusBar.js const defaultOptions = { hideUploadButton: false, hideRetryButton: false, diff --git a/yarn.lock b/yarn.lock index a7381c9d75..44dc5a07da 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9643,7 +9643,6 @@ __metadata: dependencies: "@types/react": ^18.0.8 "@uppy/utils": "workspace:^" - prop-types: ^15.6.1 react: ^18.1.0 peerDependencies: "@uppy/core": "workspace:^" @@ -26050,7 +26049,7 @@ __metadata: languageName: node linkType: hard -"prop-types@npm:^15.6.0, prop-types@npm:^15.6.1, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1": +"prop-types@npm:^15.6.0, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1": version: 15.8.1 resolution: "prop-types@npm:15.8.1" dependencies: From 3977985e6897c094439ff279776bff509f2f86d5 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Wed, 27 Mar 2024 13:42:29 +0100 Subject: [PATCH 21/27] e2e: bump Cypress version (#5034) --- yarn.lock | 41 ++++++++++++++++++++++------------------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/yarn.lock b/yarn.lock index 0829013460..df3f4517f6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8293,13 +8293,6 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:^18.17.5": - version: 18.18.4 - resolution: "@types/node@npm:18.18.4" - checksum: 4901e91c4cc479bb58acbcd79236a97a0ad6db4a53cb1f4ba4cf32af15324c61b16faa6e31c1b09bf538a20feb5f5274239157ce5237f5741db0b9ab71e69c52 - languageName: node - linkType: hard - "@types/normalize-package-data@npm:^2.4.0": version: 2.4.1 resolution: "@types/normalize-package-data@npm:2.4.1" @@ -12063,7 +12056,7 @@ __metadata: languageName: node linkType: hard -"buffer@npm:^5.5.0, buffer@npm:^5.6.0": +"buffer@npm:^5.5.0, buffer@npm:^5.7.1": version: 5.7.1 resolution: "buffer@npm:5.7.1" dependencies: @@ -13616,33 +13609,32 @@ __metadata: linkType: hard "cypress-terminal-report@npm:^5.0.0": - version: 5.3.7 - resolution: "cypress-terminal-report@npm:5.3.7" + version: 5.3.12 + resolution: "cypress-terminal-report@npm:5.3.12" dependencies: chalk: ^4.0.0 fs-extra: ^10.1.0 process: ^0.11.10 - semver: ^7.3.5 + semver: ^7.5.4 tv4: ^1.3.0 peerDependencies: cypress: ">=10.0.0" - checksum: 7bd433c26009936d74a0cc002ec4e48942fa806b465d71ffdb783785074433f650cb95dda77ec4ea00e10cf723bcbea8543f74cfbd49104c89022776690dcc1c + checksum: e90443438e0dabb49007cc0009b74b79da97728c6b74cbba2f4791d59b2effd35398675b4cc0770d7ca0b7524af329bcfa37fc2662ccbf45e8292966fdc8970d languageName: node linkType: hard "cypress@npm:^13.0.0": - version: 13.3.0 - resolution: "cypress@npm:13.3.0" + version: 13.7.1 + resolution: "cypress@npm:13.7.1" dependencies: "@cypress/request": ^3.0.0 "@cypress/xvfb": ^1.2.4 - "@types/node": ^18.17.5 "@types/sinonjs__fake-timers": 8.1.1 "@types/sizzle": ^2.3.2 arch: ^2.2.0 blob-util: ^2.0.2 bluebird: ^3.7.2 - buffer: ^5.6.0 + buffer: ^5.7.1 cachedir: ^2.3.0 chalk: ^4.1.0 check-more-types: ^2.24.0 @@ -13660,7 +13652,7 @@ __metadata: figures: ^3.2.0 fs-extra: ^9.1.0 getos: ^3.2.1 - is-ci: ^3.0.0 + is-ci: ^3.0.1 is-installed-globally: ~0.4.0 lazy-ass: ^1.6.0 listr2: ^3.8.3 @@ -13679,7 +13671,7 @@ __metadata: yauzl: ^2.10.0 bin: cypress: bin/cypress - checksum: 6ad11bd6aabccfaf8be78afff0e854c9c8ccc935704c41efe4d8e9412ccfcb652f23791931c3aa26fc41068eeeb739a51563308670c9dada91cb272d08227caf + checksum: f6c3cafdc53f5737d3b981c9e8ace574398bc999c47e87bb8c8855052b6aa53c140d8361484b573f600acbcbcc113aad4c97a6953878e8c82f0a43864b58e17c languageName: node linkType: hard @@ -18796,7 +18788,7 @@ __metadata: languageName: node linkType: hard -"is-ci@npm:^3.0.0": +"is-ci@npm:^3.0.1": version: 3.0.1 resolution: "is-ci@npm:3.0.1" dependencies: @@ -28241,6 +28233,17 @@ __metadata: languageName: node linkType: hard +"semver@npm:^7.5.4": + version: 7.6.0 + resolution: "semver@npm:7.6.0" + dependencies: + lru-cache: ^6.0.0 + bin: + semver: bin/semver.js + checksum: 7427f05b70786c696640edc29fdd4bc33b2acf3bbe1740b955029044f80575fc664e1a512e4113c3af21e767154a94b4aa214bf6cd6e42a1f6dba5914e0b208c + languageName: node + linkType: hard + "semver@npm:~7.0.0": version: 7.0.0 resolution: "semver@npm:7.0.0" From 9b40f7ffadb64178eb5bf4929c945fb3101c0128 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Wed, 27 Mar 2024 14:32:30 +0100 Subject: [PATCH 22/27] @uppy/aws-s3-multipart: mark `opts` as optional (#5039) --- packages/@uppy/aws-s3-multipart/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@uppy/aws-s3-multipart/src/index.ts b/packages/@uppy/aws-s3-multipart/src/index.ts index 94836509c4..ecee59a8ea 100644 --- a/packages/@uppy/aws-s3-multipart/src/index.ts +++ b/packages/@uppy/aws-s3-multipart/src/index.ts @@ -346,7 +346,7 @@ export default class AwsS3Multipart< protected uploaderSockets: Record - constructor(uppy: Uppy, opts: AwsS3MultipartOptions) { + constructor(uppy: Uppy, opts?: AwsS3MultipartOptions) { super(uppy, { ...defaultOptions, uploadPartBytes: AwsS3Multipart.uploadPartBytes, From 00405fb24c1797a0b64da8d50726652fe5d6ffff Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Wed, 27 Mar 2024 14:32:45 +0100 Subject: [PATCH 23/27] fix type imports (#5038) We sometimes where importing source files, which may not be available in production. Instead we should target extension-less paths in `lib/` folders so the generated `.d.ts` files are used. --- packages/@uppy/audio/src/Audio.tsx | 2 +- packages/@uppy/aws-s3-multipart/src/index.ts | 2 +- packages/@uppy/companion-client/src/Provider.ts | 4 ++-- packages/@uppy/companion-client/src/RequestClient.ts | 2 +- packages/@uppy/companion-client/src/SearchProvider.ts | 2 +- packages/@uppy/compressor/src/index.ts | 2 +- packages/@uppy/dashboard/src/Dashboard.tsx | 2 +- packages/@uppy/drag-drop/src/DragDrop.tsx | 4 ++-- packages/@uppy/drop-target/src/index.ts | 2 +- packages/@uppy/dropbox/src/Dropbox.tsx | 2 +- packages/@uppy/facebook/src/Facebook.tsx | 2 +- packages/@uppy/golden-retriever/src/index.ts | 2 +- packages/@uppy/google-drive/src/GoogleDrive.tsx | 2 +- packages/@uppy/instagram/src/Instagram.tsx | 2 +- packages/@uppy/onedrive/src/OneDrive.tsx | 2 +- packages/@uppy/progress-bar/src/ProgressBar.tsx | 3 ++- packages/@uppy/provider-views/src/Item/index.tsx | 2 +- packages/@uppy/provider-views/src/ProviderView/Header.tsx | 2 +- .../@uppy/provider-views/src/ProviderView/ProviderView.tsx | 4 ++-- .../src/SearchProviderView/SearchProviderView.tsx | 4 ++-- packages/@uppy/remote-sources/src/index.ts | 2 +- packages/@uppy/screen-capture/src/ScreenCapture.tsx | 2 +- packages/@uppy/status-bar/src/Components.tsx | 2 +- packages/@uppy/status-bar/src/StatusBar.tsx | 4 ++-- packages/@uppy/status-bar/src/StatusBarUI.tsx | 2 +- packages/@uppy/thumbnail-generator/src/index.ts | 2 +- packages/@uppy/unsplash/src/Unsplash.tsx | 2 +- packages/@uppy/webcam/src/Webcam.tsx | 4 ++-- packages/@uppy/zoom/src/Zoom.tsx | 2 +- 29 files changed, 36 insertions(+), 35 deletions(-) diff --git a/packages/@uppy/audio/src/Audio.tsx b/packages/@uppy/audio/src/Audio.tsx index fa02d5e715..a7c168bd80 100644 --- a/packages/@uppy/audio/src/Audio.tsx +++ b/packages/@uppy/audio/src/Audio.tsx @@ -6,7 +6,7 @@ import type { Meta, MinimalRequiredUppyFile, } from '@uppy/utils/lib/UppyFile' -import type { Uppy } from '@uppy/core/lib/Uppy.ts' +import type { Uppy } from '@uppy/core/lib/Uppy' import getFileTypeExtension from '@uppy/utils/lib/getFileTypeExtension' import supportsMediaRecorder from './supportsMediaRecorder.ts' diff --git a/packages/@uppy/aws-s3-multipart/src/index.ts b/packages/@uppy/aws-s3-multipart/src/index.ts index ecee59a8ea..d55937c6a2 100644 --- a/packages/@uppy/aws-s3-multipart/src/index.ts +++ b/packages/@uppy/aws-s3-multipart/src/index.ts @@ -3,7 +3,7 @@ import BasePlugin, { type PluginOpts, } from '@uppy/core/lib/BasePlugin.js' import { RequestClient } from '@uppy/companion-client' -import type { RequestOptions } from '@uppy/utils/lib/CompanionClientProvider.ts' +import type { RequestOptions } from '@uppy/utils/lib/CompanionClientProvider' import type { Body as _Body, Meta, UppyFile } from '@uppy/utils/lib/UppyFile' import type { Uppy } from '@uppy/core' import EventManager from '@uppy/core/lib/EventManager.js' diff --git a/packages/@uppy/companion-client/src/Provider.ts b/packages/@uppy/companion-client/src/Provider.ts index 9895759c47..b69eefe96a 100644 --- a/packages/@uppy/companion-client/src/Provider.ts +++ b/packages/@uppy/companion-client/src/Provider.ts @@ -1,11 +1,11 @@ import type { Uppy } from '@uppy/core' import type { Body, Meta } from '@uppy/utils/lib/UppyFile' -import type { PluginOpts } from '@uppy/core/lib/BasePlugin.ts' +import type { PluginOpts } from '@uppy/core/lib/BasePlugin' import type { RequestOptions, CompanionClientProvider, } from '@uppy/utils/lib/CompanionClientProvider' -import type { UnknownProviderPlugin } from '@uppy/core/lib/Uppy.ts' +import type { UnknownProviderPlugin } from '@uppy/core/lib/Uppy' import RequestClient, { authErrorStatusCode } from './RequestClient.ts' import type { CompanionPluginOptions } from '.' diff --git a/packages/@uppy/companion-client/src/RequestClient.ts b/packages/@uppy/companion-client/src/RequestClient.ts index fb343268e9..5c5388491a 100644 --- a/packages/@uppy/companion-client/src/RequestClient.ts +++ b/packages/@uppy/companion-client/src/RequestClient.ts @@ -9,7 +9,7 @@ import getSocketHost from '@uppy/utils/lib/getSocketHost' import type Uppy from '@uppy/core' import type { UppyFile, Meta, Body } from '@uppy/utils/lib/UppyFile' -import type { RequestOptions } from '@uppy/utils/lib/CompanionClientProvider.ts' +import type { RequestOptions } from '@uppy/utils/lib/CompanionClientProvider' import AuthError from './AuthError.ts' // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore We don't want TS to generate types for the package.json diff --git a/packages/@uppy/companion-client/src/SearchProvider.ts b/packages/@uppy/companion-client/src/SearchProvider.ts index f3699168c4..b0c83b5fdd 100644 --- a/packages/@uppy/companion-client/src/SearchProvider.ts +++ b/packages/@uppy/companion-client/src/SearchProvider.ts @@ -1,4 +1,4 @@ -import type { Body, Meta } from '@uppy/utils/lib/UppyFile.ts' +import type { Body, Meta } from '@uppy/utils/lib/UppyFile' import type { Uppy } from '@uppy/core' import type { CompanionClientSearchProvider } from '@uppy/utils/lib/CompanionClientProvider' import RequestClient, { type Opts } from './RequestClient.ts' diff --git a/packages/@uppy/compressor/src/index.ts b/packages/@uppy/compressor/src/index.ts index c9f6eaaab4..d02c9a8da7 100644 --- a/packages/@uppy/compressor/src/index.ts +++ b/packages/@uppy/compressor/src/index.ts @@ -7,7 +7,7 @@ import prettierBytes from '@transloadit/prettier-bytes' import CompressorJS from 'compressorjs' import type { Body, Meta, UppyFile } from '@uppy/utils/lib/UppyFile' -import type { PluginOpts } from '@uppy/core/lib/BasePlugin.ts' +import type { PluginOpts } from '@uppy/core/lib/BasePlugin' import locale from './locale.ts' diff --git a/packages/@uppy/dashboard/src/Dashboard.tsx b/packages/@uppy/dashboard/src/Dashboard.tsx index 8d435b9e45..3c6d087fc0 100644 --- a/packages/@uppy/dashboard/src/Dashboard.tsx +++ b/packages/@uppy/dashboard/src/Dashboard.tsx @@ -7,7 +7,7 @@ import { type State, } from '@uppy/core' import type { ComponentChild, VNode } from 'preact' -import type { DefinePluginOpts } from '@uppy/core/lib/BasePlugin.ts' +import type { DefinePluginOpts } from '@uppy/core/lib/BasePlugin' import type { Body, Meta, UppyFile } from '@uppy/utils/lib/UppyFile' import StatusBar from '@uppy/status-bar' import Informer from '@uppy/informer' diff --git a/packages/@uppy/drag-drop/src/DragDrop.tsx b/packages/@uppy/drag-drop/src/DragDrop.tsx index d53381d140..32445dfc89 100644 --- a/packages/@uppy/drag-drop/src/DragDrop.tsx +++ b/packages/@uppy/drag-drop/src/DragDrop.tsx @@ -1,6 +1,6 @@ import { UIPlugin, type Uppy } from '@uppy/core' -import type { DefinePluginOpts } from '@uppy/core/lib/BasePlugin.ts' -import type { UIPluginOptions } from '@uppy/core/lib/UIPlugin.ts' +import type { DefinePluginOpts } from '@uppy/core/lib/BasePlugin' +import type { UIPluginOptions } from '@uppy/core/lib/UIPlugin' import type { Body, Meta } from '@uppy/utils/lib/UppyFile' import type { ChangeEvent } from 'preact/compat' import toArray from '@uppy/utils/lib/toArray' diff --git a/packages/@uppy/drop-target/src/index.ts b/packages/@uppy/drop-target/src/index.ts index 54614cfb59..373602d5b1 100644 --- a/packages/@uppy/drop-target/src/index.ts +++ b/packages/@uppy/drop-target/src/index.ts @@ -1,5 +1,5 @@ import type { Body, Meta } from '@uppy/utils/lib/UppyFile' -import type { Uppy } from '@uppy/core/src/Uppy.ts' +import type { Uppy } from '@uppy/core/lib/Uppy' import type { DefinePluginOpts, PluginOpts } from '@uppy/core/lib/BasePlugin.js' import BasePlugin from '@uppy/core/lib/BasePlugin.js' import getDroppedFiles from '@uppy/utils/lib/getDroppedFiles' diff --git a/packages/@uppy/dropbox/src/Dropbox.tsx b/packages/@uppy/dropbox/src/Dropbox.tsx index 1e5c2cca7c..3b4f24809b 100644 --- a/packages/@uppy/dropbox/src/Dropbox.tsx +++ b/packages/@uppy/dropbox/src/Dropbox.tsx @@ -9,7 +9,7 @@ import { ProviderViews } from '@uppy/provider-views' import { h, type ComponentChild } from 'preact' import type { UppyFile, Body, Meta } from '@uppy/utils/lib/UppyFile' -import type { UnknownProviderPluginState } from '@uppy/core/lib/Uppy.ts' +import type { UnknownProviderPluginState } from '@uppy/core/lib/Uppy' import locale from './locale.ts' // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore We don't want TS to generate types for the package.json diff --git a/packages/@uppy/facebook/src/Facebook.tsx b/packages/@uppy/facebook/src/Facebook.tsx index 91c186d1c2..a4732235f3 100644 --- a/packages/@uppy/facebook/src/Facebook.tsx +++ b/packages/@uppy/facebook/src/Facebook.tsx @@ -9,7 +9,7 @@ import { ProviderViews } from '@uppy/provider-views' import { h, type ComponentChild } from 'preact' import type { UppyFile, Body, Meta } from '@uppy/utils/lib/UppyFile' -import type { UnknownProviderPluginState } from '@uppy/core/lib/Uppy.ts' +import type { UnknownProviderPluginState } from '@uppy/core/lib/Uppy' import locale from './locale.ts' // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore We don't want TS to generate types for the package.json diff --git a/packages/@uppy/golden-retriever/src/index.ts b/packages/@uppy/golden-retriever/src/index.ts index e1992b8bf0..a2d30a9389 100644 --- a/packages/@uppy/golden-retriever/src/index.ts +++ b/packages/@uppy/golden-retriever/src/index.ts @@ -1,6 +1,6 @@ import throttle from 'lodash/throttle.js' import BasePlugin from '@uppy/core/lib/BasePlugin.js' -import type { PluginOpts, DefinePluginOpts } from '@uppy/core/lib/BasePlugin.ts' +import type { PluginOpts, DefinePluginOpts } from '@uppy/core/lib/BasePlugin' import type { Body, Meta, UppyFile } from '@uppy/utils/lib/UppyFile' import type Uppy from '@uppy/core' import type { UploadResult } from '@uppy/core' diff --git a/packages/@uppy/google-drive/src/GoogleDrive.tsx b/packages/@uppy/google-drive/src/GoogleDrive.tsx index 1ae6a231b0..ac028d6bc4 100644 --- a/packages/@uppy/google-drive/src/GoogleDrive.tsx +++ b/packages/@uppy/google-drive/src/GoogleDrive.tsx @@ -9,7 +9,7 @@ import { ProviderViews } from '@uppy/provider-views' import { h, type ComponentChild } from 'preact' import type { UppyFile, Body, Meta } from '@uppy/utils/lib/UppyFile' -import type { UnknownProviderPluginState } from '@uppy/core/lib/Uppy.ts' +import type { UnknownProviderPluginState } from '@uppy/core/lib/Uppy' import DriveProviderViews from './DriveProviderViews.ts' import locale from './locale.ts' // eslint-disable-next-line @typescript-eslint/ban-ts-comment diff --git a/packages/@uppy/instagram/src/Instagram.tsx b/packages/@uppy/instagram/src/Instagram.tsx index 7a215126d7..59005be3be 100644 --- a/packages/@uppy/instagram/src/Instagram.tsx +++ b/packages/@uppy/instagram/src/Instagram.tsx @@ -9,7 +9,7 @@ import { ProviderViews } from '@uppy/provider-views' import { h, type ComponentChild } from 'preact' import type { UppyFile, Body, Meta } from '@uppy/utils/lib/UppyFile' -import type { UnknownProviderPluginState } from '@uppy/core/lib/Uppy.ts' +import type { UnknownProviderPluginState } from '@uppy/core/lib/Uppy' import locale from './locale.ts' // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore We don't want TS to generate types for the package.json diff --git a/packages/@uppy/onedrive/src/OneDrive.tsx b/packages/@uppy/onedrive/src/OneDrive.tsx index 6d7c20c6d4..fc31c8d306 100644 --- a/packages/@uppy/onedrive/src/OneDrive.tsx +++ b/packages/@uppy/onedrive/src/OneDrive.tsx @@ -9,7 +9,7 @@ import { ProviderViews } from '@uppy/provider-views' import { h, type ComponentChild } from 'preact' import type { UppyFile, Body, Meta } from '@uppy/utils/lib/UppyFile' -import type { UnknownProviderPluginState } from '@uppy/core/lib/Uppy.ts' +import type { UnknownProviderPluginState } from '@uppy/core/lib/Uppy' import locale from './locale.ts' // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore We don't want TS to generate types for the package.json diff --git a/packages/@uppy/progress-bar/src/ProgressBar.tsx b/packages/@uppy/progress-bar/src/ProgressBar.tsx index a7230999a3..f4587146d1 100644 --- a/packages/@uppy/progress-bar/src/ProgressBar.tsx +++ b/packages/@uppy/progress-bar/src/ProgressBar.tsx @@ -1,5 +1,6 @@ import { h, type ComponentChild } from 'preact' -import { UIPlugin, Uppy, type UIPluginOptions, type State } from '@uppy/core' +import { UIPlugin, type UIPluginOptions } from '@uppy/core' +import type { Uppy, State } from '@uppy/core/lib/Uppy' import type { Body, Meta } from '@uppy/utils/lib/UppyFile' import type { DefinePluginOpts } from '@uppy/core/lib/BasePlugin.js' diff --git a/packages/@uppy/provider-views/src/Item/index.tsx b/packages/@uppy/provider-views/src/Item/index.tsx index 97040c1373..e3a6dd6d4c 100644 --- a/packages/@uppy/provider-views/src/Item/index.tsx +++ b/packages/@uppy/provider-views/src/Item/index.tsx @@ -4,7 +4,7 @@ import { h } from 'preact' import classNames from 'classnames' import type { I18n } from '@uppy/utils/lib/Translator' import type { CompanionFile } from '@uppy/utils/lib/CompanionFile' -import type { RestrictionError } from '@uppy/core/lib/Restricter.ts' +import type { RestrictionError } from '@uppy/core/lib/Restricter' import type { Meta, Body } from '@uppy/utils/lib/UppyFile' import ItemIcon from './components/ItemIcon.tsx' import GridListItem from './components/GridLi.tsx' diff --git a/packages/@uppy/provider-views/src/ProviderView/Header.tsx b/packages/@uppy/provider-views/src/ProviderView/Header.tsx index f0e3d70dfa..1ef7b7855d 100644 --- a/packages/@uppy/provider-views/src/ProviderView/Header.tsx +++ b/packages/@uppy/provider-views/src/ProviderView/Header.tsx @@ -2,7 +2,7 @@ import { h, Fragment } from 'preact' import type { I18n } from '@uppy/utils/lib/Translator' import type { Body, Meta } from '@uppy/utils/lib/UppyFile' -import type { UnknownProviderPluginState } from '@uppy/core/lib/Uppy.ts' +import type { UnknownProviderPluginState } from '@uppy/core/lib/Uppy' import User from './User.tsx' import Breadcrumbs from '../Breadcrumbs.tsx' import type ProviderView from './ProviderView.tsx' diff --git a/packages/@uppy/provider-views/src/ProviderView/ProviderView.tsx b/packages/@uppy/provider-views/src/ProviderView/ProviderView.tsx index 689092acd3..8d2d0cd721 100644 --- a/packages/@uppy/provider-views/src/ProviderView/ProviderView.tsx +++ b/packages/@uppy/provider-views/src/ProviderView/ProviderView.tsx @@ -9,9 +9,9 @@ import type { Uppy, } from '@uppy/core/lib/Uppy.ts' import type { Body, Meta } from '@uppy/utils/lib/UppyFile' -import type { CompanionFile } from '@uppy/utils/lib/CompanionFile.ts' +import type { CompanionFile } from '@uppy/utils/lib/CompanionFile' import type Translator from '@uppy/utils/lib/Translator' -import type { DefinePluginOpts } from '@uppy/core/lib/BasePlugin.ts' +import type { DefinePluginOpts } from '@uppy/core/lib/BasePlugin' import AuthView from './AuthView.tsx' import Header from './Header.tsx' import Browser from '../Browser.tsx' diff --git a/packages/@uppy/provider-views/src/SearchProviderView/SearchProviderView.tsx b/packages/@uppy/provider-views/src/SearchProviderView/SearchProviderView.tsx index cea682fc4f..83361400be 100644 --- a/packages/@uppy/provider-views/src/SearchProviderView/SearchProviderView.tsx +++ b/packages/@uppy/provider-views/src/SearchProviderView/SearchProviderView.tsx @@ -1,8 +1,8 @@ import { h } from 'preact' import type { Body, Meta } from '@uppy/utils/lib/UppyFile' -import type { UnknownSearchProviderPlugin } from '@uppy/core/lib/Uppy.ts' -import type { DefinePluginOpts } from '@uppy/core/lib/BasePlugin.ts' +import type { UnknownSearchProviderPlugin } from '@uppy/core/lib/Uppy' +import type { DefinePluginOpts } from '@uppy/core/lib/BasePlugin' import type Uppy from '@uppy/core' import type { CompanionFile } from '@uppy/utils/lib/CompanionFile' import SearchFilterInput from '../SearchFilterInput.tsx' diff --git a/packages/@uppy/remote-sources/src/index.ts b/packages/@uppy/remote-sources/src/index.ts index 4b54c802c3..84e9f44b05 100644 --- a/packages/@uppy/remote-sources/src/index.ts +++ b/packages/@uppy/remote-sources/src/index.ts @@ -15,7 +15,7 @@ import Url from '@uppy/url' import Zoom from '@uppy/zoom' import type { DefinePluginOpts } from '@uppy/core/lib/BasePlugin' -import type { Body, Meta } from '../../utils/src/UppyFile' +import type { Body, Meta } from '@uppy/utils/lib/UppyFile' // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore We don't want TS to generate types for the package.json import packageJson from '../package.json' diff --git a/packages/@uppy/screen-capture/src/ScreenCapture.tsx b/packages/@uppy/screen-capture/src/ScreenCapture.tsx index 72dd3c0cb4..9d221ee8a4 100644 --- a/packages/@uppy/screen-capture/src/ScreenCapture.tsx +++ b/packages/@uppy/screen-capture/src/ScreenCapture.tsx @@ -1,7 +1,7 @@ import { h, type ComponentChild } from 'preact' import { UIPlugin, Uppy, type UIPluginOptions } from '@uppy/core' import getFileTypeExtension from '@uppy/utils/lib/getFileTypeExtension' -import type { DefinePluginOpts } from '@uppy/core/lib/BasePlugin.ts' +import type { DefinePluginOpts } from '@uppy/core/lib/BasePlugin' import type { Body, Meta } from '@uppy/utils/lib/UppyFile' import ScreenRecIcon from './ScreenRecIcon.tsx' import RecorderScreen from './RecorderScreen.tsx' diff --git a/packages/@uppy/status-bar/src/Components.tsx b/packages/@uppy/status-bar/src/Components.tsx index 11bc73eb28..c45aa9b27d 100644 --- a/packages/@uppy/status-bar/src/Components.tsx +++ b/packages/@uppy/status-bar/src/Components.tsx @@ -1,5 +1,5 @@ import type { Body, Meta } from '@uppy/utils/lib/UppyFile' -import type { State, Uppy } from '@uppy/core/src/Uppy.ts' +import type { State, Uppy } from '@uppy/core/lib/Uppy' import type { FileProcessingInfo } from '@uppy/utils/lib/FileProgress' import type { I18n } from '@uppy/utils/lib/Translator' import { h } from 'preact' diff --git a/packages/@uppy/status-bar/src/StatusBar.tsx b/packages/@uppy/status-bar/src/StatusBar.tsx index 2eaaa3245a..26c29b87ed 100644 --- a/packages/@uppy/status-bar/src/StatusBar.tsx +++ b/packages/@uppy/status-bar/src/StatusBar.tsx @@ -1,7 +1,7 @@ import type { ComponentChild } from 'preact' import type { Body, Meta, UppyFile } from '@uppy/utils/lib/UppyFile' -import type { Uppy, State } from '@uppy/core/src/Uppy.ts' -import type { DefinePluginOpts } from '@uppy/core/lib/BasePlugin.ts' +import type { Uppy, State } from '@uppy/core/lib/Uppy' +import type { DefinePluginOpts } from '@uppy/core/lib/BasePlugin' import { UIPlugin } from '@uppy/core' import emaFilter from '@uppy/utils/lib/emaFilter' import getTextDirection from '@uppy/utils/lib/getTextDirection' diff --git a/packages/@uppy/status-bar/src/StatusBarUI.tsx b/packages/@uppy/status-bar/src/StatusBarUI.tsx index df3ec2d19b..47c6d923e0 100644 --- a/packages/@uppy/status-bar/src/StatusBarUI.tsx +++ b/packages/@uppy/status-bar/src/StatusBarUI.tsx @@ -1,6 +1,6 @@ import type { Body, Meta, UppyFile } from '@uppy/utils/lib/UppyFile' import type { I18n } from '@uppy/utils/lib/Translator' -import type { Uppy, State } from '@uppy/core/src/Uppy.ts' +import type { Uppy, State } from '@uppy/core/lib/Uppy' import { h } from 'preact' import classNames from 'classnames' import statusBarStates from './StatusBarStates.ts' diff --git a/packages/@uppy/thumbnail-generator/src/index.ts b/packages/@uppy/thumbnail-generator/src/index.ts index 4763a798a7..d0205de2b1 100644 --- a/packages/@uppy/thumbnail-generator/src/index.ts +++ b/packages/@uppy/thumbnail-generator/src/index.ts @@ -6,7 +6,7 @@ import isPreviewSupported from '@uppy/utils/lib/isPreviewSupported' // @ts-ignore untyped import { rotation } from 'exifr/dist/mini.esm.mjs' -import type { DefinePluginOpts } from '@uppy/core/lib/BasePlugin.ts' +import type { DefinePluginOpts } from '@uppy/core/lib/BasePlugin' import type { Body, Meta, UppyFile } from '@uppy/utils/lib/UppyFile' import locale from './locale.ts' // eslint-disable-next-line @typescript-eslint/ban-ts-comment diff --git a/packages/@uppy/unsplash/src/Unsplash.tsx b/packages/@uppy/unsplash/src/Unsplash.tsx index a5c8d5403b..acf9983fc4 100644 --- a/packages/@uppy/unsplash/src/Unsplash.tsx +++ b/packages/@uppy/unsplash/src/Unsplash.tsx @@ -9,7 +9,7 @@ import { SearchProviderViews } from '@uppy/provider-views' import { h, type ComponentChild } from 'preact' import type { UppyFile, Body, Meta } from '@uppy/utils/lib/UppyFile' -import type { UnknownSearchProviderPluginState } from '@uppy/core/lib/Uppy.ts' +import type { UnknownSearchProviderPluginState } from '@uppy/core/lib/Uppy' // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore We don't want TS to generate types for the package.json import packageJson from '../package.json' diff --git a/packages/@uppy/webcam/src/Webcam.tsx b/packages/@uppy/webcam/src/Webcam.tsx index 4c990d2af5..217f2dd642 100644 --- a/packages/@uppy/webcam/src/Webcam.tsx +++ b/packages/@uppy/webcam/src/Webcam.tsx @@ -2,13 +2,13 @@ import { h, type ComponentChild } from 'preact' import { UIPlugin } from '@uppy/core' import type { Uppy, UIPluginOptions } from '@uppy/core' -import type { DefinePluginOpts } from '@uppy/core/lib/BasePlugin.ts' +import type { DefinePluginOpts } from '@uppy/core/lib/BasePlugin' import type { Body, Meta, MinimalRequiredUppyFile, } from '@uppy/utils/lib/UppyFile.ts' -import type { PluginTarget } from '@uppy/core/lib/UIPlugin.ts' +import type { PluginTarget } from '@uppy/core/lib/UIPlugin' import getFileTypeExtension from '@uppy/utils/lib/getFileTypeExtension' import mimeTypes from '@uppy/utils/lib/mimeTypes' import isMobile from 'is-mobile' diff --git a/packages/@uppy/zoom/src/Zoom.tsx b/packages/@uppy/zoom/src/Zoom.tsx index 3bc576fa40..40386918e3 100644 --- a/packages/@uppy/zoom/src/Zoom.tsx +++ b/packages/@uppy/zoom/src/Zoom.tsx @@ -9,7 +9,7 @@ import { ProviderViews } from '@uppy/provider-views' import { h, type ComponentChild } from 'preact' import type { UppyFile, Body, Meta } from '@uppy/utils/lib/UppyFile' -import type { UnknownProviderPluginState } from '@uppy/core/lib/Uppy.ts' +import type { UnknownProviderPluginState } from '@uppy/core/lib/Uppy' import locale from './locale.ts' // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore We don't want TS to generate types for the package.json From b2346fa4c358ecaf154c0bc42bd21d5e4e174fa8 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Thu, 28 Mar 2024 10:04:39 +0100 Subject: [PATCH 24/27] @uppy/svelte: remove UMD output and make it use newer types (#5023) --- package.json | 4 +- packages/@uppy/svelte/.npmignore | 2 +- packages/@uppy/svelte/package.json | 22 +- packages/@uppy/svelte/rollup.config.js | 20 +- .../svelte/src/components/Dashboard.svelte | 10 +- .../src/components/DashboardModal.svelte | 10 +- .../svelte/src/components/DragDrop.svelte | 10 +- .../svelte/src/components/ProgressBar.svelte | 12 +- .../svelte/src/components/StatusBar.svelte | 12 +- packages/@uppy/svelte/src/empty.ts | 1 - .../@uppy/svelte/src/{index.js => index.ts} | 0 packages/@uppy/svelte/tsconfig.json | 13 +- packages/@uppy/svelte/typings/index.d.ts | 5 + yarn.lock | 211 +++++++++++++++++- 14 files changed, 275 insertions(+), 57 deletions(-) delete mode 100644 packages/@uppy/svelte/src/empty.ts rename packages/@uppy/svelte/src/{index.js => index.ts} (100%) create mode 100644 packages/@uppy/svelte/typings/index.d.ts diff --git a/package.json b/package.json index 330b3a07ae..b34f0a769b 100644 --- a/package.json +++ b/package.json @@ -120,7 +120,7 @@ "build:svelte": "yarn workspace @uppy/svelte build", "build:angular": "yarn workspace angular build", "build:js": "npm-run-all build:lib build:companion build:locale-pack build:svelte build:bundle", - "build:ts": "yarn workspaces list --no-private --json | yarn node ./bin/build-ts.mjs", + "build:ts": "yarn workspaces list --no-private --json | yarn node ./bin/build-ts.mjs && yarn workspace @uppy/svelte validate", "build:lib": "yarn node ./bin/build-lib.js", "build:locale-pack": "yarn workspace @uppy-dev/locale-pack build && eslint packages/@uppy/locales/src/en_US.js --fix && yarn workspace @uppy-dev/locale-pack test unused", "build": "npm-run-all --parallel build:ts build:js build:css --serial size", @@ -153,7 +153,7 @@ "test:locale-packs:warnings": "yarn workspace @uppy-dev/locale-pack test warnings", "test:unit": "yarn run build:lib && yarn test:watch --run", "test:watch": "vitest --environment jsdom --dir packages/@uppy", - "test": "npm-run-all lint test:locale-packs:unused test:unit test:type test:companion", + "test": "npm-run-all lint test:locale-packs:unused test:unit test:companion", "uploadcdn": "yarn node ./bin/upload-to-cdn.js", "version": "yarn node ./bin/after-version-bump.js", "watch:css": "onchange 'packages/{@uppy/,}*/src/*.scss' --initial --verbose -- yarn run build:css", diff --git a/packages/@uppy/svelte/.npmignore b/packages/@uppy/svelte/.npmignore index 1fc72b6165..6c816673f0 100644 --- a/packages/@uppy/svelte/.npmignore +++ b/packages/@uppy/svelte/.npmignore @@ -1 +1 @@ -# This file need to be there so .gitignored files are still uploaded to the npm registry. +tsconfig.* diff --git a/packages/@uppy/svelte/package.json b/packages/@uppy/svelte/package.json index 2b73f04ec6..f771af75da 100644 --- a/packages/@uppy/svelte/package.json +++ b/packages/@uppy/svelte/package.json @@ -1,15 +1,20 @@ { "name": "@uppy/svelte", "description": "Uppy plugin that helps integrate Uppy into your Svelte project.", - "svelte": "src/index.js", - "module": "dist/index.mjs", - "main": "dist/index.js", + "type": "module", "version": "3.1.3", "scripts": { "build": "rollup -c", "prepublishOnly": "yarn run build", "validate": "svelte-check" }, + "exports": { + ".": { + "svelte": "./src/index.js", + "default": "./lib/index.js" + }, + "./package.json": "./package.json" + }, "homepage": "https://uppy.io", "bugs": { "url": "https://github.com/transloadit/uppy/issues" @@ -18,11 +23,15 @@ "type": "git", "url": "git+https://github.com/transloadit/uppy.git" }, + "dependencies": { + "@uppy/utils": "workspace:^" + }, "devDependencies": { "@rollup/plugin-node-resolve": "^13.0.0", "@tsconfig/svelte": "^5.0.0", - "rollup": "^2.60.2", + "rollup": "^4.0.0", "rollup-plugin-svelte": "^7.0.0", + "rollup-plugin-svelte-types": "^1.0.6", "svelte": "^4.0.0", "svelte-check": "^3.0.0", "svelte-preprocess": "^5.0.0" @@ -33,7 +42,7 @@ "@uppy/drag-drop": "workspace:^", "@uppy/progress-bar": "workspace:^", "@uppy/status-bar": "workspace:^", - "svelte": "^3.0.0 || ^4.0.0" + "svelte": "^4.0.0" }, "publishConfig": { "access": "public" @@ -46,6 +55,7 @@ ], "files": [ "src", - "dist" + "lib", + "typings" ] } diff --git a/packages/@uppy/svelte/rollup.config.js b/packages/@uppy/svelte/rollup.config.js index f941b8a4d9..f19675b8e8 100644 --- a/packages/@uppy/svelte/rollup.config.js +++ b/packages/@uppy/svelte/rollup.config.js @@ -1,12 +1,7 @@ import svelte from 'rollup-plugin-svelte' import resolve from '@rollup/plugin-node-resolve' import preprocess from 'svelte-preprocess' -import pkg from './package.json' - -const name = pkg.name - .replace(/^(@\S+\/)?(svelte-)?(\S+)/, '$3') - .replace(/^\w/, m => m.toUpperCase()) - .replace(/-\w/g, m => m[1].toUpperCase()) +import svelteDts from 'rollup-plugin-svelte-types'; const globals = { '@uppy/dashboard': 'Dashboard', @@ -16,19 +11,13 @@ const globals = { } export default { - input: 'src/index.js', + input: 'src/index.ts', output: [ { - file: pkg.module, + file: 'lib/index.js', format: 'es', globals, }, - { - file: pkg.main, - format: 'umd', - name, - globals, - }, ], plugins: [ svelte({ @@ -38,5 +27,8 @@ export default { resolve({ resolveOnly: ['svelte'], }), + svelteDts.default({ + declarationDir: './lib/' + }) ], } diff --git a/packages/@uppy/svelte/src/components/Dashboard.svelte b/packages/@uppy/svelte/src/components/Dashboard.svelte index a986e6cd95..4e7db386bd 100644 --- a/packages/@uppy/svelte/src/components/Dashboard.svelte +++ b/packages/@uppy/svelte/src/components/Dashboard.svelte @@ -1,12 +1,12 @@ - - - diff --git a/examples/vue/package.json b/examples/vue/package.json deleted file mode 100644 index 95219324ea..0000000000 --- a/examples/vue/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@uppy-example/vue2", - "version": "0.0.0", - "private": true, - "scripts": { - "dev": "vite", - "build": "vite build", - "preview": "vite preview --port 5050" - }, - "dependencies": { - "@uppy/core": "workspace:*", - "@uppy/dashboard": "workspace:*", - "@uppy/drag-drop": "workspace:*", - "@uppy/progress-bar": "workspace:*", - "@uppy/transloadit": "workspace:*", - "@uppy/vue": "workspace:*", - "vue": "^2.6.14" - }, - "devDependencies": { - "vite": "^5.0.0", - "vite-plugin-vue2": "^2.0.1", - "vue-template-compiler": "^2.6.14" - } -} diff --git a/examples/vue/src/App.vue b/examples/vue/src/App.vue deleted file mode 100644 index 1ab426d370..0000000000 --- a/examples/vue/src/App.vue +++ /dev/null @@ -1,111 +0,0 @@ - - - - - - - - - diff --git a/examples/vue/src/main.js b/examples/vue/src/main.js deleted file mode 100644 index 63eb05f711..0000000000 --- a/examples/vue/src/main.js +++ /dev/null @@ -1,8 +0,0 @@ -import Vue from 'vue' -import App from './App.vue' - -Vue.config.productionTip = false - -new Vue({ - render: h => h(App), -}).$mount('#app') diff --git a/examples/vue/vite.config.js b/examples/vue/vite.config.js deleted file mode 100644 index fa372dc49b..0000000000 --- a/examples/vue/vite.config.js +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from 'vite' -import { createVuePlugin } from 'vite-plugin-vue2' - -// https://vitejs.dev/config/ -export default defineConfig({ - plugins: [createVuePlugin()], -}) diff --git a/examples/vue3/package.json b/examples/vue3/package.json index 526d0e026c..0b0eaec358 100644 --- a/examples/vue3/package.json +++ b/examples/vue3/package.json @@ -14,6 +14,7 @@ "@uppy/progress-bar": "workspace:*", "@uppy/tus": "workspace:*", "@uppy/vue": "workspace:*", + "@uppy/webcam": "workspace:*", "vue": "^3.2.33" }, "devDependencies": { diff --git a/examples/vue3/src/App.vue b/examples/vue3/src/App.vue index 89d8480c53..f99f38f495 100644 --- a/examples/vue3/src/App.vue +++ b/examples/vue3/src/App.vue @@ -1,7 +1,3 @@ - - + + `) or bundle it with your webapp. +this from a CDN (``) or bundle it with your webapp. Note that the recommended way to use Uppy is to install it with yarn/npm and use a bundler like Webpack so that you can create a smaller custom build with only the diff --git a/CHANGELOG.md b/CHANGELOG.md index 68e8cee0e1..559d6b5ade 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,171 @@ Please add your entries in this format: In the current stage we aim to release a new version at least every month. +## 4.0.0-beta.1 + +Released: 2024-03-28 + +| Package | Version | Package | Version | +| ------------------------- | ------------ | ------------------------- | ------------ | +| @uppy/angular | 0.7.0-beta.1 | @uppy/progress-bar | 4.0.0-beta.1 | +| @uppy/audio | 2.0.0-beta.1 | @uppy/provider-views | 4.0.0-beta.1 | +| @uppy/aws-s3 | 4.0.0-beta.1 | @uppy/react | 4.0.0-beta.1 | +| @uppy/aws-s3-multipart | 4.0.0-beta.1 | @uppy/redux-dev-tools | 4.0.0-beta.1 | +| @uppy/box | 3.0.0-beta.1 | @uppy/remote-sources | 2.0.0-beta.1 | +| @uppy/companion | 5.0.0-beta.1 | @uppy/screen-capture | 4.0.0-beta.1 | +| @uppy/companion-client | 4.0.0-beta.1 | @uppy/status-bar | 4.0.0-beta.1 | +| @uppy/compressor | 2.0.0-beta.1 | @uppy/store-default | 4.0.0-beta.1 | +| @uppy/core | 4.0.0-beta.1 | @uppy/store-redux | 4.0.0-beta.1 | +| @uppy/dashboard | 4.0.0-beta.1 | @uppy/svelte | 4.0.0-beta.1 | +| @uppy/drag-drop | 4.0.0-beta.1 | @uppy/thumbnail-generator | 4.0.0-beta.1 | +| @uppy/drop-target | 3.0.0-beta.1 | @uppy/transloadit | 4.0.0-beta.1 | +| @uppy/dropbox | 4.0.0-beta.1 | @uppy/tus | 4.0.0-beta.1 | +| @uppy/facebook | 4.0.0-beta.1 | @uppy/unsplash | 4.0.0-beta.1 | +| @uppy/file-input | 4.0.0-beta.1 | @uppy/url | 4.0.0-beta.1 | +| @uppy/form | 4.0.0-beta.1 | @uppy/utils | 6.0.0-beta.1 | +| @uppy/golden-retriever | 4.0.0-beta.1 | @uppy/vue | 2.0.0-beta.1 | +| @uppy/google-drive | 4.0.0-beta.1 | @uppy/webcam | 4.0.0-beta.1 | +| @uppy/image-editor | 3.0.0-beta.1 | @uppy/xhr-upload | 4.0.0-beta.1 | +| @uppy/informer | 4.0.0-beta.1 | @uppy/zoom | 3.0.0-beta.1 | +| @uppy/instagram | 4.0.0-beta.1 | uppy | 4.0.0-beta.1 | +| @uppy/onedrive | 4.0.0-beta.1 | | | + +- @uppy/vue: migrate to Composition API with TS & drop Vue 2 support (Merlijn Vos / #5043) +- @uppy/angular: upgrade to Angular 17.x and to TS 5.4 (Antoine du Hamel / #5008) +- @uppy/svelte: remove UMD output and make it use newer types (Antoine du Hamel / #5023) +- @uppy/companion-client,@uppy/provider-views,@uppy/status-bar: fix type imports (Antoine du Hamel / #5038) +- @uppy/aws-s3-multipart: mark `opts` as optional (Antoine du Hamel / #5039) +- e2e: bump Cypress version (Antoine du Hamel / #5034) +- @uppy/react: remove `prop-types` dependency (Antoine du Hamel / #5031) +- @uppy/progress-bar: remove default target (Antoine du Hamel / #4971) +- @uppy/status-bar: remove default target (Antoine du Hamel / #4970) +- @uppy/react: remove `Wrapper.ts` (Antoine du Hamel / #5032) +- @uppy/react: refactor to TS (Antoine du Hamel / #5012) +- @uppy/core: refine type of private variables (Antoine du Hamel / #5028) +- @uppy/dashboard: refine type of private variables (Antoine du Hamel / #5027) +- @uppy/drag-drop: refine type of private variables (Antoine du Hamel / #5026) +- @uppy/status-bar: refine type of private variables (Antoine du Hamel / #5025) +- @uppy/remote-sources: migrate to TS (Merlijn Vos / #5020) +- @uppy/dashboard: refine option types (Antoine du Hamel / #5022) +- @uppy/dashboard: add new `autoOpen` option (Chris Grigg / #5001) +- @uppy/aws-s3-multipart,@uppy/tus,@uppy/utils,@uppy/xhr-upload: Make `allowedMetaFields` consistent (Merlijn Vos / #5011) +- @uppy/core: fix some type errors (Antoine du Hamel / #5015) +- @uppy/audio,@uppy/dashboard,@uppy/drop-target,@uppy/webcam: add missing exports (Antoine du Hamel / #5014) +- meta: Bump webpack-dev-middleware from 5.3.3 to 5.3.4 (dependabot[bot] / #5013) +- @uppy/dashboard: refactor to TypeScript (Antoine du Hamel / #4984) +- @uppy/companion: improve error msg (Mikael Finstad / #5010) +- @uppy/aws-s3-multipart: refactor to TS (Antoine du Hamel / #4902) +- @uppy/dashboard: refactor to stable lifecycle method (Antoine du Hamel / #4999) +- @uppy/companion: crash if trying to set path to / (Mikael Finstad / #5003) +- @uppy/provider-views: fix `super.toggleCheckbox` bug (Mikael Finstad / #5004) +- @uppy/aws-s3-multipart: fix escaping issue with client signed request (Hiroki Shimizu / #5006) +- @uppy/drag-drop,@uppy/progress-bar: add missing exports (Antoine du Hamel / #5009) +- @uppy/transloadit: migrate to TS (Merlijn Vos / #4987) +- @uppy/utils: fix `RateLimitedQueue#wrapPromiseFunction` types (Antoine du Hamel / #5007) +- @uppy/golden-retriever: migrate to TS (Merlijn Vos / #4989) +- meta: Bump follow-redirects from 1.15.4 to 1.15.6 (dependabot[bot] / #5002) +- meta: fix `resize-observer-polyfill` types (Antoine du Hamel / #4994) +- @uppy/core: various type fixes (Antoine du Hamel / #4995) +- @uppy/utils: fix `findAllDOMElements` type (Antoine du Hamel / #4997) +- @uppy/status-bar: fix `recoveredState` type (Antoine du Hamel / #4996) +- @uppy/utils: fix `AbortablePromise` type (Antoine du Hamel / #4988) +- @uppy/core,@uppy/provider-views: Fix breadcrumbs (Evgenia Karunus / #4986) +- @uppy/drag-drop: refactor to TypeScript (Antoine du Hamel / #4983) +- @uppy/webcam: refactor to TypeScript (Antoine du Hamel / #4870) +- @uppy/url: migrate to TS (Merlijn Vos / #4980) +- @uppy/zoom: refactor to TypeScript (Murderlon / #4979) +- @uppy/unsplash: refactor to TypeScript (Murderlon / #4979) +- @uppy/onedrive: refactor to TypeScript (Murderlon / #4979) +- @uppy/instagram: refactor to TypeScript (Murderlon / #4979) +- @uppy/google-drive: refactor to TypeScript (Murderlon / #4979) +- @uppy/facebook: refactor to TypeScript (Murderlon / #4979) +- @uppy/dropbox: refactor to TypeScript (Murderlon / #4979) +- @uppy/box: refactor to TypeScript (Murderlon / #4979) +- @uppy/utils: migrate RateLimitedQueue to TS (Merlijn Vos / #4981) +- @uppy/thumbnail-generator: migrate to TS (Merlijn Vos / #4978) +- @uppy/screen-capture: migrate to TS (Merlijn Vos / #4965) +- @uppy/companion-client: Replace Provider.initPlugin with composition (Merlijn Vos / #4977) +- uppy: remove legacy bundle (Antoine du Hamel) +- meta: include types in npm archive (Antoine du Hamel) +- @uppy/angular: fix build (Antoine du Hamel) +- meta: Remove generate types from locale-pack (Murderlon) +- meta: enable CI on `4.x` branch (Antoine du Hamel) +- @uppy/vue: [v4.x] remove manual types (Antoine du Hamel / #4803) +- meta: prepare release workflow for beta versions (Antoine du Hamel) + + +## 3.24.0 + +Released: 2024-03-27 + +| Package | Version | Package | Version | +| ------------------------- | ------- | ------------------------- | ------- | +| @uppy/audio | 1.1.8 | @uppy/progress-bar | 3.1.1 | +| @uppy/aws-s3-multipart | 3.11.0 | @uppy/provider-views | 3.11.0 | +| @uppy/box | 2.3.0 | @uppy/react | 3.3.0 | +| @uppy/companion | 4.13.0 | @uppy/remote-sources | 1.2.0 | +| @uppy/companion-client | 3.8.0 | @uppy/screen-capture | 3.2.0 | +| @uppy/compressor | 1.1.2 | @uppy/status-bar | 3.3.1 | +| @uppy/core | 3.10.0 | @uppy/thumbnail-generator | 3.1.0 | +| @uppy/dashboard | 3.8.0 | @uppy/transloadit | 3.6.0 | +| @uppy/drag-drop | 3.1.0 | @uppy/tus | 3.5.4 | +| @uppy/drop-target | 2.0.5 | @uppy/unsplash | 3.3.0 | +| @uppy/dropbox | 3.3.0 | @uppy/url | 3.6.0 | +| @uppy/facebook | 3.3.0 | @uppy/utils | 5.7.5 | +| @uppy/golden-retriever | 3.2.0 | @uppy/webcam | 3.4.0 | +| @uppy/google-drive | 3.5.0 | @uppy/zoom | 2.3.0 | +| @uppy/instagram | 3.3.0 | uppy | 3.24.0 | +| @uppy/onedrive | 3.3.0 | | | + +- @uppy/box,@uppy/companion-client,@uppy/provider-views,@uppy/status-bar: fix type imports (Antoine du Hamel / #5038) +- @uppy/aws-s3-multipart: mark `opts` as optional (Antoine du Hamel / #5039) +- e2e: bump Cypress version (Antoine du Hamel / #5034) +- @uppy/react: refactor to TS (Antoine du Hamel / #5012) +- @uppy/core: refine type of private variables (Antoine du Hamel / #5028) +- @uppy/dashboard: refine type of private variables (Antoine du Hamel / #5027) +- @uppy/drag-drop: refine type of private variables (Antoine du Hamel / #5026) +- @uppy/status-bar: refine type of private variables (Antoine du Hamel / #5025) +- @uppy/remote-sources: migrate to TS (Merlijn Vos / #5020) +- @uppy/dashboard: refine option types (Antoine du Hamel / #5022) +- @uppy/dashboard: add new `autoOpen` option (Chris Grigg / #5001) +- @uppy/core: fix some type errors (Antoine du Hamel / #5015) +- @uppy/audio,@uppy/dashboard,@uppy/drop-target,@uppy/webcam: add missing exports (Antoine du Hamel / #5014) +- meta: Bump webpack-dev-middleware from 5.3.3 to 5.3.4 (dependabot[bot] / #5013) +- @uppy/dashboard: refactor to TypeScript (Antoine du Hamel / #4984) +- @uppy/companion: improve error msg (Mikael Finstad / #5010) +- @uppy/aws-s3-multipart: refactor to TS (Antoine du Hamel / #4902) +- @uppy/dashboard: refactor to stable lifecycle method (Antoine du Hamel / #4999) +- @uppy/companion: crash if trying to set path to / (Mikael Finstad / #5003) +- @uppy/provider-views: fix `super.toggleCheckbox` bug (Mikael Finstad / #5004) +- @uppy/aws-s3-multipart: fix escaping issue with client signed request (Hiroki Shimizu / #5006) +- @uppy/drag-drop,@uppy/progress-bar: add missing exports (Antoine du Hamel / #5009) +- @uppy/transloadit: migrate to TS (Merlijn Vos / #4987) +- @uppy/utils: fix `RateLimitedQueue#wrapPromiseFunction` types (Antoine du Hamel / #5007) +- @uppy/golden-retriever: migrate to TS (Merlijn Vos / #4989) +- meta: Bump follow-redirects from 1.15.4 to 1.15.6 (dependabot[bot] / #5002) +- meta: fix `resize-observer-polyfill` types (Antoine du Hamel / #4994) +- @uppy/core: various type fixes (Antoine du Hamel / #4995) +- @uppy/utils: fix `findAllDOMElements` type (Antoine du Hamel / #4997) +- @uppy/status-bar: fix `recoveredState` type (Antoine du Hamel / #4996) +- @uppy/utils: fix `AbortablePromise` type (Antoine du Hamel / #4988) +- @uppy/core,@uppy/provider-views: Fix breadcrumbs (Evgenia Karunus / #4986) +- @uppy/drag-drop: refactor to TypeScript (Antoine du Hamel / #4983) +- @uppy/webcam: refactor to TypeScript (Antoine du Hamel / #4870) +- @uppy/url: migrate to TS (Merlijn Vos / #4980) +- @uppy/zoom: refactor to TypeScript (Murderlon / #4979) +- @uppy/unsplash: refactor to TypeScript (Murderlon / #4979) +- @uppy/onedrive: refactor to TypeScript (Murderlon / #4979) +- @uppy/instagram: refactor to TypeScript (Murderlon / #4979) +- @uppy/google-drive: refactor to TypeScript (Murderlon / #4979) +- @uppy/facebook: refactor to TypeScript (Murderlon / #4979) +- @uppy/dropbox: refactor to TypeScript (Murderlon / #4979) +- @uppy/box: refactor to TypeScript (Murderlon / #4979) +- @uppy/utils: migrate RateLimitedQueue to TS (Merlijn Vos / #4981) +- @uppy/thumbnail-generator: migrate to TS (Merlijn Vos / #4978) +- @uppy/screen-capture: migrate to TS (Merlijn Vos / #4965) +- @uppy/companion-client: Replace Provider.initPlugin with composition (Merlijn Vos / #4977) + + ## 3.23.0 Released: 2024-02-28 diff --git a/README.md b/README.md index fa909dfa23..1367a6ece2 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ const uppy = new Uppy() npm install @uppy/core @uppy/dashboard @uppy/tus ``` -Add CSS [uppy.min.css](https://releases.transloadit.com/uppy/v3.23.0/uppy.min.css), either to your HTML page’s `` or include in JS, if your bundler of choice supports it. +Add CSS [uppy.min.css](https://releases.transloadit.com/uppy/v4.0.0-beta.1/uppy.min.css), either to your HTML page’s `` or include in JS, if your bundler of choice supports it. Alternatively, you can also use a pre-built bundle from Transloadit’s CDN: Edgly. In that case `Uppy` will attach itself to the global `window.Uppy` object. @@ -73,12 +73,12 @@ Alternatively, you can also use a pre-built bundle from Transloadit’s CDN: Edg ```html - +