From c48aa82a8a68392f5c5f09d866deebd8806fe8a6 Mon Sep 17 00:00:00 2001 From: Murderlon Date: Tue, 21 Nov 2023 15:24:06 +0100 Subject: [PATCH] @uppy/core: refactor to TS Co-authored-by: Antoine du Hamel --- .eslintrc.js | 2 +- packages/@uppy/core/src/BasePlugin.js | 85 -- packages/@uppy/core/src/BasePlugin.ts | 106 ++ packages/@uppy/core/src/EventManager.ts | 114 ++ packages/@uppy/core/src/Restricter.js | 137 -- packages/@uppy/core/src/Restricter.ts | 204 +++ .../{UIPlugin.test.js => UIPlugin.test.ts} | 10 +- .../core/src/{UIPlugin.js => UIPlugin.ts} | 90 +- .../core/src/{Uppy.test.js => Uppy.test.ts} | 786 +++++++---- packages/@uppy/core/src/{Uppy.js => Uppy.ts} | 1159 ++++++++++++----- .../core/src/__snapshots__/Uppy.test.ts.snap | 69 + .../src/{getFileName.js => getFileName.ts} | 5 +- packages/@uppy/core/src/index.js | 5 - packages/@uppy/core/src/index.ts | 5 + .../@uppy/core/src/{locale.js => locale.ts} | 3 +- packages/@uppy/core/src/loggers.js | 23 - packages/@uppy/core/src/loggers.ts | 25 + ...{acquirerPlugin1.js => acquirerPlugin1.ts} | 19 +- ...{acquirerPlugin2.js => acquirerPlugin2.ts} | 19 +- .../{invalidPlugin.js => invalidPlugin.ts} | 0 .../core/src/mocks/invalidPluginWithoutId.js | 19 - .../core/src/mocks/invalidPluginWithoutId.ts | 24 + .../src/mocks/invalidPluginWithoutType.js | 19 - .../src/mocks/invalidPluginWithoutType.ts | 24 + .../core/src/supportsUploadProgress.test.js | 29 - .../core/src/supportsUploadProgress.test.ts | 57 + ...dProgress.js => supportsUploadProgress.ts} | 8 +- packages/@uppy/core/tsconfig.build.json | 20 + packages/@uppy/core/tsconfig.json | 16 + packages/@uppy/utils/package.json | 2 + packages/@uppy/utils/src/EventManager.ts | 118 +- packages/@uppy/utils/src/FileProgress.ts | 8 +- packages/@uppy/utils/src/Translator.ts | 20 +- packages/@uppy/utils/src/UppyFile.ts | 37 +- .../@uppy/utils/src/emitSocketProgress.ts | 2 +- packages/@uppy/utils/src/fileFilters.ts | 11 +- packages/@uppy/utils/src/findDOMElement.ts | 2 +- packages/@uppy/utils/src/generateFileID.ts | 6 +- packages/@uppy/utils/src/getFileType.test.ts | 18 +- packages/@uppy/utils/src/getFileType.ts | 2 +- packages/@uppy/utils/src/getSpeed.ts | 5 +- 41 files changed, 2200 insertions(+), 1113 deletions(-) delete mode 100644 packages/@uppy/core/src/BasePlugin.js create mode 100644 packages/@uppy/core/src/BasePlugin.ts create mode 100644 packages/@uppy/core/src/EventManager.ts delete mode 100644 packages/@uppy/core/src/Restricter.js create mode 100644 packages/@uppy/core/src/Restricter.ts rename packages/@uppy/core/src/{UIPlugin.test.js => UIPlugin.test.ts} (70%) rename packages/@uppy/core/src/{UIPlugin.js => UIPlugin.ts} (58%) rename packages/@uppy/core/src/{Uppy.test.js => Uppy.test.ts} (76%) rename packages/@uppy/core/src/{Uppy.js => Uppy.ts} (54%) create mode 100644 packages/@uppy/core/src/__snapshots__/Uppy.test.ts.snap rename packages/@uppy/core/src/{getFileName.js => getFileName.ts} (65%) delete mode 100644 packages/@uppy/core/src/index.js create mode 100644 packages/@uppy/core/src/index.ts rename packages/@uppy/core/src/{locale.js => locale.ts} (96%) delete mode 100644 packages/@uppy/core/src/loggers.js create mode 100644 packages/@uppy/core/src/loggers.ts rename packages/@uppy/core/src/mocks/{acquirerPlugin1.js => acquirerPlugin1.ts} (59%) rename packages/@uppy/core/src/mocks/{acquirerPlugin2.js => acquirerPlugin2.ts} (59%) rename packages/@uppy/core/src/mocks/{invalidPlugin.js => invalidPlugin.ts} (100%) delete mode 100644 packages/@uppy/core/src/mocks/invalidPluginWithoutId.js create mode 100644 packages/@uppy/core/src/mocks/invalidPluginWithoutId.ts delete mode 100644 packages/@uppy/core/src/mocks/invalidPluginWithoutType.js create mode 100644 packages/@uppy/core/src/mocks/invalidPluginWithoutType.ts delete mode 100644 packages/@uppy/core/src/supportsUploadProgress.test.js create mode 100644 packages/@uppy/core/src/supportsUploadProgress.test.ts rename packages/@uppy/core/src/{supportsUploadProgress.js => supportsUploadProgress.ts} (81%) create mode 100644 packages/@uppy/core/tsconfig.build.json create mode 100644 packages/@uppy/core/tsconfig.json diff --git a/.eslintrc.js b/.eslintrc.js index df44d24e58..6110795d31 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -469,7 +469,7 @@ module.exports = { }, { files: ['packages/@uppy/*/src/**/*.ts', 'packages/@uppy/*/src/**/*.tsx'], - excludedFiles: ['packages/@uppy/**/*.test.ts'], + excludedFiles: ['packages/@uppy/**/*.test.ts', 'packages/@uppy/core/src/mocks/*.ts'], rules: { '@typescript-eslint/explicit-function-return-type': 'error', }, diff --git a/packages/@uppy/core/src/BasePlugin.js b/packages/@uppy/core/src/BasePlugin.js deleted file mode 100644 index 9ad2e433c1..0000000000 --- a/packages/@uppy/core/src/BasePlugin.js +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Core plugin logic that all plugins share. - * - * BasePlugin does not contain DOM rendering so it can be used for plugins - * without a user interface. - * - * See `Plugin` for the extended version with Preact rendering for interfaces. - */ - -import Translator from '@uppy/utils/lib/Translator' - -export default class BasePlugin { - constructor (uppy, opts = {}) { - this.uppy = uppy - this.opts = opts - } - - getPluginState () { - const { plugins } = this.uppy.getState() - return plugins[this.id] || {} - } - - setPluginState (update) { - const { plugins } = this.uppy.getState() - - this.uppy.setState({ - plugins: { - ...plugins, - [this.id]: { - ...plugins[this.id], - ...update, - }, - }, - }) - } - - setOptions (newOpts) { - this.opts = { ...this.opts, ...newOpts } - this.setPluginState() // so that UI re-renders with new options - this.i18nInit() - } - - i18nInit () { - const onMissingKey = (key) => this.uppy.log(`Missing i18n string: ${key}`, 'error') - const translator = new Translator([this.defaultLocale, this.uppy.locale, this.opts.locale], { onMissingKey }) - this.i18n = translator.translate.bind(translator) - this.i18nArray = translator.translateArray.bind(translator) - this.setPluginState() // so that UI re-renders and we see the updated locale - } - - /** - * Extendable methods - * ================== - * These methods are here to serve as an overview of the extendable methods as well as - * making them not conditional in use, such as `if (this.afterUpdate)`. - */ - - // eslint-disable-next-line class-methods-use-this - addTarget () { - throw new Error('Extend the addTarget method to add your plugin to another plugin\'s target') - } - - // eslint-disable-next-line class-methods-use-this - install () {} - - // eslint-disable-next-line class-methods-use-this - uninstall () {} - - /** - * Called when plugin is mounted, whether in DOM or into another plugin. - * Needed because sometimes plugins are mounted separately/after `install`, - * so this.el and this.parent might not be available in `install`. - * This is the case with @uppy/react plugins, for example. - */ - render () { - throw new Error('Extend the render method to add your plugin to a DOM element') - } - - // eslint-disable-next-line class-methods-use-this - update () {} - - // Called after every state update, after everything's mounted. Debounced. - // eslint-disable-next-line class-methods-use-this - afterUpdate () {} -} diff --git a/packages/@uppy/core/src/BasePlugin.ts b/packages/@uppy/core/src/BasePlugin.ts new file mode 100644 index 0000000000..513efba499 --- /dev/null +++ b/packages/@uppy/core/src/BasePlugin.ts @@ -0,0 +1,106 @@ +/* eslint-disable class-methods-use-this */ +/* eslint-disable @typescript-eslint/no-empty-function */ + +/** + * Core plugin logic that all plugins share. + * + * BasePlugin does not contain DOM rendering so it can be used for plugins + * without a user interface. + * + * See `Plugin` for the extended version with Preact rendering for interfaces. + */ + +import Translator from '@uppy/utils/lib/Translator' +import type { I18n, Locale } from '@uppy/utils/lib/Translator' +import type { Body, Meta } from '@uppy/utils/lib/UppyFile' +import type { Uppy } from '.' + +export type PluginOpts = { locale?: Locale; [key: string]: unknown } + +export default class BasePlugin< + Opts extends PluginOpts, + M extends Meta, + B extends Body, +> { + uppy: Uppy + + opts: Opts + + id: string + + defaultLocale: Locale + + i18n: I18n + + i18nArray: Translator['translateArray'] + + type: string + + VERSION: string + + constructor(uppy: Uppy, opts: Opts) { + this.uppy = uppy + this.opts = opts ?? {} + } + + getPluginState(): Record { + const { plugins } = this.uppy.getState() + return plugins?.[this.id] || {} + } + + setPluginState(update: unknown): void { + if (!update) return + const { plugins } = this.uppy.getState() + + this.uppy.setState({ + plugins: { + ...plugins, + [this.id]: { + ...plugins[this.id], + ...update, + }, + }, + }) + } + + setOptions(newOpts: Partial): void { + this.opts = { ...this.opts, ...newOpts } + this.setPluginState(undefined) // so that UI re-renders with new options + this.i18nInit() + } + + i18nInit(): void { + const translator = new Translator([ + this.defaultLocale, + this.uppy.locale, + this.opts.locale, + ]) + this.i18n = translator.translate.bind(translator) + this.i18nArray = translator.translateArray.bind(translator) + this.setPluginState(undefined) // so that UI re-renders and we see the updated locale + } + + /** + * Extendable methods + * ================== + * These methods are here to serve as an overview of the extendable methods as well as + * making them not conditional in use, such as `if (this.afterUpdate)`. + */ + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + addTarget(plugin: unknown): HTMLElement { + throw new Error( + "Extend the addTarget method to add your plugin to another plugin's target", + ) + } + + install(): void {} + + uninstall(): void {} + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + update(state: any): void {} + + // Called after every state update, after everything's mounted. Debounced. + afterUpdate(): void {} +} diff --git a/packages/@uppy/core/src/EventManager.ts b/packages/@uppy/core/src/EventManager.ts new file mode 100644 index 0000000000..e4a819f4e3 --- /dev/null +++ b/packages/@uppy/core/src/EventManager.ts @@ -0,0 +1,114 @@ +import type { Meta, Body, UppyFile } from '@uppy/utils/lib/UppyFile' +import type { + DeprecatedUppyEventMap, + Uppy, + UppyEventMap, + _UppyEventMap, +} from './Uppy' + +/** + * Create a wrapper around an event emitter with a `remove` method to remove + * all events that were added using the wrapped emitter. + */ +export default class EventManager { + #uppy: Uppy + + #events: Array<[keyof UppyEventMap, (...args: any[]) => void]> = [] + + constructor(uppy: Uppy) { + this.#uppy = uppy + } + + on>( + event: K, + fn: _UppyEventMap[K], + ): Uppy + + /** @deprecated */ + on>( + event: K, + fn: DeprecatedUppyEventMap[K], + ): Uppy + + on>( + event: K, + fn: UppyEventMap[K], + ): Uppy { + this.#events.push([event, fn]) + return this.#uppy.on(event as keyof _UppyEventMap, fn) + } + + remove(): void { + for (const [event, fn] of this.#events.splice(0)) { + this.#uppy.off(event, fn) + } + } + + onFilePause( + fileID: UppyFile['id'], + cb: (isPaused: boolean) => void, + ): void { + this.on('upload-pause', (targetFileID, isPaused) => { + if (fileID === targetFileID) { + cb(isPaused) + } + }) + } + + onFileRemove( + fileID: UppyFile['id'], + cb: (isPaused: UppyFile['id']) => void, + ): void { + this.on('file-removed', (file) => { + if (fileID === file.id) cb(file.id) + }) + } + + onPause(fileID: UppyFile['id'], cb: (isPaused: boolean) => void): void { + this.on('upload-pause', (targetFileID, isPaused) => { + if (fileID === targetFileID) { + // const isPaused = this.#uppy.pauseResume(fileID) + cb(isPaused) + } + }) + } + + onRetry(fileID: UppyFile['id'], cb: () => void): void { + this.on('upload-retry', (targetFileID) => { + if (fileID === targetFileID) { + cb() + } + }) + } + + onRetryAll(fileID: UppyFile['id'], cb: () => void): void { + this.on('retry-all', () => { + if (!this.#uppy.getFile(fileID)) return + cb() + }) + } + + onPauseAll(fileID: UppyFile['id'], cb: () => void): void { + this.on('pause-all', () => { + if (!this.#uppy.getFile(fileID)) return + cb() + }) + } + + onCancelAll( + fileID: UppyFile['id'], + eventHandler: UppyEventMap['cancel-all'], + ): void { + this.on('cancel-all', (...args) => { + if (!this.#uppy.getFile(fileID)) return + eventHandler(...args) + }) + } + + onResumeAll(fileID: UppyFile['id'], cb: () => void): void { + this.on('resume-all', () => { + if (!this.#uppy.getFile(fileID)) return + cb() + }) + } +} diff --git a/packages/@uppy/core/src/Restricter.js b/packages/@uppy/core/src/Restricter.js deleted file mode 100644 index 4255d47197..0000000000 --- a/packages/@uppy/core/src/Restricter.js +++ /dev/null @@ -1,137 +0,0 @@ -/* eslint-disable max-classes-per-file, class-methods-use-this */ -import prettierBytes from '@transloadit/prettier-bytes' -import match from 'mime-match' - -const defaultOptions = { - maxFileSize: null, - minFileSize: null, - maxTotalFileSize: null, - maxNumberOfFiles: null, - minNumberOfFiles: null, - allowedFileTypes: null, - requiredMetaFields: [], -} - -class RestrictionError extends Error { - constructor (message, { isUserFacing = true, file } = {}) { - super(message) - this.isUserFacing = isUserFacing - if (file != null) this.file = file // only some restriction errors are related to a particular file - } - - isRestriction = true -} - -class Restricter { - constructor (getOpts, i18n) { - this.i18n = i18n - this.getOpts = () => { - const opts = getOpts() - - if (opts.restrictions.allowedFileTypes != null - && !Array.isArray(opts.restrictions.allowedFileTypes)) { - throw new TypeError('`restrictions.allowedFileTypes` must be an array') - } - return opts - } - } - - // Because these operations are slow, we cannot run them for every file (if we are adding multiple files) - validateAggregateRestrictions (existingFiles, addingFiles) { - const { maxTotalFileSize, maxNumberOfFiles } = this.getOpts().restrictions - - if (maxNumberOfFiles) { - const nonGhostFiles = existingFiles.filter(f => !f.isGhost) - if (nonGhostFiles.length + addingFiles.length > maxNumberOfFiles) { - throw new RestrictionError(`${this.i18n('youCanOnlyUploadX', { smart_count: maxNumberOfFiles })}`) - } - } - - if (maxTotalFileSize) { - let totalFilesSize = existingFiles.reduce((total, f) => (total + f.size), 0) - - for (const addingFile of addingFiles) { - if (addingFile.size != null) { // We can't check maxTotalFileSize if the size is unknown. - totalFilesSize += addingFile.size - - if (totalFilesSize > maxTotalFileSize) { - throw new RestrictionError(this.i18n('exceedsSize', { - size: prettierBytes(maxTotalFileSize), - file: addingFile.name, - })) - } - } - } - } - } - - validateSingleFile (file) { - const { maxFileSize, minFileSize, allowedFileTypes } = this.getOpts().restrictions - - if (allowedFileTypes) { - const isCorrectFileType = allowedFileTypes.some((type) => { - // check if this is a mime-type - if (type.includes('/')) { - if (!file.type) return false - return match(file.type.replace(/;.*?$/, ''), type) - } - - // otherwise this is likely an extension - if (type[0] === '.' && file.extension) { - return file.extension.toLowerCase() === type.slice(1).toLowerCase() - } - return false - }) - - if (!isCorrectFileType) { - const allowedFileTypesString = allowedFileTypes.join(', ') - throw new RestrictionError(this.i18n('youCanOnlyUploadFileTypes', { types: allowedFileTypesString }), { file }) - } - } - - // We can't check maxFileSize if the size is unknown. - if (maxFileSize && file.size != null && file.size > maxFileSize) { - throw new RestrictionError(this.i18n('exceedsSize', { - size: prettierBytes(maxFileSize), - file: file.name, - }), { file }) - } - - // We can't check minFileSize if the size is unknown. - if (minFileSize && file.size != null && file.size < minFileSize) { - throw new RestrictionError(this.i18n('inferiorSize', { - size: prettierBytes(minFileSize), - }), { file }) - } - } - - validate (existingFiles, addingFiles) { - addingFiles.forEach((addingFile) => { - this.validateSingleFile(addingFile) - }) - this.validateAggregateRestrictions(existingFiles, addingFiles) - } - - validateMinNumberOfFiles (files) { - const { minNumberOfFiles } = this.getOpts().restrictions - if (Object.keys(files).length < minNumberOfFiles) { - throw new RestrictionError(this.i18n('youHaveToAtLeastSelectX', { smart_count: minNumberOfFiles })) - } - } - - getMissingRequiredMetaFields (file) { - const error = new RestrictionError(this.i18n('missingRequiredMetaFieldOnFile', { fileName: file.name })) - const { requiredMetaFields } = this.getOpts().restrictions - const missingFields = [] - - for (const field of requiredMetaFields) { - if (!Object.hasOwn(file.meta, field) || file.meta[field] === '') { - missingFields.push(field) - } - } - - return { missingFields, error } - } -} - -export { Restricter, defaultOptions, RestrictionError } diff --git a/packages/@uppy/core/src/Restricter.ts b/packages/@uppy/core/src/Restricter.ts new file mode 100644 index 0000000000..6fe448d0bd --- /dev/null +++ b/packages/@uppy/core/src/Restricter.ts @@ -0,0 +1,204 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +/* eslint-disable max-classes-per-file, class-methods-use-this */ +// @ts-ignore untyped +import prettierBytes from '@transloadit/prettier-bytes' +// @ts-ignore untyped +import match from 'mime-match' +import Translator from '@uppy/utils/lib/Translator' +import type { Body, Meta, UppyFile } from '@uppy/utils/lib/UppyFile' +import type { I18n } from '@uppy/utils/lib/Translator' +import type { State, NonNullableUppyOptions } from './Uppy' + +export type Restrictions = { + maxFileSize: number | null + minFileSize: number | null + maxTotalFileSize: number | null + maxNumberOfFiles: number | null + minNumberOfFiles: number | null + allowedFileTypes: string[] | null + requiredMetaFields: string[] +} + +const defaultOptions = { + maxFileSize: null, + minFileSize: null, + maxTotalFileSize: null, + maxNumberOfFiles: null, + minNumberOfFiles: null, + allowedFileTypes: null, + requiredMetaFields: [], +} + +class RestrictionError extends Error { + isUserFacing: boolean + + file: UppyFile + + constructor( + message: string, + opts?: { isUserFacing?: boolean; file?: UppyFile }, + ) { + super(message) + this.isUserFacing = opts?.isUserFacing ?? true + if (opts?.file) { + this.file = opts.file // only some restriction errors are related to a particular file + } + } + + isRestriction = true +} + +class Restricter { + i18n: Translator['translate'] + + getOpts: () => NonNullableUppyOptions + + constructor(getOpts: () => NonNullableUppyOptions, i18n: I18n) { + this.i18n = i18n + this.getOpts = (): NonNullableUppyOptions => { + const opts = getOpts() + + if ( + opts.restrictions?.allowedFileTypes != null && + !Array.isArray(opts.restrictions.allowedFileTypes) + ) { + throw new TypeError('`restrictions.allowedFileTypes` must be an array') + } + return opts + } + } + + // Because these operations are slow, we cannot run them for every file (if we are adding multiple files) + validateAggregateRestrictions( + existingFiles: UppyFile[], + addingFiles: UppyFile[], + ): void { + const { maxTotalFileSize, maxNumberOfFiles } = this.getOpts().restrictions + + if (maxNumberOfFiles) { + const nonGhostFiles = existingFiles.filter((f) => !f.isGhost) + if (nonGhostFiles.length + addingFiles.length > maxNumberOfFiles) { + throw new RestrictionError( + `${this.i18n('youCanOnlyUploadX', { + smart_count: maxNumberOfFiles, + })}`, + ) + } + } + + if (maxTotalFileSize) { + let totalFilesSize = existingFiles.reduce( + (total, f) => (total + (f.size ?? 0)) as number, + 0, + ) + + for (const addingFile of addingFiles) { + if (addingFile.size != null) { + // We can't check maxTotalFileSize if the size is unknown. + totalFilesSize += addingFile.size + + if (totalFilesSize > maxTotalFileSize) { + throw new RestrictionError( + this.i18n('exceedsSize', { + size: prettierBytes(maxTotalFileSize), + file: addingFile.name, + }), + ) + } + } + } + } + } + + validateSingleFile(file: UppyFile): void { + const { maxFileSize, minFileSize, allowedFileTypes } = + this.getOpts().restrictions + + if (allowedFileTypes) { + const isCorrectFileType = allowedFileTypes.some((type) => { + // check if this is a mime-type + if (type.includes('/')) { + if (!file.type) return false + return match(file.type.replace(/;.*?$/, ''), type) + } + + // otherwise this is likely an extension + if (type[0] === '.' && file.extension) { + return file.extension.toLowerCase() === type.slice(1).toLowerCase() + } + return false + }) + + if (!isCorrectFileType) { + const allowedFileTypesString = allowedFileTypes.join(', ') + throw new RestrictionError( + this.i18n('youCanOnlyUploadFileTypes', { + types: allowedFileTypesString, + }), + { file }, + ) + } + } + + // We can't check maxFileSize if the size is unknown. + if (maxFileSize && file.size != null && file.size > maxFileSize) { + throw new RestrictionError( + this.i18n('exceedsSize', { + size: prettierBytes(maxFileSize), + file: file.name, + }), + { file }, + ) + } + + // We can't check minFileSize if the size is unknown. + if (minFileSize && file.size != null && file.size < minFileSize) { + throw new RestrictionError( + this.i18n('inferiorSize', { + size: prettierBytes(minFileSize), + }), + { file }, + ) + } + } + + validate( + existingFiles: UppyFile[], + addingFiles: UppyFile[], + ): void { + addingFiles.forEach((addingFile) => { + this.validateSingleFile(addingFile) + }) + this.validateAggregateRestrictions(existingFiles, addingFiles) + } + + validateMinNumberOfFiles(files: State['files']): void { + const { minNumberOfFiles } = this.getOpts().restrictions + if (minNumberOfFiles && Object.keys(files).length < minNumberOfFiles) { + throw new RestrictionError( + this.i18n('youHaveToAtLeastSelectX', { smart_count: minNumberOfFiles }), + ) + } + } + + getMissingRequiredMetaFields(file: UppyFile): { + missingFields: string[] + error: RestrictionError + } { + const error = new RestrictionError( + this.i18n('missingRequiredMetaFieldOnFile', { fileName: file.name }), + ) + const { requiredMetaFields } = this.getOpts().restrictions + const missingFields: string[] = [] + + for (const field of requiredMetaFields) { + if (!Object.hasOwn(file.meta, field) || file.meta[field] === '') { + missingFields.push(field) + } + } + + return { missingFields, error } + } +} + +export { Restricter, defaultOptions, RestrictionError } diff --git a/packages/@uppy/core/src/UIPlugin.test.js b/packages/@uppy/core/src/UIPlugin.test.ts similarity index 70% rename from packages/@uppy/core/src/UIPlugin.test.js rename to packages/@uppy/core/src/UIPlugin.test.ts index a8ba16a127..1efd66ebc8 100644 --- a/packages/@uppy/core/src/UIPlugin.test.js +++ b/packages/@uppy/core/src/UIPlugin.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from 'vitest' -import UIPlugin from './UIPlugin.js' -import Core from './index.js' +import UIPlugin from './UIPlugin.ts' +import Core from './index.ts' describe('UIPlugin', () => { describe('getPluginState', () => { it('returns an empty object if no state is available', () => { - class Example extends UIPlugin {} - const inst = new Example(new Core(), {}) + class Example extends UIPlugin {} + const inst = new Example(new Core(), {}) expect(inst.getPluginState()).toEqual({}) }) @@ -14,7 +14,7 @@ describe('UIPlugin', () => { describe('setPluginState', () => { it('applies patches', () => { - class Example extends UIPlugin {} + class Example extends UIPlugin {} const inst = new Example(new Core(), {}) inst.setPluginState({ a: 1 }) diff --git a/packages/@uppy/core/src/UIPlugin.js b/packages/@uppy/core/src/UIPlugin.ts similarity index 58% rename from packages/@uppy/core/src/UIPlugin.js rename to packages/@uppy/core/src/UIPlugin.ts index b1f6ca4513..fd404d48cf 100644 --- a/packages/@uppy/core/src/UIPlugin.js +++ b/packages/@uppy/core/src/UIPlugin.ts @@ -1,18 +1,21 @@ -import { render } from 'preact' +/* eslint-disable class-methods-use-this */ +/* eslint-disable @typescript-eslint/no-empty-function */ +import { render, type ComponentChild } from 'preact' import findDOMElement from '@uppy/utils/lib/findDOMElement' import getTextDirection from '@uppy/utils/lib/getTextDirection' -import BasePlugin from './BasePlugin.js' +import type { Body, Meta } from '@uppy/utils/lib/UppyFile' +import BasePlugin from './BasePlugin.ts' +import type { PluginOpts } from './BasePlugin.ts' /** * Defer a frequent call to the microtask queue. - * - * @param {() => T} fn - * @returns {Promise} */ -function debounce (fn) { - let calling = null - let latestArgs = null +function debounce any>( + fn: T, +): (...args: Parameters) => Promise> { + let calling: Promise> | null = null + let latestArgs: Parameters return (...args) => { latestArgs = args if (!calling) { @@ -35,10 +38,20 @@ function debounce (fn) { * * For plugins without an user interface, see BasePlugin. */ -class UIPlugin extends BasePlugin { - #updateUI +class UIPlugin< + Opts extends PluginOpts & { direction?: 'ltr' | 'rtl' }, + M extends Meta, + B extends Body, +> extends BasePlugin { + #updateUI: (state: any) => void + + isTargetDOMEl: boolean + + el: HTMLElement | null + + parent: unknown - getTargetPlugin (target) { + getTargetPlugin(target: unknown): UIPlugin | undefined { let targetPlugin if (typeof target === 'object' && target instanceof UIPlugin) { // Targeting a plugin *instance* @@ -47,7 +60,7 @@ class UIPlugin extends BasePlugin { // Targeting a plugin type const Target = target // Find the target plugin instance. - this.uppy.iteratePlugins(p => { + this.uppy.iteratePlugins((p) => { if (p instanceof Target) { targetPlugin = p } @@ -62,7 +75,10 @@ class UIPlugin extends BasePlugin { * If it’s an object — target is a plugin, and we search `plugins` * for a plugin with same name and return its target. */ - mount (target, plugin) { + mount( + target: HTMLElement | string, + plugin: UIPlugin, + ): HTMLElement { const callerPluginName = plugin.id const targetElement = findDOMElement(target) @@ -85,7 +101,9 @@ class UIPlugin extends BasePlugin { this.afterUpdate() }) - this.uppy.log(`Installing ${callerPluginName} to a DOM element '${target}'`) + this.uppy.log( + `Installing ${callerPluginName} to a DOM element '${target}'`, + ) if (this.opts.replaceTargetContent) { // Doing render(h(null), targetElement), which should have been @@ -99,7 +117,8 @@ class UIPlugin extends BasePlugin { targetElement.appendChild(uppyRootElement) // Set the text direction if the page has not defined one. - uppyRootElement.dir = this.opts.direction || getTextDirection(uppyRootElement) || 'ltr' + uppyRootElement.dir = + this.opts.direction || getTextDirection(uppyRootElement) || 'ltr' this.onMount() @@ -121,37 +140,50 @@ class UIPlugin extends BasePlugin { let message = `Invalid target option given to ${callerPluginName}.` if (typeof target === 'function') { - message += ' The given target is not a Plugin class. ' - + 'Please check that you\'re not specifying a React Component instead of a plugin. ' - + 'If you are using @uppy/* packages directly, make sure you have only 1 version of @uppy/core installed: ' - + 'run `npm ls @uppy/core` on the command line and verify that all the versions match and are deduped correctly.' + message += + ' The given target is not a Plugin class. ' + + "Please check that you're not specifying a React Component instead of a plugin. " + + 'If you are using @uppy/* packages directly, make sure you have only 1 version of @uppy/core installed: ' + + 'run `npm ls @uppy/core` on the command line and verify that all the versions match and are deduped correctly.' } else { - message += 'If you meant to target an HTML element, please make sure that the element exists. ' - + 'Check that the