Merge branch 'main' into ai-image-generator

This commit is contained in:
Prakash 2025-11-27 00:35:51 +05:30 committed by GitHub
commit 7be8eaaa28
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
65 changed files with 1647 additions and 200 deletions

View file

@ -0,0 +1,5 @@
---
"@uppy/locales": patch
---
Improve zh-CN and zh-TW locale

View file

@ -0,0 +1,5 @@
---
"@uppy/locales": patch
---
Improve Dutch locale

View file

@ -0,0 +1,36 @@
---
"@uppy/google-photos-picker": minor
"@uppy/google-drive-picker": minor
"@uppy/thumbnail-generator": minor
"@uppy/golden-retriever": minor
"@uppy/provider-views": minor
"@uppy/remote-sources": minor
"@uppy/screen-capture": minor
"@uppy/google-drive": minor
"@uppy/image-editor": minor
"@uppy/drop-target": minor
"@uppy/transloadit": minor
"@uppy/compressor": minor
"@uppy/status-bar": minor
"@uppy/xhr-upload": minor
"@uppy/dashboard": minor
"@uppy/drag-drop": minor
"@uppy/instagram": minor
"@uppy/facebook": minor
"@uppy/onedrive": minor
"@uppy/unsplash": minor
"@uppy/dropbox": minor
"@uppy/aws-s3": minor
"@uppy/webcam": minor
"@uppy/webdav": minor
"@uppy/audio": minor
"@uppy/core": minor
"@uppy/form": minor
"@uppy/zoom": minor
"@uppy/box": minor
"@uppy/tus": minor
"@uppy/url": minor
---
- Add PluginTypeRegistry and typed getPlugin overload in @uppy/core
- Register plugin ids across packages so uppy.getPlugin('Dashboard' | 'Webcam') returns the concrete plugin type and removes the need to pass generics in getPlugin()

View file

@ -0,0 +1,9 @@
---
"@uppy/aws-s3": patch
"@uppy/core": patch
"@uppy/tus": patch
"@uppy/utils": patch
"@uppy/xhr-upload": patch
---
Fix: Move completed uploads exclusion logic into uploaders. This fixes the problem where postprocessors would not run for already uploaded files.

View file

@ -24,7 +24,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run:

View file

@ -28,7 +28,7 @@ jobs:
node-version: [lts/*]
steps:
- name: Checkout sources
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run:
@ -65,7 +65,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run:
@ -94,7 +94,7 @@ jobs:
SKIP_YARN_COREPACK_CHECK: true
steps:
- name: Checkout sources
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Install Node.js
uses: actions/setup-node@v6

View file

@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Set SHA commit in version
run:
(cd packages/@uppy/companion && node -e 'const
@ -46,16 +46,16 @@ jobs:
COMPOSE_DOCKER_CLI_BUILD: 0
steps:
- name: Checkout sources
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Docker meta
id: docker_meta
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
with:
images: transloadit/companion
tags: |
type=edge
type=raw,value=latest,enable=false
- uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
- uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- uses: docker/setup-buildx-action@v3
- name: Log in to DockerHub
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
@ -77,7 +77,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Alter dockerfile
run: |
sed -i 's/^EXPOSE 3020$/EXPOSE $PORT/g' Dockerfile

View file

@ -36,7 +36,7 @@ jobs:
diff: ${{ steps.diff.outputs.OUTPUT_DIFF }}
steps:
- name: Checkout sources
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
fetch-depth: 2
ref: ${{ github.event.pull_request && format('refs/pull/{0}/merge', github.event.pull_request.number) || github.sha }}

View file

@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run:

View file

@ -25,7 +25,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run:

View file

@ -16,7 +16,7 @@ jobs:
published: ${{ steps.changesets.outputs.published }}
steps:
- name: Checkout Repo
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
fetch-depth: 2
@ -90,10 +90,10 @@ jobs:
COMPOSE_DOCKER_CLI_BUILD: 0
steps:
- name: Checkout sources
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Docker meta
id: docker_meta
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
with:
images: transloadit/companion
tags: |
@ -101,7 +101,7 @@ jobs:
type=semver,pattern={{version}},value=${{ needs.release.outputs.companionWasReleased }}
# set latest tag for default branch
type=raw,value=latest,enable=true
- uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
- uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- uses: docker/setup-buildx-action@v3
- name: Log in to DockerHub
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0

View file

@ -15,6 +15,12 @@ import PermissionsScreen from './PermissionsScreen.js'
import RecordingScreen from './RecordingScreen.js'
import supportsMediaRecorder from './supportsMediaRecorder.js'
declare module '@uppy/core' {
export interface PluginTypeRegistry<M extends Meta, B extends Body> {
Audio: Audio<M, B>
}
}
export interface AudioOptions extends UIPluginOptions {
showAudioSourceDropdown?: boolean
locale?: LocaleStrings<typeof locale>

View file

@ -9,7 +9,7 @@ import {
} from 'vitest'
import 'whatwg-fetch'
import Core, { type UppyFile } from '@uppy/core'
import Core, { type Meta, type UppyFile } from '@uppy/core'
import nock from 'nock'
import AwsS3Multipart, {
type AwsBody,
@ -36,8 +36,8 @@ describe('AwsS3Multipart', () => {
let opts: Partial<AwsS3MultipartOptions<any, any>>
beforeEach(() => {
const core = new Core<any, AwsBody>().use(AwsS3Multipart)
const awsS3Multipart = core.getPlugin('AwsS3Multipart') as any
const core = new Core<Meta, AwsBody>().use(AwsS3Multipart)
const awsS3Multipart = core.getPlugin('AwsS3Multipart')!
opts = awsS3Multipart.opts
})
@ -125,8 +125,8 @@ describe('AwsS3Multipart', () => {
const awsS3Multipart = core.getPlugin('AwsS3Multipart')!
const err = 'Expected a `endpoint` option'
const file = {}
const opts = {}
const file = {} as unknown as UppyFile<Meta, Record<string, never>>
const opts = {} as any
expect(() => awsS3Multipart.opts.createMultipartUpload(file)).toThrow(err)
expect(() => awsS3Multipart.opts.listParts(file, opts)).toThrow(err)
@ -202,11 +202,11 @@ describe('AwsS3Multipart', () => {
})
describe('without companionUrl (custom main functions)', () => {
let core: Core<any, AwsBody>
let awsS3Multipart: AwsS3Multipart<any, AwsBody>
let core: Core<Meta, AwsBody>
let awsS3Multipart: AwsS3Multipart<Meta, AwsBody>
beforeEach(() => {
core = new Core<any, AwsBody>()
core = new Core<Meta, AwsBody>()
core.use(AwsS3Multipart, {
shouldUseMultipart: true,
limit: 0,
@ -226,7 +226,7 @@ describe('AwsS3Multipart', () => {
}),
listParts: undefined as any,
})
awsS3Multipart = core.getPlugin('AwsS3Multipart') as any
awsS3Multipart = core.getPlugin('AwsS3Multipart')!
})
it('Keeps chunks marked as busy through retries until they complete', async () => {
@ -366,7 +366,6 @@ describe('AwsS3Multipart', () => {
),
listParts: undefined as any,
})
const awsS3Multipart = core.getPlugin('AwsS3Multipart')!
const fileSize = 5 * MB + 1 * MB
core.addFile({
@ -380,7 +379,7 @@ describe('AwsS3Multipart', () => {
await core.upload()
expect(awsS3Multipart.opts.uploadPartBytes.mock.calls.length).toEqual(3)
expect(uploadPartBytes.mock.calls.length).toEqual(3)
})
it('calls `upload-error` when uploadPartBytes fails after all retries', async () => {
@ -406,7 +405,6 @@ describe('AwsS3Multipart', () => {
listParts: undefined as any,
})
const fileSize = 5 * MB + 1 * MB
const awsS3Multipart = core.getPlugin('AwsS3Multipart')!
const uploadErrorMock = vi.fn()
const uploadSuccessMock = vi.fn()
core.on('upload-error', uploadErrorMock)
@ -438,7 +436,7 @@ describe('AwsS3Multipart', () => {
// Catch Promise.all reject
}
expect(awsS3Multipart.opts.uploadPartBytes.mock.calls.length).toEqual(5)
expect(uploadPartBytes.mock.calls.length).toEqual(5)
expect(uploadErrorMock.mock.calls.length).toEqual(1)
expect(uploadSuccessMock.mock.calls.length).toEqual(1) // This fails for me becuase upload returned early.
})
@ -526,8 +524,8 @@ describe('AwsS3Multipart', () => {
})
describe('dynamic companionHeader using setOption', () => {
let core: Core<any, AwsBody>
let awsS3Multipart: AwsS3Multipart<any, AwsBody>
let core: Core<Meta, AwsBody>
let awsS3Multipart: AwsS3Multipart<Meta, AwsBody>
const newToken = 'new token'
it('companionHeader is updated before uploading file', async () => {
@ -536,7 +534,7 @@ describe('AwsS3Multipart', () => {
/* Set up preprocessor */
core.addPreProcessor(() => {
awsS3Multipart =
core.getPlugin<AwsS3Multipart<any, AwsBody>>('AwsS3Multipart')!
core.getPlugin<AwsS3Multipart<Meta, AwsBody>>('AwsS3Multipart')!
awsS3Multipart.setOptions({
endpoint: 'http://localhost',
headers: {

View file

@ -16,7 +16,7 @@ import type {
import {
createAbortError,
filterFilesToEmitUploadStarted,
filterNonFailedFiles,
filterFilesToUpload,
getAllowedMetaFields,
RateLimitedQueue,
} from '@uppy/utils'
@ -45,6 +45,9 @@ declare module '@uppy/core' {
export interface UppyEventMap<M extends Meta, B extends Body> {
's3-multipart:part-uploaded': PartUploadedCallback<M, B>
}
export interface PluginTypeRegistry<M extends Meta, B extends Body> {
AwsS3Multipart: AwsS3Multipart<M, B>
}
}
function assertServerError<T>(res: T): T {
@ -935,7 +938,7 @@ export default class AwsS3Multipart<
if (fileIDs.length === 0) return undefined
const files = this.uppy.getFilesByIds(fileIDs)
const filesFiltered = filterNonFailedFiles(files)
const filesFiltered = filterFilesToUpload(files)
const filesToEmit = filterFilesToEmitUploadStarted(filesFiltered)
this.uppy.emit('upload-start', filesToEmit)

View file

@ -109,3 +109,9 @@ export default class Box<M extends Meta, B extends Body>
return this.view.render(state)
}
}
declare module '@uppy/core' {
export interface PluginTypeRegistry<M extends Meta, B extends Body> {
Box: Box<M, B>
}
}

View file

@ -11,6 +11,9 @@ declare module '@uppy/core' {
export interface UppyEventMap<M extends Meta, B extends Body> {
'compressor:complete': (file: UppyFile<M, B>[]) => void
}
export interface PluginTypeRegistry<M extends Meta, B extends Body> {
Compressor: Compressor<M, B>
}
}
export interface CompressorOpts extends PluginOpts, CompressorJS.Options {

View file

@ -2609,7 +2609,7 @@ describe('src/Core', () => {
expect(infoVisibleEvent.mock.calls.length).toEqual(1)
})
it('should set an info message to be displayed for a period of time before hiding', (done) => {
it('should set an info message to be displayed for a period of time before hiding', async () => {
const infoVisibleEvent = vi.fn()
const infoHiddenEvent = vi.fn()
const core = new Core()
@ -2618,12 +2618,10 @@ describe('src/Core', () => {
core.info('This is the message', 'info', 100)
expect(infoHiddenEvent.mock.calls.length).toEqual(0)
setTimeout(() => {
expect(infoHiddenEvent.mock.calls.length).toEqual(1)
expect(core.getState().info).toEqual([])
// @ts-ignore
done()
}, 110)
await new Promise((resolve) => setTimeout(resolve, 110))
expect(infoHiddenEvent.mock.calls.length).toEqual(1)
expect(core.getState().info).toEqual([])
})
it('should hide an info message', () => {

View file

@ -142,6 +142,10 @@ export type UnknownProviderPluginState = {
searchResults?: string[] | undefined
}
// biome-ignore lint/suspicious/noEmptyInterface: PluginTypeRegistry is extended via module augmentation
// biome-ignore lint/correctness/noUnusedVariables: Type parameters are used in module augmentation
export interface PluginTypeRegistry<M extends Meta, B extends Body> {}
export interface AsyncStore {
getItem: (key: string) => Promise<string | null>
setItem: (key: string, value: string) => Promise<void>
@ -1941,12 +1945,21 @@ export class Uppy<
/**
* Find one Plugin by name.
*/
getPlugin<K extends keyof PluginTypeRegistry<M, B>>(
id: K,
): PluginTypeRegistry<M, B>[K] | undefined
getPlugin<T extends UnknownPlugin<M, B> = UnknownPlugin<M, B>>(
id: string,
): T | undefined {
): T | undefined
getPlugin(id: string): UnknownPlugin<M, B> | undefined {
for (const plugins of Object.values(this.#plugins)) {
const foundPlugin = plugins.find((plugin) => plugin.id === id)
if (foundPlugin != null) return foundPlugin as T
if (foundPlugin != null) {
return foundPlugin as UnknownPlugin<M, B>
}
}
return undefined
}
@ -2220,9 +2233,6 @@ export class Uppy<
]
try {
for (let step = currentUpload.step || 0; step < steps.length; step++) {
if (!currentUpload) {
break
}
const fn = steps[step]
this.setState({
@ -2235,13 +2245,7 @@ export class Uppy<
},
})
// when restoring (e.g. using golden retriever), we don't need to re-upload already successfully uploaded files
// so let's exclude them here:
// https://github.com/transloadit/uppy/issues/5930
const fileIDs = currentUpload.fileIDs.filter((fileID) => {
const file = this.getFile(fileID)
return !file.progress.uploadComplete
})
const { fileIDs } = currentUpload
// TODO give this the `updatedUpload` object as its only parameter maybe?
// Otherwise when more metadata may be added to the upload this would keep getting more parameters
@ -2249,6 +2253,9 @@ export class Uppy<
// Update currentUpload value in case it was modified asynchronously.
currentUpload = getCurrentUpload()
if (!currentUpload) {
break
}
}
} catch (err) {
this.#removeUpload(uploadID)

View file

@ -22,6 +22,7 @@ export type {
PartialTreeFolderNode,
PartialTreeFolderRoot,
PartialTreeId,
PluginTypeRegistry,
State,
UnknownPlugin,
UnknownProviderPlugin,

View file

@ -1,5 +1,6 @@
import type { Body, InternalMetadata, LocaleStrings, Meta } from '@uppy/utils'
import { expectTypeOf, test } from 'vitest'
import BasePlugin from './BasePlugin.js'
import UIPlugin, { type UIPluginOptions } from './UIPlugin.js'
import Uppy, { type UnknownPlugin } from './Uppy.js'
@ -68,3 +69,72 @@ test('Meta and Body generic move through the Uppy class', async () => {
await core.upload()
})
class TestRegistryPlugin<M extends Meta, B extends Body> extends BasePlugin<
Record<string, never>,
M,
B
> {
constructor(uppy: Uppy<M, B>) {
super(uppy, {})
this.id = 'TestRegistryPlugin'
this.type = 'acquirer'
}
}
// Augment the Type Registry with our test plugin
declare module './Uppy.js' {
interface PluginTypeRegistry<M extends Meta, B extends Body> {
TestRegistryPlugin: TestRegistryPlugin<M, B>
}
}
test('Type Registry: getPlugin with registered plugin name returns correct type', () => {
const uppy = new Uppy()
uppy.use(TestRegistryPlugin)
// When using a registered plugin name, TypeScript should infer the correct type from PluginTypeRegistry
const plugin = uppy.getPlugin('TestRegistryPlugin')
expectTypeOf(plugin).toEqualTypeOf<
TestRegistryPlugin<Meta, Record<string, never>> | undefined
>()
})
test('Type Registry: getPlugin with unregistered name returns UnknownPlugin', () => {
const uppy = new Uppy()
// When using a non-registered string, should return UnknownPlugin
const plugin = uppy.getPlugin('SomeRandomPlugin')
expectTypeOf(plugin).toEqualTypeOf<
UnknownPlugin<Meta, Record<string, never>> | undefined
>()
})
test('Type Registry: getPlugin with dynamic string returns UnknownPlugin', () => {
const uppy = new Uppy()
const pluginName: string = 'DynamicName'
// Dynamic string should use the fallback overload unlike literal string
const plugin = uppy.getPlugin(pluginName)
expectTypeOf(plugin).toEqualTypeOf<
UnknownPlugin<Meta, Record<string, never>> | undefined
>()
})
test('Type Registry: works with custom Meta and Body types', () => {
type CustomMeta = { userId: string; timestamp: number }
type CustomBody = { encrypted: boolean }
// With custom Meta and Body types
const uppy = new Uppy<CustomMeta, CustomBody>()
uppy.use(TestRegistryPlugin)
const plugin = uppy.getPlugin('TestRegistryPlugin')
expectTypeOf(plugin).toEqualTypeOf<
TestRegistryPlugin<CustomMeta, CustomBody> | undefined
>()
})

View file

@ -56,6 +56,7 @@
},
"devDependencies": {
"@uppy/core": "workspace:^",
"@uppy/dropbox": "workspace:^",
"@uppy/google-drive": "workspace:^",
"@uppy/url": "workspace:^",
"@uppy/webcam": "workspace:^",

View file

@ -40,6 +40,10 @@ declare module '@uppy/core' {
'dashboard:file-edit-complete': DashboardFileEditCompleteCallback<M, B>
'dashboard:close-panel': (id: string | undefined) => void
}
export interface PluginTypeRegistry<M extends Meta, B extends Body> {
Dashboard: Dashboard<M, B>
}
}
interface PromiseWithResolvers<T> {

View file

@ -0,0 +1,568 @@
import Uppy from '@uppy/core'
import Dashboard from '@uppy/dashboard'
import Dropbox from '@uppy/dropbox'
import GoogleDrive from '@uppy/google-drive'
import { ProviderViews } from '@uppy/provider-views'
import { page, userEvent } from '@vitest/browser/context'
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest'
import { worker } from './setup.js'
import '@uppy/core/css/style.css'
import '@uppy/dashboard/css/style.css'
let uppy: Uppy | undefined
/**
* In Normal mode (ListItem.tsx), folders are rendered as buttons, whereas files are rendered as checkboxes with a corresponding <label>.
* In Search mode (SearchListItem.tsx), both files and folders are rendered as buttons.
* Because of this, in Normal mode, when checking whether a file exists, we need to use:
* await expect.element(page.getByRole('button', { name:'nested-target.pdf', exact: true }))
* whereas, in Search mode, we need to scope the query to the checkbox role instead when searching for a file
*/
beforeAll(async () => {
// Disable search debounce inside ProviderView during tests to avoid long sleeps
// @ts-expect-error test-only hook
ProviderViews[Symbol.for('uppy test: searchDebounceMs')] = 0
await worker.start({
onUnhandledRequest: 'bypass',
})
})
afterAll(async () => {
await worker.stop()
})
type SourceName = 'Dropbox' | 'GoogleDrive'
function initializeUppy(sources: SourceName[] = ['Dropbox']) {
document.body.innerHTML = '<div id="app"></div>'
const instance = new Uppy({ id: 'uppy-e2e' }).use(Dashboard, {
target: '#app',
inline: true,
height: 500,
})
for (const source of sources) {
if (source === 'Dropbox') {
instance.use(Dropbox, { companionUrl: 'http://companion.test' })
} else if (source === 'GoogleDrive') {
instance.use(GoogleDrive, { companionUrl: 'http://companion.test' })
}
}
return instance
}
// Removed shared beforeEach initialization. Each test initializes its own Uppy instance.
afterEach(async () => {
if (!uppy) return
// this is done to prevent the edgecase when all plugins are removed before dashboard is unmounted from UI
// causing PickerPanelContent to crash
const dashboard = uppy.getPlugin('Dashboard')
dashboard?.hideAllPanels()
const panelSelector = '[data-uppy-panelType="PickerPanel"]'
if (document.querySelector(panelSelector)) {
await expect.poll(() => document.querySelector(panelSelector)).toBeNull()
}
uppy.destroy()
uppy = undefined
})
describe('ProviderView Search E2E', () => {
test('Search for nested file in Dropbox and verify results', async () => {
uppy = initializeUppy(['Dropbox'])
await expect
.element(page.getByRole('presentation').getByText('Dropbox'))
.toBeVisible()
await page.getByRole('tab', { name: 'Dropbox' }).click()
await expect
.element(page.getByRole('heading', { name: /import from dropbox/i }))
.toBeVisible()
const panel = page.getByRole('tabpanel')
await expect.element(panel.getByText('test-user@example.com')).toBeVisible()
const searchInput = document.querySelector(
'.uppy-ProviderBrowser-searchFilterInput',
) as HTMLInputElement
expect(searchInput).toBeDefined()
// Search mode (SearchResultItem.tsx): files and folders render as buttons;
// use role=button for file assertions in search results.
await userEvent.type(searchInput, 'target')
await expect
.element(page.getByRole('button', { name: 'target.pdf', exact: true }))
.toBeVisible()
const targetPdfItem = await page.getByRole('button', {
name: 'target.pdf',
exact: true,
})
expect(targetPdfItem).toBeTruthy()
})
test('Search deep folder -> open it -> click ancestor breadcrumb and navigate correctly', async () => {
uppy = initializeUppy(['Dropbox'])
await expect
.element(page.getByRole('presentation').getByText('Dropbox'))
.toBeVisible()
await page.getByRole('tab', { name: 'Dropbox' }).click()
await expect
.element(page.getByRole('heading', { name: /import from dropbox/i }))
.toBeVisible()
const panel = page.getByRole('tabpanel')
await expect.element(panel.getByText('test-user@example.com')).toBeVisible()
await expect
.element(page.getByRole('button', { name: 'first' }))
.toBeVisible()
const searchInput = document.querySelector(
'.uppy-ProviderBrowser-searchFilterInput',
) as HTMLInputElement
await userEvent.clear(searchInput)
await userEvent.type(searchInput, 'second')
await expect
.element(page.getByRole('button', { name: 'second' }))
.toBeVisible()
const secondFolder = await page.getByRole('button', { name: 'second' })
await secondFolder.click()
// Normal mode (ListItem.tsx): files render as checkboxes with a corresponding <label>.
// Use role=checkbox for file assertions in browse view.
await expect
.element(
page.getByRole('checkbox', { name: 'deep-file.txt', exact: true }),
)
.toBeVisible()
// Click ancestor breadcrumb that was never loaded before in browse mode
const firstBreadcrumb = page.getByRole('button', { name: 'first' })
await firstBreadcrumb.click()
const hasSecondFolder = await page.getByRole('button', {
name: 'second',
exact: true,
})
expect(hasSecondFolder).toBeVisible()
})
test('Check folder in browse mode, search for nested item -> nested item should be checked', async () => {
uppy = initializeUppy(['Dropbox'])
await expect
.element(page.getByRole('presentation').getByText('Dropbox'))
.toBeVisible()
await page.getByRole('tab', { name: 'Dropbox' }).click()
await expect
.element(page.getByRole('heading', { name: /import from dropbox/i }))
.toBeVisible()
const panel = page.getByRole('tabpanel')
await expect.element(panel.getByText('test-user@example.com')).toBeVisible()
await expect
.element(page.getByRole('button', { name: 'first' }))
.toBeVisible()
const firstFolderItem = Array.from(
document.querySelectorAll('.uppy-ProviderBrowserItem'),
).find(
(item) =>
item.textContent?.includes('first') && item.querySelector('button'),
)
const firstFolderCheckbox =
firstFolderItem?.querySelector<HTMLInputElement>('input[type="checkbox"]')
expect(firstFolderCheckbox).toBeTruthy()
await firstFolderCheckbox!.click()
expect(firstFolderCheckbox!.checked).toBe(true)
const searchInput = document.querySelector(
'.uppy-ProviderBrowser-searchFilterInput',
) as HTMLInputElement
await userEvent.type(searchInput, 'second')
await expect
.element(page.getByRole('button', { name: 'second', exact: true }))
.toBeVisible()
const secondFolderItem = Array.from(
document.querySelectorAll('.uppy-ProviderBrowserItem'),
).find((item) => item.textContent?.includes('second'))
const secondFolderCheckbox =
secondFolderItem?.querySelector<HTMLInputElement>(
'input[type="checkbox"]',
)
expect(secondFolderCheckbox).toBeTruthy()
// Children inherit checked state from parent
expect(secondFolderCheckbox!.checked).toBe(true)
})
test('Search for nested item, check it, go back to normal view -> parent should be partial', async () => {
uppy = initializeUppy(['Dropbox'])
await expect
.element(page.getByRole('presentation').getByText('Dropbox'))
.toBeVisible()
await page.getByRole('tab', { name: 'Dropbox' }).click()
await expect
.element(page.getByRole('heading', { name: /import from dropbox/i }))
.toBeVisible()
const panel = page.getByRole('tabpanel')
await expect.element(panel.getByText('test-user@example.com')).toBeVisible()
await expect
.element(page.getByRole('button', { name: 'first' }))
.toBeVisible()
const searchInput = document.querySelector(
'.uppy-ProviderBrowser-searchFilterInput',
) as HTMLInputElement
await userEvent.type(searchInput, 'second')
await expect
.element(page.getByRole('button', { name: 'second', exact: true }))
.toBeVisible()
const secondFolderItem = Array.from(
document.querySelectorAll('.uppy-ProviderBrowserItem'),
).find((item) => item.textContent?.includes('second'))
const secondFolderCheckbox =
secondFolderItem?.querySelector<HTMLInputElement>(
'input[type="checkbox"]',
)
expect(secondFolderCheckbox).toBeTruthy()
await secondFolderCheckbox!.click()
expect(secondFolderCheckbox!.checked).toBe(true)
const clearSearchButton = document.querySelector(
'.uppy-ProviderBrowser-searchFilterReset',
) as HTMLButtonElement
expect(clearSearchButton).toBeDefined()
await clearSearchButton.click()
await expect
.element(page.getByRole('button', { name: 'first' }))
.toBeVisible()
const firstFolderItem = Array.from(
document.querySelectorAll('.uppy-ProviderBrowserItem'),
).find(
(item) =>
item.textContent?.includes('first') && item.querySelector('button'),
)
expect(firstFolderItem).toBeTruthy()
// Parent is partial when some (but not all) children are checked
expect(
firstFolderItem?.classList.contains(
'uppy-ProviderBrowserItem--is-partial',
),
).toBe(true)
})
test('Search for nested item, check then uncheck it, go back to normal view -> parent should be unchecked', async () => {
uppy = initializeUppy(['Dropbox'])
await expect
.element(page.getByRole('presentation').getByText('Dropbox'))
.toBeVisible()
await page.getByRole('tab', { name: 'Dropbox' }).click()
await expect
.element(page.getByRole('heading', { name: /import from dropbox/i }))
.toBeVisible()
const panel = page.getByRole('tabpanel')
await expect.element(panel.getByText('test-user@example.com')).toBeVisible()
await expect
.element(page.getByRole('button', { name: 'first' }))
.toBeVisible()
const searchInput = document.querySelector(
'.uppy-ProviderBrowser-searchFilterInput',
) as HTMLInputElement
await userEvent.type(searchInput, 'second')
await expect
.element(page.getByRole('button', { name: 'second', exact: true }))
.toBeVisible()
const secondFolderItem = Array.from(
document.querySelectorAll('.uppy-ProviderBrowserItem'),
).find((item) => item.textContent?.includes('second'))
const secondFolderCheckbox =
secondFolderItem?.querySelector<HTMLInputElement>(
'input[type="checkbox"]',
)
expect(secondFolderCheckbox).toBeTruthy()
await secondFolderCheckbox!.click()
expect(secondFolderCheckbox!.checked).toBe(true)
await secondFolderCheckbox!.click()
expect(secondFolderCheckbox!.checked).toBe(false)
const clearSearchButton = document.querySelector(
'.uppy-ProviderBrowser-searchFilterReset',
) as HTMLButtonElement
expect(clearSearchButton).toBeDefined()
await clearSearchButton.click()
await expect
.element(page.getByRole('button', { name: 'first' }))
.toBeVisible()
const firstFolderItem = Array.from(
document.querySelectorAll('.uppy-ProviderBrowserItem'),
).find(
(item) =>
item.textContent?.includes('first') && item.querySelector('button'),
)
expect(firstFolderItem).toBeTruthy()
expect(
firstFolderItem?.classList.contains(
'uppy-ProviderBrowserItem--is-checked',
),
).toBe(false)
expect(
firstFolderItem?.classList.contains(
'uppy-ProviderBrowserItem--is-partial',
),
).toBe(false)
const firstFolderCheckbox =
firstFolderItem?.querySelector<HTMLInputElement>('input[type="checkbox"]')
expect(firstFolderCheckbox).toBeTruthy()
expect(firstFolderCheckbox!.checked).toBe(false)
})
test('Navigate into folder and perform scoped search -> should find nested files at multiple levels', async () => {
uppy = initializeUppy(['Dropbox'])
await expect
.element(page.getByRole('presentation').getByText('Dropbox'))
.toBeVisible()
await page.getByRole('tab', { name: 'Dropbox' }).click()
await expect
.element(page.getByRole('heading', { name: /import from dropbox/i }))
.toBeVisible()
const panel = page.getByRole('tabpanel')
await expect.element(panel.getByText('test-user@example.com')).toBeVisible()
await expect
.element(page.getByRole('button', { name: 'first' }))
.toBeVisible()
const firstFolderButton = page.getByRole('button', { name: 'first' })
await firstFolderButton.click()
await expect
.element(page.getByRole('button', { name: 'second' }))
.toBeVisible()
// Normal mode (ListItem.tsx): files render as checkboxes with corresponding <label>; scope by role=checkbox. refer to a commment at the top of the file for more detailed explanation.
await expect
.element(
page.getByRole('checkbox', { name: 'intermediate.doc', exact: true }),
)
.toBeVisible()
const searchInput = document.querySelector(
'.uppy-ProviderBrowser-searchFilterInput',
) as HTMLInputElement
expect(searchInput).toBeDefined()
await userEvent.type(searchInput, 'target')
// Search mode (SearchResultItem.tsx): files render as buttons; scope by role=button.
await expect
.element(page.getByRole('button', { name: 'target.pdf', exact: true }))
.toBeVisible()
await expect
.element(
page.getByRole('button', { name: 'nested-target.pdf', exact: true }),
)
.toBeVisible()
const searchResults = Array.from(
document.querySelectorAll('.uppy-ProviderBrowserItem'),
)
const targetFiles = searchResults.filter((item) =>
item.textContent?.toLowerCase().includes('target'),
)
expect(targetFiles.length).toBe(2)
})
test('No duplicate items when searching and then browsing to the same file', async () => {
uppy = initializeUppy(['Dropbox'])
await expect
.element(page.getByRole('presentation').getByText('Dropbox'))
.toBeVisible()
await page.getByRole('tab', { name: 'Dropbox' }).click()
await expect
.element(page.getByRole('heading', { name: /import from dropbox/i }))
.toBeVisible()
const panel = page.getByRole('tabpanel')
await expect.element(panel.getByText('test-user@example.com')).toBeVisible()
await expect
.element(page.getByRole('button', { name: 'first' }))
.toBeVisible()
// Normal mode (ListItem.tsx): file is a checkbox; assert by role=checkbox. refer to a commment at the top of the file for more detailed explanation.
await expect
.element(page.getByRole('checkbox', { name: 'readme.md', exact: true }))
.toBeVisible()
const searchInput = document.querySelector(
'.uppy-ProviderBrowser-searchFilterInput',
) as HTMLInputElement
await userEvent.type(searchInput, 'readme')
// Search mode (SearchResultItem.tsx): file is a button; assert by role=button.
await expect
.element(page.getByRole('button', { name: 'readme.md', exact: true }))
.toBeVisible()
const searchResults = Array.from(
document.querySelectorAll('.uppy-ProviderBrowserItem'),
)
const readmeInSearch = searchResults.filter((item) =>
item.textContent?.includes('readme.md'),
)
expect(readmeInSearch.length).toBe(1)
const clearSearchButton = document.querySelector(
'.uppy-ProviderBrowser-searchFilterReset',
) as HTMLButtonElement
expect(clearSearchButton).toBeDefined()
await clearSearchButton.click()
// proceed to verify browse results directly
const browseResults = Array.from(
document.querySelectorAll('.uppy-ProviderBrowserItem'),
)
const readmeInBrowse = browseResults.filter((item) =>
item.textContent?.includes('readme.md'),
)
expect(readmeInBrowse.length).toBe(1)
const readmeCheckbox = readmeInBrowse[0]?.querySelector<HTMLInputElement>(
'input[type="checkbox"]',
)
expect(readmeCheckbox).toBeTruthy()
await readmeCheckbox!.click()
expect(readmeCheckbox!.checked).toBe(true)
// Verify checked state persists after searching again (same node in partialTree)
await userEvent.clear(searchInput)
await userEvent.type(searchInput, 'readme')
await expect
.element(page.getByRole('button', { name: 'readme.md', exact: true }))
.toBeVisible()
const searchResultsAgain = Array.from(
document.querySelectorAll('.uppy-ProviderBrowserItem'),
)
const readmeInSearchAgain = searchResultsAgain.find((item) =>
item.textContent?.includes('readme.md'),
)
const readmeCheckboxInSearch =
readmeInSearchAgain?.querySelector<HTMLInputElement>(
'input[type="checkbox"]',
)
expect(readmeCheckboxInSearch).toBeTruthy()
expect(readmeCheckboxInSearch!.checked).toBe(true)
})
test('Client-side filtering works for providers without server-side search (Google Drive)', async () => {
uppy = initializeUppy(['GoogleDrive'])
await expect
.element(page.getByRole('presentation').getByText('Google Drive'))
.toBeVisible()
await page.getByRole('tab', { name: /google drive/i }).click()
await expect
.element(page.getByRole('heading', { name: /import from google drive/i }))
.toBeVisible()
const panel = page.getByRole('tabpanel')
await expect.element(panel.getByText('test-user@example.com')).toBeVisible()
await expect
.element(page.getByRole('button', { name: 'first' }))
.toBeVisible()
await expect
.element(page.getByRole('checkbox', { name: 'workspace' }))
.toBeVisible()
await expect
.element(page.getByRole('checkbox', { name: 'readme.md' }))
.toBeVisible()
const searchInput = document.querySelector(
'.uppy-ProviderBrowser-searchFilterInput',
) as HTMLInputElement
expect(searchInput).toBeDefined()
await userEvent.type(searchInput, 'workspace')
await expect.element(page.getByText('workspace')).toBeVisible()
const visibleItems = Array.from(
document.querySelectorAll('.uppy-ProviderBrowserItem'),
)
expect(visibleItems.length).toBe(1)
const workspaceItem = visibleItems.find((item) =>
item.textContent?.includes('workspace'),
)
expect(workspaceItem).toBeTruthy()
const firstItem = visibleItems.find((item) => {
const button = item.querySelector('button.uppy-ProviderBrowserItem-inner')
return button?.textContent?.trim() === 'first'
})
const readmeItem = visibleItems.find((item) =>
item.textContent?.includes('readme.md'),
)
expect(firstItem).toBeUndefined()
expect(readmeItem).toBeUndefined()
await userEvent.clear(searchInput)
await expect
.element(page.getByRole('button', { name: 'first' }))
.toBeVisible()
const allItems = Array.from(
document.querySelectorAll('.uppy-ProviderBrowserItem'),
)
expect(allItems.length).toBe(3)
await userEvent.type(searchInput, 'readme')
await expect
.element(page.getByRole('checkbox', { name: 'readme.md' }))
.toBeVisible()
const filteredItems = Array.from(
document.querySelectorAll('.uppy-ProviderBrowserItem'),
)
expect(filteredItems.length).toBe(1)
const readmeFiltered = filteredItems.find((item) =>
item.textContent?.includes('readme.md'),
)
expect(readmeFiltered).toBeTruthy()
})
})

View file

@ -0,0 +1,447 @@
import { HttpResponse, http } from 'msw'
const COMPANION_URL = 'http://companion.test'
/**
* Mocked Folder structure :
*
root/ (Dropbox)
first/
second/
third/
nested-target.pdf
new-file.pdf
deep-file.txt
target.pdf
workspace.pdf
intermediate.doc
workspace/
project/
code.js
readme.md
*/
export const handlers = [
// Mock pre-auth token
http.get(`${COMPANION_URL}/:provider/preauth`, () => {
return HttpResponse.json({ token: 'mock-preauth-token' })
}),
// Mock authentication check
http.get(`${COMPANION_URL}/:provider/connect`, () => {
return HttpResponse.json({
authenticated: true,
username: 'test-user@example.com',
})
}),
// Mock folder listing endpoint
http.get(`${COMPANION_URL}/:provider/list/*`, ({ request }) => {
const url = new URL(request.url)
// Extract path: split by '/list/' and take everything after it
const [, afterList] = url.pathname.split('/list/')
const pathStr = afterList ? decodeURIComponent(afterList) : ''
// Root folder
if (!pathStr || pathStr === 'root') {
return HttpResponse.json({
username: 'test-user@example.com',
items: [
{
isFolder: true,
icon: 'folder',
name: 'first',
mimeType: 'folder',
id: 'folder-first',
thumbnail: null,
requestPath: encodeURIComponent('/first'),
modifiedDate: '2024-01-01T00:00:00Z',
size: null,
},
{
isFolder: true,
icon: 'folder',
name: 'workspace',
mimeType: 'folder',
id: 'folder-workspace',
thumbnail: null,
requestPath: encodeURIComponent('/workspace'),
modifiedDate: '2024-01-02T00:00:00Z',
size: null,
},
{
isFolder: false,
icon: 'file',
name: 'readme.md',
mimeType: 'text/markdown',
id: 'file-readme',
thumbnail: null,
requestPath: encodeURIComponent('/readme.md'),
modifiedDate: '2024-01-03T00:00:00Z',
size: 1024,
},
],
nextPagePath: null,
})
}
// first folder
if (pathStr === '/first') {
return HttpResponse.json({
username: 'test-user@example.com',
items: [
{
isFolder: true,
icon: 'folder',
name: 'second',
mimeType: 'folder',
id: 'folder-second',
thumbnail: null,
requestPath: encodeURIComponent('/first/second'),
modifiedDate: '2024-01-04T00:00:00Z',
size: null,
},
{
isFolder: false,
icon: 'file',
name: 'intermediate.doc',
mimeType: 'application/msword',
id: 'file-intermediate',
thumbnail: null,
requestPath: encodeURIComponent('/first/intermediate.doc'),
modifiedDate: '2024-01-05T00:00:00Z',
size: 2048,
},
],
nextPagePath: null,
})
}
// first/second folder (deep nested)
if (pathStr === '/first/second') {
return HttpResponse.json({
username: 'test-user@example.com',
items: [
{
isFolder: true,
icon: 'folder',
name: 'third',
mimeType: 'folder',
id: 'folder-third',
thumbnail: null,
requestPath: encodeURIComponent('/first/second/third'),
modifiedDate: '2024-01-06T00:00:00Z',
size: null,
},
{
isFolder: false,
icon: 'file',
name: 'deep-file.txt',
mimeType: 'text/plain',
id: 'file-deep',
thumbnail: null,
requestPath: encodeURIComponent('/first/second/deep-file.txt'),
modifiedDate: '2024-01-07T00:00:00Z',
size: 512,
},
{
isFolder: false,
icon: 'file',
name: 'target.pdf',
mimeType: 'application/pdf',
id: 'file-target',
thumbnail: null,
requestPath: encodeURIComponent('/first/second/target.pdf'),
modifiedDate: '2024-01-08T00:00:00Z',
size: 4096,
},
{
isFolder: false,
icon: 'file',
name: 'workspace.pdf',
mimeType: 'application/pdf',
id: 'file-workspace-pdf',
thumbnail: null,
requestPath: encodeURIComponent('/first/second/workspace.pdf'),
modifiedDate: '2024-01-11T00:00:00Z',
size: 5120,
},
],
nextPagePath: null,
})
}
// first/second/third folder (deepest level)
if (pathStr === '/first/second/third') {
return HttpResponse.json({
username: 'test-user@example.com',
items: [
{
isFolder: false,
icon: 'file',
name: 'nested-target.pdf',
mimeType: 'application/pdf',
id: 'file-nested-target',
thumbnail: null,
requestPath: encodeURIComponent(
'/first/second/third/nested-target.pdf',
),
modifiedDate: '2024-01-09T00:00:00Z',
size: 2048,
},
{
isFolder: false,
icon: 'file',
name: 'new-file.pdf',
mimeType: 'application/pdf',
id: 'file-new',
thumbnail: null,
requestPath: encodeURIComponent('/first/second/third/new-file.pdf'),
modifiedDate: '2024-01-10T00:00:00Z',
size: 3072,
},
],
nextPagePath: null,
})
}
// workspace folder
if (pathStr === '/workspace') {
return HttpResponse.json({
username: 'test-user@example.com',
items: [
{
isFolder: true,
icon: 'folder',
name: 'project',
mimeType: 'folder',
id: 'folder-project',
thumbnail: null,
requestPath: encodeURIComponent('/workspace/project'),
modifiedDate: '2024-01-08T00:00:00Z',
size: null,
},
],
nextPagePath: null,
})
}
// workspace/project folder
if (pathStr === '/workspace/project') {
return HttpResponse.json({
username: 'test-user@example.com',
items: [
{
isFolder: false,
icon: 'file',
name: 'code.js',
mimeType: 'application/javascript',
id: 'file-code',
thumbnail: null,
requestPath: encodeURIComponent('/workspace/project/code.js'),
modifiedDate: '2024-01-09T00:00:00Z',
size: 3072,
},
],
nextPagePath: null,
})
}
// Default empty folder
return HttpResponse.json({
username: 'test-user@example.com',
items: [],
nextPagePath: null,
})
}),
// Mock search endpoint
http.get(`${COMPANION_URL}/:provider/search`, ({ request, params }) => {
const url = new URL(request.url)
const provider = params.provider as string
const query = url.searchParams.get('q') || ''
const searchPath = url.searchParams.get('path')
if (provider === 'drive') {
return HttpResponse.json(
{ message: 'method not implemented' },
{ status: 500 },
)
}
// Search for "second" folder
if (query.toLowerCase() === 'second') {
return HttpResponse.json({
username: 'test-user@example.com',
items: [
{
isFolder: true,
icon: 'folder',
name: 'second',
mimeType: 'folder',
id: 'folder-second',
thumbnail: null,
requestPath: encodeURIComponent('/first/second'),
modifiedDate: '2024-01-04T00:00:00Z',
size: null,
},
],
nextPagePath: null,
searchedFor: query,
})
}
// Search for "target" file (global search from root)
if (query.toLowerCase() === 'target' && !searchPath) {
return HttpResponse.json({
username: 'test-user@example.com',
items: [
{
isFolder: false,
icon: 'file',
name: 'target.pdf',
mimeType: 'application/pdf',
id: 'file-target',
thumbnail: null,
requestPath: encodeURIComponent('/first/second/target.pdf'),
modifiedDate: '2024-01-08T00:00:00Z',
size: 4096,
},
{
isFolder: false,
icon: 'file',
name: 'nested-target.pdf',
mimeType: 'application/pdf',
id: 'file-nested-target',
thumbnail: null,
requestPath: encodeURIComponent(
'/first/second/third/nested-target.pdf',
),
modifiedDate: '2024-01-09T00:00:00Z',
size: 2048,
},
],
nextPagePath: null,
searchedFor: query,
})
}
// Scoped search for "target" from /first directory
if (
query.toLowerCase() === 'target' &&
searchPath === encodeURIComponent('/first')
) {
return HttpResponse.json({
username: 'test-user@example.com',
items: [
{
isFolder: false,
icon: 'file',
name: 'target.pdf',
mimeType: 'application/pdf',
id: 'file-target',
thumbnail: null,
requestPath: encodeURIComponent('/first/second/target.pdf'),
modifiedDate: '2024-01-08T00:00:00Z',
size: 4096,
},
{
isFolder: false,
icon: 'file',
name: 'nested-target.pdf',
mimeType: 'application/pdf',
id: 'file-nested-target',
thumbnail: null,
requestPath: encodeURIComponent(
'/first/second/third/nested-target.pdf',
),
modifiedDate: '2024-01-09T00:00:00Z',
size: 2048,
},
],
nextPagePath: null,
searchedFor: query,
})
}
// Search for "deep" - deep nested file
if (query.toLowerCase() === 'deep') {
return HttpResponse.json({
username: 'test-user@example.com',
items: [
{
isFolder: false,
icon: 'file',
name: 'deep-file.txt',
mimeType: 'text/plain',
id: 'file-deep',
thumbnail: null,
requestPath: encodeURIComponent('/first/second/deep-file.txt'),
modifiedDate: '2024-01-07T00:00:00Z',
size: 512,
},
],
nextPagePath: null,
searchedFor: query,
})
}
// Search from subpath - "code" in workspace
if (
query.toLowerCase() === 'code' &&
searchPath === encodeURIComponent('/workspace')
) {
return HttpResponse.json({
username: 'test-user@example.com',
items: [
{
isFolder: false,
icon: 'file',
name: 'code.js',
mimeType: 'application/javascript',
id: 'file-code',
thumbnail: null,
requestPath: encodeURIComponent('/workspace/project/code.js'),
modifiedDate: '2024-01-09T00:00:00Z',
size: 3072,
},
],
nextPagePath: null,
searchedFor: query,
})
}
// Search for "readme" file in root
if (query.toLowerCase() === 'readme') {
return HttpResponse.json({
username: 'test-user@example.com',
items: [
{
isFolder: false,
icon: 'file',
name: 'readme.md',
mimeType: 'text/markdown',
id: 'file-readme',
thumbnail: null,
requestPath: encodeURIComponent('/readme.md'),
modifiedDate: '2024-01-03T00:00:00Z',
size: 1024,
},
],
nextPagePath: null,
searchedFor: query,
})
}
// No results
return HttpResponse.json({
username: 'test-user@example.com',
items: [],
nextPagePath: null,
searchedFor: query,
})
}),
]

View file

@ -0,0 +1,4 @@
import { setupWorker } from 'msw/browser'
import { handlers } from '../src/mocks/CompanionHandler.js'
export const worker = setupWorker(...handlers)

View file

@ -12,6 +12,12 @@ import type { ComponentChild, h } from 'preact'
import packageJson from '../package.json' with { type: 'json' }
import locale from './locale.js'
declare module '@uppy/core' {
export interface PluginTypeRegistry<M extends Meta, B extends Body> {
DragDrop: DragDrop<M, B>
}
}
export interface DragDropOptions extends UIPluginOptions {
inputName?: string
allowMultipleFiles?: boolean

View file

@ -3,6 +3,12 @@ import { BasePlugin } from '@uppy/core'
import { getDroppedFiles, toArray } from '@uppy/utils'
import packageJson from '../package.json' with { type: 'json' }
declare module '@uppy/core' {
export interface PluginTypeRegistry<M extends Meta, B extends Body> {
DropTarget: DropTarget<M, B>
}
}
export interface DropTargetOptions extends PluginOpts {
target?: HTMLElement | string | null
onDrop?: (event: DragEvent) => void

View file

@ -21,6 +21,12 @@ import { type ComponentChild, h } from 'preact'
import packageJson from '../package.json' with { type: 'json' }
import locale from './locale.js'
declare module '@uppy/core' {
export interface PluginTypeRegistry<M extends Meta, B extends Body> {
Dropbox: Dropbox<M, B>
}
}
export type DropboxOptions = CompanionPluginOptions & {
locale?: LocaleStrings<typeof locale>
}

View file

@ -21,6 +21,12 @@ import { type ComponentChild, h } from 'preact'
import packageJson from '../package.json' with { type: 'json' }
import locale from './locale.js'
declare module '@uppy/core' {
export interface PluginTypeRegistry<M extends Meta, B extends Body> {
Facebook: Facebook<M, B>
}
}
export type FacebookOptions = CompanionPluginOptions & {
locale?: LocaleStrings<typeof locale>
}

View file

@ -14,6 +14,12 @@ import getFormData from 'get-form-data'
import packageJson from '../package.json' with { type: 'json' }
declare module '@uppy/core' {
export interface PluginTypeRegistry<M extends Meta, B extends Body> {
Form: Form<M, B>
}
}
type Result<M extends Meta, B extends Body> = Parameters<
UppyEventMap<M, B>['complete']
>[0]

View file

@ -20,7 +20,9 @@ function createUppy(
const root = document.createElement('div')
document.body.appendChild(root)
return new Uppy().use(Dashboard, {
return new Uppy({
debug: true,
}).use(Dashboard, {
target: root,
inline: true,
...opts,
@ -36,9 +38,11 @@ beforeEach(async () => {
document.body.innerHTML = ''
})
let fileIndex = 0
const createMockFile = ({
size,
name = `${Date.now()}.txt`,
name = `${fileIndex++}.txt`,
type = 'text/plain',
}: {
name?: string
@ -99,7 +103,9 @@ describe('Golden retriever', () => {
requestAt += 1
return HttpResponse.json({})
}
// never reply to subsequent requests (leave them hanging)
// fail subsequent requests
requestAt += 1
return HttpResponse.json({}, { status: 400 })
}),
)
@ -117,10 +123,15 @@ describe('Golden retriever', () => {
uppy.once('upload-success', () => resolve()),
)
const uploadCompletePromise = new Promise<void>((resolve) =>
uppy.once('complete', () => resolve()),
)
// Start the upload
await page.getByRole('button', { name: 'Upload 2 files' }).click()
await uploadFirstFileCompletePromise
await uploadCompletePromise
// reload page and recreate Uppy instance
uppy = createUppy({ withPageReload: true })
@ -140,7 +151,8 @@ describe('Golden retriever', () => {
return HttpResponse.json({})
}
// don't allow more than 1 request
return new HttpResponse({ status: 400 })
requestAt += 1
return HttpResponse.json({}, { status: 400 })
}),
)
@ -152,6 +164,7 @@ describe('Golden retriever', () => {
.toBeVisible()
expect(uppy.getFiles().length).toBe(2)
expect(requestAt).toBe(1) // only the first file upload should have happened so far
})
test('Should not clean up files upon completion if there were failed uploads and it should only make the failed file a ghost', async ({
@ -207,9 +220,9 @@ describe('Golden retriever', () => {
// Simulate ghosting of the files by deleting it from store(s)
// @ts-expect-error
;(uppy.getPlugin('GoldenRetriever') as GoldenRetriever<any, any>)[
Symbol.for('uppy test: deleteBlobs')
](fileIds)
uppy
.getPlugin('GoldenRetriever')
[Symbol.for('uppy test: deleteBlobs')](fileIds)
// reload page and recreate Uppy instance
uppy = createUppy({ withPageReload: true }).use(GoldenRetriever)

View file

@ -19,6 +19,9 @@ declare module '@uppy/core' {
export interface UppyEventMap<M extends Meta, B extends Body> {
'restore:plugin-data-changed': (data: Record<string, unknown>) => void
}
export interface PluginTypeRegistry<M extends Meta, B extends Body> {
GoldenRetriever: GoldenRetriever<M, B>
}
}
export interface GoldenRetrieverOptions extends PluginOpts {

View file

@ -15,6 +15,12 @@ import type { LocaleStrings } from '@uppy/utils'
import packageJson from '../package.json' with { type: 'json' }
import locale from './locale.js'
declare module '@uppy/core' {
export interface PluginTypeRegistry<M extends Meta, B extends Body> {
GoogleDrivePicker: GoogleDrivePicker<M, B>
}
}
export type GoogleDrivePickerOptions = CompanionPluginOptions & {
clientId: string
apiKey: string

View file

@ -22,6 +22,12 @@ import packageJson from '../package.json' with { type: 'json' }
import DriveProviderViews from './DriveProviderViews.js'
import locale from './locale.js'
declare module '@uppy/core' {
export interface PluginTypeRegistry<M extends Meta, B extends Body> {
GoogleDrive: GoogleDrive<M, B>
}
}
export type GoogleDriveOptions = CompanionPluginOptions & {
locale?: LocaleStrings<typeof locale>
}

View file

@ -15,6 +15,12 @@ import type { LocaleStrings } from '@uppy/utils'
import packageJson from '../package.json' with { type: 'json' }
import locale from './locale.js'
declare module '@uppy/core' {
export interface PluginTypeRegistry<M extends Meta, B extends Body> {
GooglePhotosPicker: GooglePhotosPicker<M, B>
}
}
export type GooglePhotosPickerOptions = CompanionPluginOptions & {
clientId: string
locale?: LocaleStrings<typeof locale>

View file

@ -13,6 +13,12 @@ import packageJson from '../package.json' with { type: 'json' }
import Editor from './Editor.js'
import locale from './locale.js'
declare module '@uppy/core' {
export interface PluginTypeRegistry<M extends Meta, B extends Body> {
ImageEditor: ImageEditor<M, B>
}
}
declare global {
namespace preact {
interface Component {

View file

@ -21,6 +21,12 @@ import { type ComponentChild, h } from 'preact'
import packageJson from '../package.json' with { type: 'json' }
import locale from './locale.js'
declare module '@uppy/core' {
export interface PluginTypeRegistry<M extends Meta, B extends Body> {
Instagram: Instagram<M, B>
}
}
export type InstagramOptions = CompanionPluginOptions & {
locale?: LocaleStrings<typeof locale>
}

View file

@ -15,152 +15,185 @@ nl_NL.strings = {
'0': 'Toevoegen van %{smart_count} bestand mislukt door een interne fout',
'1': 'Toevoegen van %{smart_count} bestanden mislukt door interne fouten',
},
addedNumFiles: '%{numFiles} bestand(en) toegevoegd',
addingMoreFiles: 'Bezig met extra bestanden toe te voegen',
additionalRestrictionsFailed:
'Er werd niet voldaan aan %{count} additionele restricties',
addMore: 'Meer toevoegen',
addMoreFiles: 'Extra bestanden toevoegen',
addMoreFiles: 'Meer bestanden toevoegen',
aggregateExceedsSize:
'Je selecteerde %{size} aan bestanden, maar maximaal %{sizeAllowed} is toegestaan',
allFilesFromFolderNamed: 'Alle bestanden uit de map %{name}',
allowAccessDescription:
"Geef toestemming om foto's of videobeelden te kunnen maken.",
allowAccessTitle: 'Geef toestemming om je camera te gebruiken',
"Geef deze site toegang tot je camera om foto's of videobeelden te kunnen maken.",
allowAccessTitle: 'Geef toegang tot je camera',
allowAudioAccessDescription:
'Om geluidsopnamen te maken moet U toestemming geven voor deze website',
allowAudioAccessTitle: 'Geef toestemming om uw microfoon te gebruiken',
'Geef deze site toegang tot je microfoon om geluidsopnames te maken.',
allowAudioAccessTitle: 'Geef toegang tot je microfoon',
aspectRatioLandscape: 'Landschap knippen (16:9)',
aspectRatioPortrait: 'Portret knippen (9:16)',
aspectRatioSquare: 'Vierkant knippen',
authAborted: 'Authenticatie geannuleerd',
authenticate: 'Verbinden',
authenticateWith: 'Verbinden met %{pluginName}',
authenticateWithTitle: 'Verbind met %{pluginName} om bestanden te selecteren',
back: 'Terug',
browse: 'blader',
browseFiles: 'blader',
browseFolders: 'blader',
browseFiles: 'blader naar bestanden',
browseFolders: 'blader naar mappen',
cancel: 'Annuleer',
cancelUpload: 'Annuleer upload',
closeModal: 'Sluit Venster',
closeModal: 'Sluit venster',
companionError: 'Verbinding met Companion mislukt',
companionUnauthorizeHint:
'Om toegang te ontnemen voor uw %{provider} account, ga naar %{url}',
'Om toegang te ontnemen voor je %{provider} account, ga naar %{url}',
complete: 'Voltooid',
compressedX: '%{size} bespaard door het comprimeren van afbeeldingen',
compressedX: '%{size} bespaard door afbeeldingen te comprimeren',
compressingImages: 'Afbeeldingen aan het comprimeren...',
connectedToInternet: 'Verbonden met het internet',
copyLink: 'Kopieer link',
copyLinkToClipboardFallback: 'Kopieer de onderstaande URL',
copyLinkToClipboardSuccess: 'Link naar klembord gekopieerd',
creatingAssembly: 'Upload voorbereiden...',
creatingAssemblyFailed: 'Transloadit: Kon Assembly niet creëeren',
creatingAssemblyFailed: 'Transloadit: Kon Assembly niet aanmaken',
dashboardTitle: 'Uppy Dashboard',
dashboardWindowTitle: 'Uppy Dashboard Venster (Druk escape om te sluiten)',
dataUploadedOfTotal: '%{complete} van %{total}',
dataUploadedOfUnknown: '%{complete} van onbekend',
discardMediaFile: 'Verwijder Media',
discardRecordedFile: 'Verwijder opgenomen bestand',
done: 'Klaar',
dropHint: 'Sleep hier je bestanden naartoe',
dropPasteBoth: 'Sleep hier je bestanden naartoe, plak of %{browse}',
dropPasteFiles: 'Sleep hier je bestanden naartoe, plak of %{browse}',
dropPasteFolders: 'Sleep hier je bestanden naartoe, plak of %{browse}',
dropHereOr: 'Sleep bestanden hier heen of %{browse}',
dropHint: 'Sleep bestanden hier heen',
dropPasteBoth:
'Sleep bestanden hier heen, %{browseFiles} of %{browseFolders}',
dropPasteFiles: 'Sleep bestanden hier heen of %{browseFiles}',
dropPasteFolders: 'Sleep bestanden hier heen of %{browseFolders}',
dropPasteImportBoth:
'Sleep hier je bestanden naartoe, plak, %{browse} of importeer vanuit',
'Sleep bestanden hier heen, %{browseFiles}, %{browseFolders} of importeer vanuit:',
dropPasteImportFiles:
'Sleep hier je bestanden naartoe, plak, %{browse} of importeer vanuit',
'Sleep bestanden hier heen, %{browseFiles} of importeer vanuit:',
dropPasteImportFolders:
'Sleep hier je bestanden naartoe, plak, %{browse} of importeer vanuit',
editFile: 'Bestand aanpassen',
editImage: 'Afbeelding aanpassen',
editFileWithFilename: 'Bestand aanpassen %{file}',
editing: 'Bezig %{file} aan te passen',
'Sleep bestanden hier heen, %{browseFolders} of importeer vanuit:',
editFile: 'Bestand wijzigen',
editFileWithFilename: 'Wijzig bestand %{file}',
editImage: 'Afbeelding wijzigen',
editing: 'Bezig %{file} te wijzigen',
emptyFolderAdded: 'Er werden geen bestanden toegevoegd uit de lege map',
encoding: 'Coderen...',
encoding: 'Encoderen...',
enterCorrectUrl:
'Ongeldige URL: Zorg dat je een directe link naar een bestand invoert',
enterTextToSearch: 'Type hier om te zoeken naar plaatjes',
enterUrlToImport: 'Voeg URL toe om een bestand te importeren',
enterTextToSearch: 'Type hier om te zoeken naar afbeeldingen',
enterUrlToImport: 'Voer een URL in om een bestand te importeren',
error: 'Fout',
exceedsSize:
'%{file} overschrijdt de maximaal toegelaten bestandsgrootte van %{size}',
failedToFetch:
'Companion kan deze URL niet laden, controleer of de URL correct is',
failedToUpload: 'Kon %{file} niet uploaden',
fileSource: 'Bronbestand: %{name}',
filesUploadedOfTotal: {
'0': '%{complete} van %{smart_count} bestand geüpload',
'1': '%{complete} van %{smart_count} bestanden geüpload',
},
filter: 'Filter',
finishEditingFile: 'Klaar met bestand aan te passen',
flipHorizontal: 'Flip horizontaal',
finishEditingFile: 'Klaar met bestand te wijzigen',
flipHorizontal: 'Horizontaal spiegelen',
folderAdded: {
'0': '%{smart_count} bestand uit %{folder} toegevoegd',
'1': '%{smart_count} bestanden uit %{folder} toegevoegd',
'0': '%{smart_count} bestand toegevoegd uit %{folder}',
'1': '%{smart_count} bestanden toegevoegd uit %{folder}',
},
folderAlreadyAdded: 'De map "%{folder}" is al toegevoegd',
folderAlreadyAdded: 'De map "%{folder}" werd reeds toegevoegd',
generatingThumbnails: 'Thumbnails genereren...',
import: 'Importeer',
importFiles: 'Importeer bestanden van:',
importFrom: 'Importeer vanuit %{name}',
inferiorSize: 'Dit bestand is kleiner dat de minimale grootte van %{size}',
importFiles: 'Bestanden importeren van:',
importFrom: 'Bestanden importeren vanuit %{name}',
inferiorSize: 'Dit bestand is kleiner dan de minimum grootte van %{size}',
loadedXFiles: '%{numFiles} bestanden geladen',
loading: 'Bezig met laden...',
logIn: 'Inloggen',
logOut: 'Uitloggen',
micDisabled: 'Microfoon toegang geweigerd door de gebruiker',
missingRequiredMetaField: 'De vereiste metadata ontbreekt',
missingRequiredMetaFieldOnFile:
'De vereiste metadata ontbreekt voor %{fileName}',
micDisabled: 'Microfoon toegang geweigerd door gebruiker',
missingRequiredMetaField: 'Vereiste metadata ontbreekt',
missingRequiredMetaFieldOnFile: 'Vereiste metadata ontbreekt in %{fileName}',
missingRequiredMetaFields: {
'0': 'Deze vereiste metadata ontbreekt: %{fields}.',
'1': 'Deze vereiste metadata ontbreekt: %{fields}.',
'0': 'Ontbrekende metadata: %{fields}.',
'1': 'Ontbrekende metadata: %{fields}.',
},
myDevice: 'Mijn apparaat',
noAudioDescription:
'Om audio op te nemen, sluit een microfoon of andere geluidsinput aan',
'Sluit een microfoon of andere geluidsinput aan om een geluidsopname te maken',
noAudioTitle: 'Microfoon niet beschikbaar',
noCameraDescription:
"Om foto's en/of video op te nemen, sluit een camera aan",
"Sluit een camera aan om foto's of videobeelden te maken",
noCameraTitle: 'Camera niet beschikbaar',
noDuplicates:
"Kan het dubbele bestand '%{fileName}' niet toevoegen, deze bestaat al",
"Kan het dubbele bestand '%{fileName}' niet toevoegen, het bestaat al",
noFilesFound: 'Geen bestanden of mappen gevonden',
noInternetConnection: 'Geen internetverbinding',
noMoreFilesAllowed: 'Meer bestanden kunnen niet worden toegevoegd',
noInternetConnection: 'Geen verbinding met het internet',
noMoreFilesAllowed: 'Meer bestanden kunnen niet toegevoegd worden',
noSearchResults: 'Deze zoekopdracht leverde helaas geen resultaten op',
openFolderNamed: 'Open map %{name}',
pause: 'Pauze',
paused: 'Gepauzeerd',
pauseUpload: 'Pauzeer upload',
pauseUpload: 'Upload pauzeren',
pickFiles: 'Selecteer bestanden',
pickPhotos: "Selecteer foto's",
pleaseWait: 'Gelieve te wachten',
pluginNameAudio: 'Audio',
pluginNameBox: 'Box',
pluginNameCamera: 'Camera',
pluginNameDropbox: 'Dropbox',
pluginNameFacebook: 'Facebook',
pluginNameGoogleDrive: 'Google Drive',
pluginNameGoogleDrivePicker: 'Google Drive',
pluginNameGooglePhotosPicker: 'Google Photos',
pluginNameInstagram: 'Instagram',
pluginNameOneDrive: 'OneDrive',
pluginNameScreenCapture: 'Screencast',
pluginNameUnsplash: 'Unsplash',
pluginNameUrl: 'Link',
pluginNameWebdav: 'WebDAV',
pluginNameZoom: 'Zoom',
pluginWebdavInputLabel:
'WebDAV URL naar een bestand (bijv. van ownCloud of Nextcloud)',
poweredBy: 'Mogelijk gemaakt door %{uppy}',
processingXFiles: {
'0': 'Bezig met %{smart_count} bestand te verwerken',
'1': 'Bezig met %{smart_count} bestanden te verwerken',
'0': 'Bezig met verwerken van %{smart_count} bestand',
'1': 'Bezig met verwerken van %{smart_count} bestanden',
},
recording: 'Aan het opnemen',
recordingLength: 'Opnameduur %{recording_length}',
recordingStoppedMaxSize:
'Opname gestopt omdat de bestandsgrootte de limiet bijna overschrijdt',
recordVideoBtn: 'Video opnemen',
recoveredAllFiles:
'Alle bestanden zijn herstel. U kunt doorgaan me de upload.',
'We hebben alle bestanden hersteld. Je kan de upload nu hervatten.',
recoveredXFiles: {
'0': 'We konden 1 bestand niet herstellen. Kies het bestand opnieuw en vervolg de upload',
'1': 'We konden %{smart_count} bestanden niet herstellen. Kies de bestanden opnieuw en vervolg de upload.',
'0': 'We konden 1 bestand niet herstellen. Kies het bestand opnieuw en hervat de upload.',
'1': 'We konden %{smart_count} bestanden niet herstellen. Kies de bestanden opnieuw en hervat de upload.',
},
removeFile: 'Verwijder bestand %{file}',
removeFile: 'Verwijder bestand',
reSelect: 'Opnieuw selecteren',
resetFilter: 'Filter resetten',
resetSearch: 'Zoeken resetten',
resume: 'Hervatten',
resumeUpload: 'Upload hervatten',
retry: 'Opnieuw',
retryUpload: 'Upload opnieuw',
retry: 'Opnieuw proberen',
retryUpload: 'Upload opnieuw proberen',
revert: 'Terugdraaien',
rotate: 'Draai',
rotate: 'Draai 90°',
save: 'Opslaan',
saveChanges: 'Wijzigingen opslaan',
search: 'Zoek',
searchImages: 'Zoek naar plaatjes',
selectFileNamed: 'Selecteer bestand %{name}',
search: 'Zoeken',
searchImages: "Foto's zoeken",
selectX: {
'0': 'Selecteer %{smart_count}',
'1': 'Selecteer %{smart_count}',
},
sessionRestored: 'Sessie hersteld',
showErrorDetails: 'Laat fout details zien',
showErrorDetails: 'Toon foutdetails',
signInWithGoogle: 'Inloggen met Google',
smile: 'Lach!',
smile: 'Lachen!',
startAudioRecording: 'Start audio-opname',
startCapturing: 'Start scherm-opname',
startRecording: 'Start video-opname',
@ -171,18 +204,20 @@ nl_NL.strings = {
streamPassive: 'Stream passief',
submitRecordedFile: 'Stuur opgenomen bestand',
takePicture: 'Neem een foto',
timedOut:
'Upload al gedurende %{seconds} seconden vastgelopen, bezig afbreken upload.',
unselectFileNamed: 'Deselecteer bestand %{name}',
takePictureBtn: 'Foto nemen',
takeScreenshot: 'Screenshot maken',
unnamed: 'Onbekend',
upload: 'Upload',
uploadComplete: 'Upload voltooid',
uploadFailed: 'Upload mislukt',
uploading: 'Bezig met uploaden',
uploading: 'Aan het uploaden',
uploadingXFiles: {
'0': 'Bezig met %{smart_count} bestand te uploaden',
'1': 'Bezig met %{smart_count} bestanden te uploaden',
'0': '%{smart_count} bestand aan het uploaden',
'1': '%{smart_count} bestanden aan het uploaden',
},
uploadPaused: 'Upload gepauzeerd',
uploadStalled:
'Upload maakte geen vordering gedurende %{seconds} seconden. Probeer opnieuw.',
uploadXFiles: {
'0': 'Upload %{smart_count} bestand',
'1': 'Upload %{smart_count} bestanden',
@ -199,7 +234,7 @@ nl_NL.strings = {
'0': '%{smart_count} extra bestand toegevoegd',
'1': '%{smart_count} extra bestanden toegevoegd',
},
xTimeLeft: '%{time} over',
xTimeLeft: '%{time} resterend',
youCanOnlyUploadFileTypes: 'Je kan enkel volgende types uploaden: %{types}',
youCanOnlyUploadX: {
'0': 'Je kan slechts %{smart_count} bestand uploaden',

View file

@ -9,23 +9,38 @@ const zh_CN: Locale<0> = {
zh_CN.strings = {
addBulkFilesFailed: '内部错误导致添加 %{smart_count} 个文件失败',
addedNumFiles: '已添加 %{numFiles} 个文件',
addingMoreFiles: '添加更多文件',
additionalRestrictionsFailed: '有 %{count} 个附加限制未满足',
addMore: '添加更多文件',
addMoreFiles: '添加更多文件',
addingMoreFiles: '添加更多文件',
aggregateExceedsSize:
'您选择了 %{size} 的文件,但最大允许大小为 %{sizeAllowed}',
allFilesFromFolderNamed: '来自文件夹 %{name} 的所有文件',
allowAccessDescription:
'为了通过您的相机进行拍照或录像,请给网站相机的访问权限',
allowAccessTitle: '请允许对相机的访问权限',
allowAudioAccessDescription: '为了录制音频,请允许网站访问麦克风',
allowAudioAccessTitle: '请允许访问麦克风',
aspectRatioLandscape: '裁剪为横向16:9',
aspectRatioPortrait: '裁剪为纵向9:16',
aspectRatioSquare: '裁剪为正方形',
authAborted: '认证已中止',
authenticate: '连接',
authenticateWith: '连接到 %{pluginName}',
authenticateWithTitle: '请使用 %{pluginName} 进行认证以选择文件',
back: '返回',
browse: '浏览',
browseFiles: '浏览',
browseFiles: '浏览文件',
browseFolders: '浏览文件夹',
cancel: '取消',
cancelUpload: '取消上传',
closeModal: '关闭窗口',
companionError: '和 Companion 连接失败了',
companionUnauthorizeHint: '请访问 %{url} 以认证您的 %{provider} 账户',
complete: '上传完毕',
compressedX: '通过压缩图片节省了 %{size}',
compressingImages: '正在压缩图片…',
connectedToInternet: '连接至网络',
copyLink: '复制链接',
copyLinkToClipboardFallback: '复制以下网址',
@ -33,88 +48,134 @@ zh_CN.strings = {
creatingAssembly: '正在准备上传…',
creatingAssemblyFailed: 'Transloadit无法创建程序集',
dashboardTitle: '文件上传工具',
dashboardWindowTitle: '文件上传工具窗口(点击离开以关闭)',
dataUploadedOfTotal: '%{total} / %{complete}',
dashboardWindowTitle: '文件上传工具窗口(按 Escape 键关闭)',
dataUploadedOfTotal: '%{complete} / %{total}',
dataUploadedOfUnknown: '%{complete} / 未知大小',
discardMediaFile: '丢弃媒体文件',
discardRecordedFile: '丢弃录制的文件',
done: '完成',
dropHereOr: '将文件拖拽到这里或者%{browse}',
dropHint: '拖拽文件到这里',
dropPasteBoth: '拖拽文件到这里,或者%{browse}文件',
dropPasteFiles: '拖拽文件到这里,或者%{browse}文件',
dropPasteFolders: '拖拽文件到这里,或者%{browse}文件',
dropPasteImportBoth: '拖拽文件到这里,粘贴、%{browse}或者导入',
dropPasteImportFiles: '拖拽文件到这里,粘贴、%{browse}或者导入',
dropPasteImportFolders: '拖拽文件到这里,粘贴、%{browse}或者导入',
dropPasteBoth: '将文件拖拽到这里,%{browseFiles}或%{browseFolders}',
dropPasteFiles: '将文件拖拽到这里或%{browseFiles}',
dropPasteFolders: '将文件拖拽到这里或%{browseFolders}',
dropPasteImportBoth:
'将文件拖拽到这里,%{browseFiles}、%{browseFolders}或从以下位置导入:',
dropPasteImportFiles: '将文件拖拽到这里,%{browseFiles}或从以下位置导入:',
dropPasteImportFolders:
'将文件拖拽到这里,%{browseFolders}或从以下位置导入:',
editFile: '编辑文件',
editFileWithFilename: '编辑文件 %{file}',
editImage: '编辑图片',
editing: '正在编辑 %{file}',
emptyFolderAdded: '无法从空文件夹添加文件',
encoding: '正在编码…',
enterCorrectUrl: '错误链接: 请确认您输入的是文件的链接',
enterUrlToImport: '输入链接或者导入文件',
exceedsSize: '文件超过了最大尺寸限制 %{size}',
enterCorrectUrl: '错误链接:请确认您输入的是文件的直接链接',
enterTextToSearch: '输入文字以搜索图片',
enterUrlToImport: '输入链接以导入文件',
error: '错误',
exceedsSize: '%{file} 超过了最大允许大小 %{size}',
failedToFetch: 'Companion 无法抓取此链接,请确保它是正确的',
failedToUpload: '上传 %{file} 失败',
fileSource: '文件源:%{name}',
filesUploadedOfTotal: '已上传 %{smart_count} 个文件中的 %{complete} 个',
filter: '筛选器',
finishEditingFile: '完成文件编辑',
flipHorizontal: '水平翻转',
folderAdded: '从 %{folder} 添加了 %{smart_count} 个文件',
folderAlreadyAdded: '文件夹"%{folder}"已经被添加过了',
generatingThumbnails: '正在生成缩略图…',
import: '导入',
importFiles: '从以下位置导入文件:',
importFrom: '从 %{name} 导入',
inferiorSize: '文件大小必须超过 %{size}',
inferiorSize: '此文件小于最小允许大小 %{size}',
loadedXFiles: '已加载 %{numFiles} 个文件',
loading: '正在载入…',
logIn: '登录',
logOut: '登出',
micDisabled: '麦克风的权限访问被用户拒绝',
missingRequiredMetaField: '缺少必需的元数据字段',
missingRequiredMetaFieldOnFile: '%{fileName} 中缺少必需的元数据字段',
missingRequiredMetaFields: '缺少必需的元数据字段:%{fields}',
myDevice: '我的设备',
noAudioDescription: '为了录制音频,请连接麦克风或其他音频输入设备',
noAudioTitle: '麦克风不可用',
noCameraDescription: '为了拍摄照片或录制视频,请连接一个摄像设备',
noCameraTitle: '摄像头不可用',
noDuplicates: '无法添加重复文件 %{fileName},该文件已存在',
noDuplicates: '无法添加重复文件"%{fileName}",该文件已存在',
noFilesFound: '这里空空如也',
noInternetConnection: '无法连接到网络',
noMoreFilesAllowed: '无法添加新文件:已正在上传文件',
noMoreFilesAllowed: '无法添加更多文件',
noSearchResults: '很抱歉,没有找到相关搜索结果',
openFolderNamed: '打开文件夹 %{name}',
pause: '暂停',
pauseUpload: '暂停上传',
paused: '已暂停',
pauseUpload: '暂停上传',
pickFiles: '选择文件',
pickPhotos: '选择照片',
pleaseWait: '请稍候',
pluginNameAudio: '音频',
pluginNameCamera: '相机',
pluginNameScreenCapture: '屏幕录制',
pluginNameUrl: '链接',
pluginWebdavInputLabel: 'WebDAV 文件 URL例如来自 ownCloud 或 Nextcloud',
poweredBy: '强力驱动于 %{uppy}',
processingXFiles: '正在处理 %{smart_count} 个文件',
recording: '正在录制',
recordingLength: '录制长度 %{recording_length}',
recordingStoppedMaxSize: '录像已停止,文件大小即将超过限制',
recordingLength: '录制时长 %{recording_length}',
recordingStoppedMaxSize: '录制已停止,文件大小即将超过限制',
recordVideoBtn: '录制视频',
recoveredAllFiles: '我们已恢复所有文件。您现在可以继续上传。',
recoveredXFiles:
'我们无法完全恢复 %{smart_count} 个文件。请重新选择它们并继续上传。',
removeFile: '删除文件',
reSelect: '重新选择',
resetFilter: '重置筛选器',
resetSearch: '重置搜索',
resume: '恢复',
resumeUpload: '恢复上传',
retry: '重试',
retryUpload: '重试',
retryUpload: '重试上传',
revert: '重置',
rotate: '旋转 90°',
save: '保存',
saveChanges: '保存变更',
selectFileNamed: '选择文件 %{name}',
search: '搜索',
searchImages: '搜索图片',
selectX: '选择 %{smart_count}',
sessionRestored: '会话已恢复',
showErrorDetails: '显示错误详情',
signInWithGoogle: '使用 Google 登录',
smile: '笑一笑!',
startAudioRecording: '开始音频录制',
startCapturing: '开始屏幕录制',
startRecording: '开始视频录制',
stopAudioRecording: '停止音频录制',
stopCapturing: '停止屏幕录制',
stopRecording: '停止视频录制',
streamActive: '视频流已激活',
streamPassive: '视频流未激活',
submitRecordedFile: '提交已录制视频',
submitRecordedFile: '提交录制的文件',
takePicture: '拍照',
timedOut: '上传已超时 %{seconds} 秒,中止上传',
unselectFileNamed: '取消选择文件 %{name}',
takePictureBtn: '拍照',
takeScreenshot: '截屏',
unnamed: '未命名',
upload: '上传',
uploadComplete: '上传完成',
uploadFailed: '上传失败',
uploadPaused: '上传暂停',
uploadXFiles: '上传 %{smart_count} 个文件',
uploadXNewFiles: '新上传了 %{smart_count} 个文件',
uploading: '正在上传',
uploadingXFiles: '正在上传 %{smart_count} 个文件',
xFilesSelected: '%{smart_count} 个文件待上传',
xMoreFilesAdded: '又有 %{smart_count} 个文件被添加',
uploadPaused: '上传已暂停',
uploadStalled: '上传已 %{seconds} 秒没有进度。您可能需要重试。',
uploadXFiles: '上传 %{smart_count} 个文件',
uploadXNewFiles: '上传 +%{smart_count} 个文件',
xFilesSelected: '已选择 %{smart_count} 个文件',
xMoreFilesAdded: '又添加了 %{smart_count} 个文件',
xTimeLeft: '剩余 %{time}',
youCanOnlyUploadFileTypes: '您只能上传这些文件类型:%{types}',
youCanOnlyUploadFileTypes: '您只能上传%{types}',
youCanOnlyUploadX: '您只能上传 %{smart_count} 个文件',
youHaveToAtLeastSelectX: '您至少要选择 %{smart_count} 个文件',
zoomIn: '放大',
zoomOut: '缩小',
}
// @ts-ignore untyped

View file

@ -14,6 +14,8 @@ zh_TW.strings = {
additionalRestrictionsFailed: '尚未滿足 %{count} 個額外限制',
addMore: '新增更多',
addMoreFiles: '新增更多檔案',
aggregateExceedsSize:
'您選擇了 %{size} 的檔案,但最大允許大小為 %{sizeAllowed}',
allFilesFromFolderNamed: '資料夾 %{name} 中的所有檔案',
allowAccessDescription: '請允許本網站存取相機以拍照或錄影。',
allowAccessTitle: '請允許對相機的存取權限',
@ -23,6 +25,7 @@ zh_TW.strings = {
aspectRatioPortrait: '裁切成直向 (9:16)',
aspectRatioSquare: '裁切成方形',
authAborted: '驗證已中止',
authenticate: '連接',
authenticateWith: '連接到 %{pluginName}',
authenticateWithTitle: '請使用 %{pluginName} 進行身份驗證以選擇檔案',
back: '返回',
@ -46,8 +49,11 @@ zh_TW.strings = {
dashboardTitle: 'Uppy 儀表板',
dashboardWindowTitle: 'Uppy 儀表板視窗(按下 Esc 以關閉)',
dataUploadedOfTotal: '%{complete}/%{total}',
dataUploadedOfUnknown: '%{complete} / 未知大小',
discardMediaFile: '捨棄媒體檔案',
discardRecordedFile: '捨棄錄影檔',
done: '完成',
dropHereOr: '將檔案拖曳到這裡或者%{browse}',
dropHint: '將檔案拖曳至此處',
dropPasteBoth: '將檔案拖曳至此處、%{browseFiles},或%{browseFolders}',
dropPasteFiles: '將檔案拖曳至此處,或%{browseFiles}',
@ -79,7 +85,9 @@ zh_TW.strings = {
importFiles: '檔案匯入來源:',
importFrom: '從 %{name} 匯入',
inferiorSize: '此檔案小於允許的大小:%{size}',
loadedXFiles: '已載入 %{numFiles} 個檔案',
loading: '正在載入...',
logIn: '登入',
logOut: '登出',
micDisabled: '使用者拒絕麥克風存取權限',
missingRequiredMetaField: '遺漏中繼資料必要欄位',
@ -99,15 +107,14 @@ zh_TW.strings = {
pause: '暫停',
paused: '已暫停',
pauseUpload: '暫停上傳',
pickFiles: '選擇檔案',
pickPhotos: '選擇照片',
pleaseWait: '請稍候',
pluginNameAudio: '音訊',
pluginNameBox: 'Box',
pluginNameCamera: '相機',
pluginNameDropbox: 'Dropbox',
pluginNameFacebook: 'Facebook',
pluginNameGoogleDrive: 'Google 雲端硬碟',
pluginNameInstagram: 'Instagram',
pluginNameOneDrive: 'OneDrive',
pluginNameZoom: 'Zoom',
pluginNameScreenCapture: '螢幕錄製',
pluginNameUrl: '連結',
pluginWebdavInputLabel: 'WebDAV 檔案 URL例如來自 ownCloud 或 Nextcloud',
poweredBy: '技術提供者 %{uppy}',
processingXFiles: '正在處理 %{smart_count} 個檔案',
recording: '正在錄製',
@ -147,7 +154,9 @@ zh_TW.strings = {
submitRecordedFile: '提交錄影檔',
takePicture: '拍照',
takePictureBtn: '拍照',
takeScreenshot: '螢幕截圖',
timedOut: '上傳已停滯 %{seconds} 秒,正在中止上傳。',
unnamed: '未命名',
upload: '上傳',
uploadComplete: '上傳完成',
uploadFailed: '上傳失敗',

View file

@ -122,3 +122,9 @@ export default class OneDrive<M extends Meta, B extends Body>
return this.view.render(state)
}
}
declare module '@uppy/core' {
export interface PluginTypeRegistry<M extends Meta, B extends Body> {
OneDrive: OneDrive<M, B>
}
}

View file

@ -110,6 +110,10 @@ type RenderOpts<M extends Meta, B extends Body> = Omit<
export default class ProviderView<M extends Meta, B extends Body> {
static VERSION = packageJson.version
// Test hook (mirrors GoldenRetriever pattern): allow tests to override debounce time
// @ts-expect-error test-only hook key
static [Symbol.for('uppy test: searchDebounceMs')]: number | undefined
plugin: UnknownProviderPlugin<M, B>
provider: UnknownProviderPlugin<M, B>['provider']
@ -119,6 +123,7 @@ export default class ProviderView<M extends Meta, B extends Body> {
isHandlingScroll: boolean = false
previousCheckbox: string | null = null
#searchDebounced: () => void
constructor(plugin: UnknownProviderPlugin<M, B>, opts: PassedOpts<M, B>) {
this.plugin = plugin
@ -157,6 +162,16 @@ export default class ProviderView<M extends Meta, B extends Body> {
this.provider.provider,
this.provider,
)
// Configure debounced search with test override
const testHookSymbol = Symbol.for('uppy test: searchDebounceMs')
const testWait = (
ProviderView as unknown as Record<symbol, number | undefined>
)[testHookSymbol]
const wait = testWait ?? 500
const debounceOpts =
testWait === 0 ? { leading: true, trailing: true } : undefined
this.#searchDebounced = debounce(this.#search, wait, debounceOpts)
}
resetPluginState(): void {
@ -333,11 +348,13 @@ export default class ProviderView<M extends Meta, B extends Body> {
this.setLoading(false)
}
#searchDebounced = debounce(this.#search, 500)
// debounced search function is initialized in the constructor
onSearchInput = (s: string): void => {
this.plugin.setPluginState({ searchString: s })
if (this.opts.supportsSearch) this.#searchDebounced()
if (this.opts.supportsSearch) {
this.#searchDebounced()
}
}
async openSearchResultFolder(folderId: PartialTreeId): Promise<void> {

View file

@ -82,7 +82,7 @@ class DashboardModal<M extends Meta, B extends Body> extends Component<
uppy.use(DashboardPlugin<M, B>, options)
this.plugin = uppy.getPlugin(options.id) as DashboardPlugin<M, B>
this.plugin = uppy.getPlugin(options.id)!
if (open) {
this.plugin.openModal()
}

View file

@ -122,3 +122,9 @@ export default class RemoteSources<
this.#installedPlugins.clear()
}
}
declare module '@uppy/core' {
export interface PluginTypeRegistry<M extends Meta, B extends Body> {
RemoteSources: RemoteSources<M, B>
}
}

View file

@ -14,6 +14,12 @@ import locale from './locale.js'
import RecorderScreen from './RecorderScreen.js'
import ScreenRecIcon from './ScreenRecIcon.js'
declare module '@uppy/core' {
export interface PluginTypeRegistry<M extends Meta, B extends Body> {
ScreenCapture: ScreenCapture<M, B>
}
}
// Check if screen capturing is supported.
// mediaDevices is supprted on mobile Safari, getDisplayMedia is not
function isScreenRecordingSupported() {

View file

@ -15,6 +15,12 @@ import type { StatusBarOptions } from './StatusBarOptions.js'
import statusBarStates from './StatusBarStates.js'
import StatusBarUI, { type StatusBarUIProps } from './StatusBarUI.js'
declare module '@uppy/core' {
export interface PluginTypeRegistry<M extends Meta, B extends Body> {
StatusBar: StatusBar<M, B>
}
}
const speedFilterHalfLife = 2000
const ETAFilterHalfLife = 2000

View file

@ -23,7 +23,7 @@ const installPlugin = () => {
} satisfies DashboardOptions<M, B>;
uppy.use(DashboardPlugin<M, B>, options);
plugin = uppy.getPlugin(options.id) as DashboardPlugin<M, B>;
plugin = uppy.getPlugin(options.id)!;
};
const uninstallPlugin = (uppyInstance: Uppy<M, B> = uppy) => {
if (plugin != null) uppyInstance.removePlugin(plugin);

View file

@ -25,7 +25,7 @@ const installPlugin = () => {
} satisfies DashboardOptions<M, B>;
uppy.use(DashboardPlugin<M, B>, options);
plugin = uppy.getPlugin(options.id) as DashboardPlugin<M, B>;
plugin = uppy.getPlugin(options.id)!;
if (open) plugin.openModal();
};
const uninstallPlugin = (uppyInstance: Uppy<M, B> = uppy) => {

View file

@ -20,7 +20,7 @@ const installPlugin = () => {
} satisfies StatusBarOptions;
uppy.use(StatusBarPlugin<M, B>, options);
plugin = uppy.getPlugin(options.id) as StatusBarPlugin<M, B>;
plugin = uppy.getPlugin(options.id)!;
};
const uninstallPlugin = (uppyInstance: Uppy<M, B> = uppy) => {
if (plugin != null) uppyInstance.removePlugin(plugin);

View file

@ -15,6 +15,9 @@ declare module '@uppy/core' {
'thumbnail:request': (file: UppyFile<M, B>) => void
'thumbnail:cancel': (file: UppyFile<M, B>) => void
}
export interface PluginTypeRegistry<M extends Meta, B extends Body> {
ThumbnailGenerator: ThumbnailGenerator<M, B>
}
}
interface Rotation {
@ -500,3 +503,9 @@ export default class ThumbnailGenerator<
}
}
}
declare module '@uppy/core' {
export interface PluginTypeRegistry<M extends Meta, B extends Body> {
ThumbnailGenerator: ThumbnailGenerator<M, B>
}
}

View file

@ -132,6 +132,10 @@ declare module '@uppy/core' {
progress_combined?: number
}) => void
}
export interface PluginTypeRegistry<M extends Meta, B extends Body> {
Transloadit: Transloadit<M, B>
}
}
declare module '@uppy/utils' {

View file

@ -10,7 +10,7 @@ import type {
import { BasePlugin, EventManager } from '@uppy/core'
import {
filterFilesToEmitUploadStarted,
filterNonFailedFiles,
filterFilesToUpload,
getAllowedMetaFields,
hasProperty,
isNetworkError,
@ -106,6 +106,12 @@ declare module '@uppy/utils' {
}
}
declare module '@uppy/core' {
export interface PluginTypeRegistry<M extends Meta, B extends Body> {
Tus: Tus<M, B>
}
}
/**
* Tus resumable file uploader
*/
@ -557,7 +563,7 @@ export default class Tus<M extends Meta, B extends Body> extends BasePlugin<
}
async #uploadFiles(files: UppyFile<M, B>[]) {
const filesFiltered = filterNonFailedFiles(files)
const filesFiltered = filterFilesToUpload(files)
const filesToEmit = filterFilesToEmitUploadStarted(filesFiltered)
this.uppy.emit('upload-start', filesToEmit)

View file

@ -112,3 +112,9 @@ export default class Unsplash<M extends Meta, B extends Body>
this.unmount()
}
}
declare module '@uppy/core' {
export interface PluginTypeRegistry<M extends Meta, B extends Body> {
Unsplash: Unsplash<M, B>
}
}

View file

@ -17,6 +17,12 @@ import locale from './locale.js'
import UrlUI from './UrlUI.js'
import forEachDroppedOrPastedUrl from './utils/forEachDroppedOrPastedUrl.js'
declare module '@uppy/core' {
export interface PluginTypeRegistry<M extends Meta, B extends Body> {
Url: Url<M, B>
}
}
function UrlIcon() {
return (
<svg

View file

@ -1,12 +1,17 @@
import type { UppyFile } from './UppyFile.js'
export function filterNonFailedFiles(
const hasError = (file: UppyFile<any, any>) => 'error' in file && !!file.error
// We don't need to re-upload already successfully uploaded files
// so let's exclude them here:
// https://github.com/transloadit/uppy/issues/5930
// This happens for example when restoring a partially finished session (e.g. using golden retriever).
const isCompleted = (file: UppyFile<any, any>) => file.progress.uploadComplete
export function filterFilesToUpload(
files: UppyFile<any, any>[],
): UppyFile<any, any>[] {
const hasError = (file: UppyFile<any, any>): boolean =>
'error' in file && !!file.error
return files.filter((file) => !hasError(file))
return files.filter((file) => !hasError(file) && !isCompleted(file))
}
// Don't double-emit upload-started for Golden Retriever-restored files that were already started

View file

@ -37,7 +37,7 @@ export { default as fetchWithNetworkError } from './fetchWithNetworkError.js'
export {
filterFilesToEmitUploadStarted,
filterNonFailedFiles,
filterFilesToUpload,
} from './fileFilters.js'
export { default as findAllDOMElements } from './findAllDOMElements.js'

View file

@ -36,7 +36,7 @@ export default defineComponent({
target: containerRef.value,
}
uppy.use(DashboardPlugin, options)
pluginRef.value = uppy.getPlugin(options.id) as DashboardPlugin<any, any>
pluginRef.value = uppy.getPlugin(options.id)!
}
useUppy(onMount, pluginRef, props.uppy, propsRef)

View file

@ -32,7 +32,7 @@ export default defineComponent({
target: containerRef.value,
}
uppy.use(DashboardPlugin, options)
pluginRef.value = uppy.getPlugin(options.id) as DashboardPlugin<any, any>
pluginRef.value = uppy.getPlugin(options.id)!
}
useUppy(onMount, pluginRef, props.uppy, propsRef)

View file

@ -26,7 +26,7 @@ export default defineComponent({
target: containerRef.value,
}
uppy.use(StatusBarPlugin, options)
pluginRef.value = uppy.getPlugin(options.id) as StatusBarPlugin<any, any>
pluginRef.value = uppy.getPlugin(options.id)!
}
useUppy(onMount, pluginRef, props.uppy, propsRef)

View file

@ -1,4 +1,4 @@
import Uppy from '@uppy/core'
import { Uppy } from '@uppy/core'
import { describe, expect, it } from 'vitest'
import Webcam from './index.js'
@ -10,10 +10,9 @@ describe('Webcam', () => {
isTypeSupported: () => true,
}
const uppy = new Uppy<any, any>().use(Webcam)
const uppy = new Uppy().use(Webcam)
expect(
(uppy.getPlugin('Webcam') as Webcam<any, any>).getMediaRecorderOptions()
.mimeType,
uppy.getPlugin('Webcam')?.getMediaRecorderOptions().mimeType,
).not.toBeDefined()
})
@ -27,8 +26,7 @@ describe('Webcam', () => {
preferredVideoMimeType: 'video/webm',
})
expect(
(uppy.getPlugin('Webcam') as Webcam<any, any>).getMediaRecorderOptions()
.mimeType,
uppy.getPlugin('Webcam')?.getMediaRecorderOptions().mimeType,
).toEqual('video/webm')
})
@ -42,8 +40,7 @@ describe('Webcam', () => {
preferredVideoMimeType: 'video/mp4',
})
expect(
(uppy.getPlugin('Webcam') as Webcam<any, any>).getMediaRecorderOptions()
.mimeType,
uppy.getPlugin('Webcam')?.getMediaRecorderOptions().mimeType,
).not.toBeDefined()
})
@ -57,8 +54,7 @@ describe('Webcam', () => {
restrictions: { allowedFileTypes: ['video/mp4', 'video/webm'] },
}).use(Webcam)
expect(
(uppy.getPlugin('Webcam') as Webcam<any, any>).getMediaRecorderOptions()
.mimeType,
uppy.getPlugin('Webcam')?.getMediaRecorderOptions().mimeType,
).toEqual('video/mp4')
})
@ -72,8 +68,7 @@ describe('Webcam', () => {
restrictions: { allowedFileTypes: ['video/mp4', 'video/webm'] },
}).use(Webcam)
expect(
(uppy.getPlugin('Webcam') as Webcam<any, any>).getMediaRecorderOptions()
.mimeType,
uppy.getPlugin('Webcam')?.getMediaRecorderOptions().mimeType,
).toEqual('video/webm')
})
@ -89,8 +84,7 @@ describe('Webcam', () => {
preferredVideoMimeType: 'video/webm',
})
expect(
(uppy.getPlugin('Webcam') as Webcam<any, any>).getMediaRecorderOptions()
.mimeType,
uppy.getPlugin('Webcam')?.getMediaRecorderOptions().mimeType,
).toEqual('video/webm')
})
@ -104,8 +98,7 @@ describe('Webcam', () => {
restrictions: { allowedFileTypes: ['video/mp4', 'video/webm'] },
}).use(Webcam)
expect(
(uppy.getPlugin('Webcam') as Webcam<any, any>).getMediaRecorderOptions()
.mimeType,
uppy.getPlugin('Webcam')?.getMediaRecorderOptions().mimeType,
).toEqual(undefined)
})
})

View file

@ -20,6 +20,12 @@ import locale from './locale.js'
import PermissionsScreen from './PermissionsScreen.js'
import supportsMediaRecorder from './supportsMediaRecorder.js'
declare module '@uppy/core' {
export interface PluginTypeRegistry<M extends Meta, B extends Body> {
Webcam: Webcam<M, B>
}
}
/**
* Normalize a MIME type or file extension into a MIME type.
*

View file

@ -160,3 +160,9 @@ export default class Webdav<M extends Meta, B extends Body>
return this.view.render(state)
}
}
declare module '@uppy/core' {
export interface PluginTypeRegistry<M extends Meta, B extends Body> {
WebDav: Webdav<M, B>
}
}

View file

@ -13,7 +13,7 @@ import {
type FetcherOptions,
fetcher,
filterFilesToEmitUploadStarted,
filterNonFailedFiles,
filterFilesToUpload,
getAllowedMetaFields,
internalRateLimitedQueue,
isNetworkError,
@ -86,6 +86,12 @@ declare module '@uppy/core' {
}
}
declare module '@uppy/core' {
export interface PluginTypeRegistry<M extends Meta, B extends Body> {
XHRUpload: XHRUpload<M, B>
}
}
function buildResponseError(
xhr?: XMLHttpRequest,
err?: string | Error | NetworkError,
@ -529,7 +535,7 @@ export default class XHRUpload<
this.uppy.log('[XHRUpload] Uploading...')
const files = this.uppy.getFilesByIds(fileIDs)
const filesFiltered = filterNonFailedFiles(files)
const filesFiltered = filterFilesToUpload(files)
const filesToEmit = filterFilesToEmitUploadStarted(filesFiltered)
this.uppy.emit('upload-start', filesToEmit)

View file

@ -107,3 +107,9 @@ export default class Zoom<M extends Meta, B extends Body>
return this.view.render(state)
}
}
declare module '@uppy/core' {
export interface PluginTypeRegistry<M extends Meta, B extends Body> {
Zoom: Zoom<M, B>
}
}

View file

@ -10907,6 +10907,7 @@ __metadata:
dependencies:
"@transloadit/prettier-bytes": "npm:^0.3.4"
"@uppy/core": "workspace:^"
"@uppy/dropbox": "workspace:^"
"@uppy/google-drive": "workspace:^"
"@uppy/provider-views": "workspace:^"
"@uppy/thumbnail-generator": "workspace:^"