diff --git a/l10n/messages.pot b/l10n/messages.pot index 8f83149482..b57e92c9ee 100644 --- a/l10n/messages.pot +++ b/l10n/messages.pot @@ -381,6 +381,15 @@ msgstr "" msgid "Pick an emoji" msgstr "" +msgid "Pick file" +msgstr "" + +msgid "Pick files" +msgstr "" + +msgid "Pick folder" +msgstr "" + msgid "Please choose a date" msgstr "" @@ -549,6 +558,18 @@ msgstr "" msgid "Undo changes" msgstr "" +msgid "Upload file" +msgstr "" + +msgid "Upload files" +msgstr "" + +msgid "Upload folder" +msgstr "" + +msgid "Uploading …" +msgstr "" + msgid "User status: {status}" msgstr "" diff --git a/src/components/NcFilePicker/NcFilePicker.vue b/src/components/NcFilePicker/NcFilePicker.vue new file mode 100644 index 0000000000..3bf1a0dfef --- /dev/null +++ b/src/components/NcFilePicker/NcFilePicker.vue @@ -0,0 +1,331 @@ + + + + + + + + + +This component allows to pick local files (or directories) which can be used to upload them to Nextcloud or directly process them in the browser. + +### Exposed methods + +- `function reset(): void` + This method allows to reset the internal state of the file picker to clear the current selection + +### Example + +```vue + + + +``` + diff --git a/src/components/NcFilePicker/index.ts b/src/components/NcFilePicker/index.ts new file mode 100644 index 0000000000..378c1ea178 --- /dev/null +++ b/src/components/NcFilePicker/index.ts @@ -0,0 +1,11 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +export type { + FilePickerItem, + FilePickerItemGroup, +} from './NcFilePicker.vue' + +export { default } from './NcFilePicker.vue' diff --git a/src/components/index.ts b/src/components/index.ts index 086153520d..284e5e29ca 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -59,6 +59,7 @@ export { default as NcDialogButton } from './NcDialogButton/index.ts' export { default as NcEllipsisedOption } from './NcEllipsisedOption/index.js' export { default as NcEmojiPicker } from './NcEmojiPicker/index.js' export { default as NcEmptyContent } from './NcEmptyContent/index.ts' +export { default as NcFilePicker } from './NcFilePicker/index.ts' export { default as NcFormBox } from './NcFormBox/index.ts' export { default as NcFormBoxButton } from './NcFormBoxButton/index.ts' export { default as NcFormBoxCopyButton } from './NcFormBoxCopyButton/index.ts' diff --git a/tests/component/components/NcFilePicker/NcFilePicker.spec.ts b/tests/component/components/NcFilePicker/NcFilePicker.spec.ts new file mode 100644 index 0000000000..fd1bce9a28 --- /dev/null +++ b/tests/component/components/NcFilePicker/NcFilePicker.spec.ts @@ -0,0 +1,201 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '@playwright/experimental-ct-vue' +import { join } from 'node:path' +import NcFilePicker from '../../../../src/components/NcFilePicker/NcFilePicker.vue' +import NcFilePickerEmitStory from './NcFilePickerEmit.story.vue' + +test('mounting with default props', async ({ mount, page }) => { + await mount(NcFilePicker, { + props: {}, + }) + + const button = page.getByRole('button', { name: 'Pick file' }) + await expect(button).toBeVisible() + // check that the input is hidden + const input = page.locator('input[type="file"]') + await expect(input).toHaveAttribute('aria-hidden', 'true') +}) + +test('setting the variant prop', { tag: '@visual' }, async ({ mount, page, browserName }) => { + if (browserName !== 'chromium') { + test.skip() + } + + const component = await mount(NcFilePicker, { + props: {}, + }) + + const button = page.getByRole('button', { name: 'Pick file' }) + await expect(button).toHaveScreenshot() + + await component.update({ props: { variant: 'primary' } }) + await expect(button).toHaveScreenshot() + + await component.update({ props: { variant: 'secondary' } }) + await expect(button).toHaveScreenshot() + + await component.update({ props: { variant: 'tertiary' } }) + await expect(button).toHaveScreenshot() +}) + +test('label is adjusted to match requested mode', async ({ mount, page }) => { + const component = await mount(NcFilePicker, { + props: {}, + }) + + await expect(page.getByRole('button', { name: 'Pick file' })).toBeVisible() + + await component.update({ props: { multiple: true } }) + await expect(page.getByRole('button', { name: 'Pick files' })).toBeVisible() + + await component.update({ props: { directory: true, multiple: false } }) + await expect(page.getByRole('button', { name: 'Pick file' })).toBeVisible() + + await component.update({ props: { directory: true, multiple: true } }) + await expect(page.getByRole('button', { name: 'Pick files' })).toBeVisible() + + await component.update({ props: { directory: true, directoryOnly: true, multiple: false } }) + await expect(page.getByRole('button', { name: 'Pick folder' })).toBeVisible() + + await component.update({ props: { directory: true, directoryOnly: true, multiple: true } }) + await expect(page.getByRole('button', { name: 'Pick folder' })).toBeVisible() // ignored because browsers only support single folder picking +}) + +test('can override labels', async ({ mount, page }) => { + const label = 'Custom label' + const component = await mount(NcFilePicker, { + props: { + label, + }, + }) + + await expect(page.getByRole('button', { name: label })).toBeVisible() + + await component.update({ props: { label, multiple: true } }) + await expect(page.getByRole('button', { name: label })).toBeVisible() + + await component.update({ props: { label, directory: true } }) + await expect(page.getByRole('button', { name: label })).toBeVisible() + + await component.update({ props: { label, directory: true, multiple: true } }) + await expect(page.getByRole('button', { name: label })).toBeVisible() + + await component.update({ props: { label, directory: true, directoryOnly: true } }) + await expect(page.getByRole('button', { name: label })).toBeVisible() +}) + +test('Shows the menu when picking directories is allowed', async ({ mount, page }) => { + await mount(NcFilePicker, { + props: { + directory: true, + }, + }) + + const button = page.getByRole('button', { name: 'Pick file' }) + await expect(button).toBeVisible() + await button.click() + + await expect(page.getByRole('menu', { name: 'Pick file' })).toBeVisible() + await expect(page.getByRole('menuitem', { name: 'Upload file' })).toBeVisible() + await expect(page.getByRole('menuitem', { name: 'Upload folder' })).toBeVisible() +}) + +test.describe('file picking', () => { + test.skip(({ browserName }) => browserName === 'webkit', 'WebKit does not support file pickers in Playwright yet') + + test('picking a single file', async ({ mount, page }) => { + page.on('filechooser', async (fileChooser) => { + expect(fileChooser.isMultiple()).toBe(false) + await fileChooser.setFiles({ name: 'testfile.txt', mimeType: 'text/plain', buffer: Buffer.from('Hello World') }) + }) + + const { promise, resolve } = Promise.withResolvers() + await mount(NcFilePickerEmitStory, { // we need this story as PlayWright can only proxy simple structures like strings but not complex ones like File objects + props: {}, + on: { + pick: resolve, + }, + }) + + const button = page.getByRole('button', { name: 'Pick file' }) + await button.click() + + expect(await promise).toEqual(['testfile.txt']) + }) + + test('picking multiple files', async ({ mount, page }) => { + page.on('filechooser', async (fileChooser) => { + expect(fileChooser.isMultiple()).toBe(true) + await fileChooser.setFiles([ + { name: 'a.txt', mimeType: 'text/plain', buffer: Buffer.from('Hello') }, + { name: 'b.txt', mimeType: 'text/plain', buffer: Buffer.from('Hallo') }, + { name: 'c.txt', mimeType: 'text/plain', buffer: Buffer.from('Hola') }, + ]) + }) + + const { promise, resolve } = Promise.withResolvers() + await mount(NcFilePickerEmitStory, { + props: { + multiple: true, + }, + on: { + pick: resolve, + }, + }) + + const button = page.getByRole('button', { name: 'Pick files' }) + await button.click() + + expect(await promise).toEqual(['a.txt', 'b.txt', 'c.txt']) + }) + + test('picking a directory', async ({ mount, page }) => { + page.on('filechooser', async (fileChooser) => { + expect(fileChooser.isMultiple()).toBe(false) + await fileChooser.setFiles(join(import.meta.dirname, 'folder')) + }) + + const { promise, resolve } = Promise.withResolvers() + await mount(NcFilePickerEmitStory, { + props: { + directory: true, + directoryOnly: true, + }, + on: { + pick: resolve, + }, + }) + + await page.getByRole('button', { name: 'Pick folder' }) + .click() + expect(await promise).toEqual(['folder/.gitkeep']) + }) + + test('picking a directory when having the menu', async ({ mount, page }) => { + page.on('filechooser', async (fileChooser) => { + expect(fileChooser.isMultiple()).toBe(false) + await fileChooser.setFiles(join(import.meta.dirname, 'folder')) + }) + + const { promise, resolve } = Promise.withResolvers() + await mount(NcFilePickerEmitStory, { + props: { + directory: true, + }, + on: { + pick: resolve, + }, + }) + + await page.getByRole('button', { name: 'Pick file' }) + .click() + await page.getByRole('menuitem', { name: 'Upload folder' }) + .click() + + expect(await promise).toEqual(['folder/.gitkeep']) + }) +}) diff --git a/tests/component/components/NcFilePicker/NcFilePickerEmit.story.vue b/tests/component/components/NcFilePicker/NcFilePickerEmit.story.vue new file mode 100644 index 0000000000..a50147210f --- /dev/null +++ b/tests/component/components/NcFilePicker/NcFilePickerEmit.story.vue @@ -0,0 +1,23 @@ + + + + + diff --git a/tests/component/components/NcFilePicker/folder/.gitkeep b/tests/component/components/NcFilePicker/folder/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component/snapshots/components/NcFilePicker/NcFilePicker.spec.ts-snapshots/mounting-with-default-props-1-chromium-linux.png b/tests/component/snapshots/components/NcFilePicker/NcFilePicker.spec.ts-snapshots/mounting-with-default-props-1-chromium-linux.png new file mode 100644 index 0000000000..75e148f399 Binary files /dev/null and b/tests/component/snapshots/components/NcFilePicker/NcFilePicker.spec.ts-snapshots/mounting-with-default-props-1-chromium-linux.png differ diff --git a/tests/component/snapshots/components/NcFilePicker/NcFilePicker.spec.ts-snapshots/setting-the-variant-prop-1-chromium-linux.png b/tests/component/snapshots/components/NcFilePicker/NcFilePicker.spec.ts-snapshots/setting-the-variant-prop-1-chromium-linux.png new file mode 100644 index 0000000000..75e148f399 Binary files /dev/null and b/tests/component/snapshots/components/NcFilePicker/NcFilePicker.spec.ts-snapshots/setting-the-variant-prop-1-chromium-linux.png differ diff --git a/tests/component/snapshots/components/NcFilePicker/NcFilePicker.spec.ts-snapshots/setting-the-variant-prop-2-chromium-linux.png b/tests/component/snapshots/components/NcFilePicker/NcFilePicker.spec.ts-snapshots/setting-the-variant-prop-2-chromium-linux.png new file mode 100644 index 0000000000..75e148f399 Binary files /dev/null and b/tests/component/snapshots/components/NcFilePicker/NcFilePicker.spec.ts-snapshots/setting-the-variant-prop-2-chromium-linux.png differ diff --git a/tests/component/snapshots/components/NcFilePicker/NcFilePicker.spec.ts-snapshots/setting-the-variant-prop-3-chromium-linux.png b/tests/component/snapshots/components/NcFilePicker/NcFilePicker.spec.ts-snapshots/setting-the-variant-prop-3-chromium-linux.png new file mode 100644 index 0000000000..859b4eda1f Binary files /dev/null and b/tests/component/snapshots/components/NcFilePicker/NcFilePicker.spec.ts-snapshots/setting-the-variant-prop-3-chromium-linux.png differ diff --git a/tests/component/snapshots/components/NcFilePicker/NcFilePicker.spec.ts-snapshots/setting-the-variant-prop-4-chromium-linux.png b/tests/component/snapshots/components/NcFilePicker/NcFilePicker.spec.ts-snapshots/setting-the-variant-prop-4-chromium-linux.png new file mode 100644 index 0000000000..8b8e38003b Binary files /dev/null and b/tests/component/snapshots/components/NcFilePicker/NcFilePicker.spec.ts-snapshots/setting-the-variant-prop-4-chromium-linux.png differ