diff --git a/packages/@uppy/dashboard/src/Dashboard.tsx b/packages/@uppy/dashboard/src/Dashboard.tsx index 108b1ae6c5..f076e108aa 100644 --- a/packages/@uppy/dashboard/src/Dashboard.tsx +++ b/packages/@uppy/dashboard/src/Dashboard.tsx @@ -7,7 +7,6 @@ import { type State, } from '@uppy/core' import type { ComponentChild, VNode } from 'preact' -import type { DefinePluginOpts } from '@uppy/core/lib/BasePlugin.js' import type { Body, Meta, UppyFile } from '@uppy/utils/lib/UppyFile' import StatusBar from '@uppy/status-bar' import Informer from '@uppy/informer' @@ -96,8 +95,8 @@ interface Target { type: string } -interface TargetWithRender extends Target { - icon: ComponentChild +export interface TargetWithRender extends Target { + icon: () => ComponentChild render: () => ComponentChild } @@ -109,77 +108,92 @@ export interface DashboardState { fileCardFor: string | null showFileEditor: boolean metaFields?: MetaField[] | ((file: UppyFile) => MetaField[]) + isHidden: boolean + isClosing: boolean + containerWidth: number + containerHeight: number + areInsidesReadyToBeVisible: boolean + isDraggingOver: boolean [key: string]: unknown } -export interface DashboardModalOptions { - inline?: false - animateOpenClose?: boolean - browserBackButtonClose?: boolean - closeAfterFinish?: boolean - closeModalOnClickOutside?: boolean - disablePageScrollWhenModalOpen?: boolean +interface DashboardMiscOptions + extends UIPluginOptions { + autoOpen: 'metaEditor' | 'imageEditor' | null + defaultPickerIcon: typeof defaultPickerIcon + disabled: boolean + disableInformer: boolean + disableLocalFiles: boolean + disableStatusBar: boolean + disableThumbnailGenerator: boolean + fileManagerSelectionType: 'files' | 'folders' | 'both' + hideCancelButton: boolean + hidePauseResumeButton: boolean + hideProgressAfterFinish: boolean + hideRetryButton: boolean + hideUploadButton: boolean + metaFields: MetaField[] | ((file: UppyFile) => MetaField[]) + nativeCameraFacingMode: 'user' | 'environment' | '' + note: string | null + onDragLeave: (event: DragEvent) => void + onDragOver: (event: DragEvent) => void + onDrop: (event: DragEvent) => void + plugins: string[] + proudlyDisplayPoweredByUppy: boolean + showLinkToFileUploadResult: boolean + showNativePhotoCameraButton: boolean + showNativeVideoCameraButton: boolean + showProgressDetails: boolean + showRemoveButtonAfterComplete: boolean + showSelectedFiles: boolean + singleFileFullScreen: boolean + theme: 'auto' | 'dark' | 'light' + thumbnailHeight: number | undefined + thumbnailType: string + thumbnailWidth: number + waitForThumbnailsBeforeUpload: boolean + + // Dynamic default options + /** + * `null` means "do not display a Done button", + * `undefined` means "I want the default behavior". + */ + doneButtonHandler: null | (() => void) + onRequestCloseModal: () => void } -export interface DashboardInlineOptions { - inline: true - - height?: string | number - width?: string | number +interface DashboardModalOptions + extends DashboardMiscOptions { + inline: false + animateOpenClose: boolean + browserBackButtonClose: boolean + closeAfterFinish: boolean + closeModalOnClickOutside: boolean + disablePageScrollWhenModalOpen: boolean + trigger: string | Element | null } -interface DashboardMiscOptions - extends UIPluginOptions { - autoOpen?: 'metaEditor' | 'imageEditor' | null - defaultPickerIcon?: typeof defaultPickerIcon - disabled?: boolean - disableInformer?: boolean - disableLocalFiles?: boolean - disableStatusBar?: boolean - disableThumbnailGenerator?: boolean - doneButtonHandler?: null | (() => void) - fileManagerSelectionType?: 'files' | 'folders' | 'both' - hideCancelButton?: boolean - hidePauseResumeButton?: boolean - hideProgressAfterFinish?: boolean - hideRetryButton?: boolean - hideUploadButton?: 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[] - proudlyDisplayPoweredByUppy?: boolean - showLinkToFileUploadResult?: 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 | Element - waitForThumbnailsBeforeUpload?: boolean +interface DashboardInlineOptions + extends DashboardMiscOptions { + inline: true + height: string | number + width: string | number } -export type DashboardOptions< - M extends Meta, - B extends Body, -> = DashboardMiscOptions & - (DashboardModalOptions | DashboardInlineOptions) +type Options = + | DashboardModalOptions + | DashboardInlineOptions -const defaultOptions = { +export type DashboardOptions = Partial< + Options +> + +const defaultOptions = (): DashboardMiscOptions< + M, + B +> => ({ target: 'body', metaFields: [], - inline: false as boolean, - width: 750, - height: 550, thumbnailWidth: 280, thumbnailType: 'image/jpeg', waitForThumbnailsBeforeUpload: false, @@ -192,44 +206,64 @@ const defaultOptions = { 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', autoOpen: null, disabled: false, disableLocalFiles: false, + nativeCameraFacingMode: '', // 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: undefined as any, - onRequestCloseModal: null as any, -} satisfies Partial> + doneButtonHandler: () => {}, + onRequestCloseModal: () => {}, + + onDragLeave: () => {}, + onDragOver: () => {}, + onDrop: () => {}, + thumbnailHeight: undefined, + plugins: [], +}) + +const defaultModalOptions = < + M extends Meta, + B extends Body, +>(): DashboardModalOptions => ({ + ...defaultOptions(), + inline: false, + animateOpenClose: true, + browserBackButtonClose: false, + closeAfterFinish: false, + closeModalOnClickOutside: false, + disablePageScrollWhenModalOpen: true, + trigger: null, +}) + +const defaultInlineOptions = < + M extends Meta, + B extends Body, +>(): DashboardInlineOptions => ({ + ...defaultOptions(), + inline: true, + width: 750, + height: 550, +}) /** * Dashboard UI with previews, metadata editing, tabs for various services and more */ export default class Dashboard extends UIPlugin< - 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 - >, + Options, M, B, DashboardState @@ -259,25 +293,30 @@ export default class Dashboard extends UIPlugin< typeof setTimeout > - constructor(uppy: Uppy, opts?: DashboardOptions) { - const autoOpen = opts?.autoOpen ?? null - super(uppy, { ...defaultOptions, ...opts, autoOpen }) + constructor(uppy: Uppy, passedOpts: DashboardOptions = {}) { + const options: Options = + passedOpts.inline === false || passedOpts.inline === undefined ? + { ...defaultModalOptions(), ...passedOpts, inline: false } + : { ...defaultInlineOptions(), ...passedOpts, inline: true } + + super(uppy, options) this.id = this.opts.id || 'Dashboard' this.title = 'Dashboard' this.type = 'orchestrator' - this.defaultLocale = locale // Dynamic default options: - if (this.opts.doneButtonHandler === undefined) { - // `null` means "do not display a Done button", while `undefined` means - // "I want the default behavior". For this reason, we need to differentiate `null` and `undefined`. + if (passedOpts.doneButtonHandler === undefined) { this.opts.doneButtonHandler = () => { this.uppy.clear() this.requestCloseModal() } } - this.opts.onRequestCloseModal ??= () => this.closeModal() + if (passedOpts.onRequestCloseModal === undefined) { + this.opts.onRequestCloseModal = () => { + this.closeModal() + } + } this.i18nInit() } @@ -422,6 +461,8 @@ export default class Dashboard extends UIPlugin< } openModal = (): Promise => { + if (this.opts.inline) return Promise.resolve() + const { promise, resolve } = createPromise() // save scroll position this.savedScrollPosition = window.pageYOffset @@ -461,6 +502,8 @@ export default class Dashboard extends UIPlugin< } closeModal = (opts?: { manualClose: boolean }): void | Promise => { + if (this.opts.inline) return Promise.resolve() + // Whether the modal is being closed by the user (`true`) or by other means (e.g. browser back button) const manualClose = opts?.manualClose ?? true @@ -746,6 +789,7 @@ export default class Dashboard extends UIPlugin< } private handleClickOutside = () => { + if (this.opts.inline) return if (this.opts.closeModalOnClickOutside) this.requestCloseModal() } @@ -766,7 +810,7 @@ export default class Dashboard extends UIPlugin< } } - private handleInputChange = (event: InputEvent) => { + private handleInputChange = (event: Event) => { event.preventDefault() const files = toArray((event.target as HTMLInputElement).files!) if (files.length > 0) { @@ -821,7 +865,7 @@ export default class Dashboard extends UIPlugin< this.setPluginState({ isDraggingOver: true }) - this.opts.onDragOver?.(event) + this.opts.onDragOver(event) } private handleDragLeave = (event: DragEvent) => { @@ -830,7 +874,7 @@ export default class Dashboard extends UIPlugin< this.setPluginState({ isDraggingOver: false }) - this.opts.onDragLeave?.(event) + this.opts.onDragLeave(event) } private handleDrop = async (event: DragEvent) => { @@ -869,7 +913,7 @@ export default class Dashboard extends UIPlugin< this.addFiles(files) } - this.opts.onDrop?.(event) + this.opts.onDrop(event) } private handleRequestThumbnail = (file: UppyFile) => { @@ -915,6 +959,7 @@ export default class Dashboard extends UIPlugin< } private handleComplete = ({ failed }: UploadResult) => { + if (this.opts.inline) return if (this.opts.closeAfterFinish && !failed?.length) { // All uploads are done this.requestCloseModal() @@ -963,10 +1008,10 @@ export default class Dashboard extends UIPlugin< initEvents = (): void => { // Modal open button - if (this.opts.trigger && !this.opts.inline) { - const showModalTrigger = findAllDOMElements(this.opts.trigger) - if (showModalTrigger) { - showModalTrigger.forEach((trigger) => + if (!this.opts.inline && this.opts.trigger) { + const triggerEls = findAllDOMElements(this.opts.trigger) + if (triggerEls) { + triggerEls.forEach((trigger) => trigger.addEventListener('click', this.openModal), ) } else { @@ -1004,11 +1049,13 @@ export default class Dashboard extends UIPlugin< } removeEvents = (): void => { - const showModalTrigger = findAllDOMElements(this.opts.trigger) - if (!this.opts.inline && showModalTrigger) { - showModalTrigger.forEach((trigger) => - trigger.removeEventListener('click', this.openModal), - ) + if (!this.opts.inline && this.opts.trigger) { + const triggerEls = findAllDOMElements(this.opts.trigger) + if (triggerEls) { + triggerEls.forEach((trigger) => + trigger.removeEventListener('click', this.openModal), + ) + } } this.stopListeningToResize() @@ -1195,7 +1242,7 @@ export default class Dashboard extends UIPlugin< saveFileEditor: this.saveFileEditor, closeFileEditor: this.closeFileEditor, disableInteractiveElements: this.disableInteractiveElements, - animateOpenClose: this.opts.animateOpenClose, + animateOpenClose: !this.opts.inline && this.opts.animateOpenClose, isClosing: pluginState.isClosing, progressindicators, editors, @@ -1224,8 +1271,9 @@ export default class Dashboard extends UIPlugin< saveFileCard: this.saveFileCard, openFileEditor: this.openFileEditor, canEditFile: this.canEditFile, - width: this.opts.width, - height: this.opts.height, + // TODO is this sensible + width: (this.opts.inline && this.opts.width) || 0, + height: (this.opts.inline && this.opts.height) || 0, showLinkToFileUploadResult: this.opts.showLinkToFileUploadResult, fileManagerSelectionType: this.opts.fileManagerSelectionType, proudlyDisplayPoweredByUppy: this.opts.proudlyDisplayPoweredByUppy, @@ -1257,7 +1305,7 @@ export default class Dashboard extends UIPlugin< } #addSpecifiedPluginsFromOptions = () => { - const plugins = this.opts.plugins || [] + const { plugins } = this.opts plugins.forEach((pluginID) => { const plugin = this.uppy.getPlugin(pluginID) @@ -1337,7 +1385,7 @@ export default class Dashboard extends UIPlugin< } } - setOptions(opts: Partial>) { + setOptions(opts: DashboardOptions) { super.setOptions(opts) this.uppy .getPlugin(this.#getStatusBarId()) @@ -1376,17 +1424,11 @@ export default class Dashboard extends UIPlugin< isDraggingOver: false, }) - 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.', - ) - } - const { allowMultipleUploads, allowMultipleUploadBatches } = this.uppy.opts if ( (allowMultipleUploads || allowMultipleUploadBatches) && - closeAfterFinish + !this.opts.inline && + this.opts.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', @@ -1463,7 +1505,7 @@ export default class Dashboard extends UIPlugin< if (thumbnail) this.uppy.removePlugin(thumbnail) } - const plugins = this.opts.plugins || [] + const { plugins } = this.opts plugins.forEach((pluginID) => { const plugin = this.uppy.getPlugin(pluginID) if (plugin) (plugin as any).unmount() @@ -1473,7 +1515,7 @@ export default class Dashboard extends UIPlugin< this.darkModeMediaQuery?.removeListener(this.handleSystemDarkModeChange) } - if (this.opts.disablePageScrollWhenModalOpen) { + if (!this.opts.inline && this.opts.disablePageScrollWhenModalOpen) { document.body.classList.remove('uppy-Dashboard-isFixed') } diff --git a/packages/@uppy/dashboard/src/components/AddFiles.tsx b/packages/@uppy/dashboard/src/components/AddFiles.tsx index da2d93d5e9..13e57e582c 100644 --- a/packages/@uppy/dashboard/src/components/AddFiles.tsx +++ b/packages/@uppy/dashboard/src/components/AddFiles.tsx @@ -1,37 +1,53 @@ -// 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' +import type { I18n } from '@uppy/utils/lib/Translator' +import type Translator from '@uppy/utils/lib/Translator' +import type { DashboardState, TargetWithRender } from '../Dashboard.js' + +interface AddFilesProps { + i18n: I18n + i18nArray: Translator['translateArray'] + acquirers: TargetWithRender[] + handleInputChange: (event: Event) => void + maxNumberOfFiles: number | null + allowedFileTypes: string[] | null + showNativePhotoCameraButton: boolean + showNativeVideoCameraButton: boolean + nativeCameraFacingMode: 'user' | 'environment' | '' + showPanel: (id: string) => void + activePickerPanel: DashboardState['activePickerPanel'] + disableLocalFiles: boolean + fileManagerSelectionType: string + note: string | null + proudlyDisplayPoweredByUppy: boolean +} -type $TSFixMe = any - -class AddFiles extends Component { - fileInput: $TSFixMe +class AddFiles extends Component { + fileInput: HTMLInputElement | null = null - folderInput: $TSFixMe + folderInput: HTMLInputElement | null = null - mobilePhotoFileInput: $TSFixMe + mobilePhotoFileInput: HTMLInputElement | null = null - mobileVideoFileInput: $TSFixMe + mobileVideoFileInput: HTMLInputElement | null = null private triggerFileInputClick = () => { - this.fileInput.click() + this.fileInput?.click() } private triggerFolderInputClick = () => { - this.folderInput.click() + this.folderInput?.click() } private triggerVideoCameraInputClick = () => { - this.mobileVideoFileInput.click() + this.mobileVideoFileInput?.click() } private triggerPhotoCameraInputClick = () => { - this.mobilePhotoFileInput.click() + this.mobilePhotoFileInput?.click() } - private onFileInputChange = (event: $TSFixMe) => { + private onFileInputChange = (event: Event) => { this.props.handleInputChange(event) // We clear the input after a file is selected, because otherwise @@ -40,31 +56,35 @@ class AddFiles extends Component { // ___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 + ;(event.target as HTMLInputElement).value = '' // eslint-disable-line no-param-reassign } - private renderHiddenInput = (isFolder: $TSFixMe, refCallback: $TSFixMe) => { + private renderHiddenInput = ( + isFolder: boolean, + refCallback: (ref: HTMLInputElement | null) => void, + ) => { return ( ) } private renderHiddenCameraInput = ( - type: $TSFixMe, - nativeCameraFacingMode: $TSFixMe, - refCallback: $TSFixMe, + type: 'photo' | 'video', + nativeCameraFacingMode: 'user' | 'environment' | '', + refCallback: (ref: HTMLInputElement | null) => void, ) => { const typeToAccept = { photo: 'image/*', video: 'video/*' } const accept = typeToAccept[type] @@ -194,7 +214,10 @@ class AddFiles extends Component { ) } - private renderBrowseButton = (text: $TSFixMe, onClickFn: $TSFixMe) => { + private renderBrowseButton = ( + text: string, + onClickFn: (event: Event) => void, + ) => { const numberOfAcquirers = this.props.acquirers.length return ( @@ -121,7 +121,7 @@ type FileInfoProps = { containerWidth: number containerHeight: number i18n: I18n - toggleAddFilesPanel: () => void + toggleAddFilesPanel: (show: boolean) => void toggleFileCard: (show: boolean, fileId: string) => void metaFields: DashboardState['metaFields'] isSingleFile: boolean diff --git a/packages/@uppy/dashboard/src/components/FileItem/index.tsx b/packages/@uppy/dashboard/src/components/FileItem/index.tsx index 371b79cf46..9065f0fce8 100644 --- a/packages/@uppy/dashboard/src/components/FileItem/index.tsx +++ b/packages/@uppy/dashboard/src/components/FileItem/index.tsx @@ -28,7 +28,7 @@ type Props = { id: string containerWidth: number containerHeight: number - toggleAddFilesPanel: () => void + toggleAddFilesPanel: (show: boolean) => void isSingleFile: boolean hideRetryButton: boolean hideCancelButton: boolean diff --git a/packages/@uppy/dashboard/src/components/FileList.tsx b/packages/@uppy/dashboard/src/components/FileList.tsx index 4e837401a2..d848e6378e 100644 --- a/packages/@uppy/dashboard/src/components/FileList.tsx +++ b/packages/@uppy/dashboard/src/components/FileList.tsx @@ -30,7 +30,7 @@ type FileListProps = { itemsPerRow: number openFileEditor: (file: UppyFile) => void canEditFile: (file: UppyFile) => boolean - toggleAddFilesPanel: () => void + toggleAddFilesPanel: (show: boolean) => void containerWidth: number containerHeight: number } diff --git a/packages/@uppy/utils/src/Translator.ts b/packages/@uppy/utils/src/Translator.ts index 2df46d45d0..5921c22b87 100644 --- a/packages/@uppy/utils/src/Translator.ts +++ b/packages/@uppy/utils/src/Translator.ts @@ -1,3 +1,5 @@ +import type { h } from 'preact' + // We're using a generic because languages have different plural rules. export interface Locale { strings: Record> @@ -14,7 +16,7 @@ export type I18n = Translator['translate'] type Options = { smart_count?: number } & { - [key: string]: string | number + [key: string]: string | number | h.JSX.Element } function insertReplacement(