Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Allow to upload directories and allow bulk upload #1175

Merged
merged 4 commits into from
May 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions __tests__/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Missing is jsdom, ref: https://github.com/jsdom/jsdom/issues/2555
import 'blob-polyfill'
167 changes: 167 additions & 0 deletions __tests__/utils/fileTree.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { describe, expect, it } from 'vitest'
import { Directory } from '../../lib/utils/fileTree.ts'

describe('file tree utils', () => {
it('Can create a directory', () => {
const dir = new Directory('base')
// expected no exception
expect(dir.name).toBe(dir.webkitRelativePath)
expect(dir.name).toBe('base')
})

it('Can create a virtual root directory', () => {
const dir = new Directory('')
// expected no exception
expect(dir.name).toBe(dir.webkitRelativePath)
expect(dir.name).toBe('')
})

it('Can create a nested directory', () => {
const dir = new Directory('base/name')
// expected no exception
expect(dir.name).toBe('name')
expect(dir.webkitRelativePath).toBe('base/name')
})

it('Can create a directory with content', () => {
const dir = new Directory('base', [new File(['a'.repeat(1024)], 'my-file')])
// expected no exception
expect(dir.name).toBe('base')
expect(dir.children).toHaveLength(1)
expect(dir.children[0].name).toBe('my-file')
})

it('Can create a virtual root with content', async () => {
const dir = new Directory('', [new File(['I am bar.txt'], 'a/bar.txt')])
expect(dir.name).toBe('')

expect(dir.children).toHaveLength(1)
expect(dir.children[0]).toBeInstanceOf(Directory)
expect(dir.children[0].name).toBe('a')
expect(dir.children[0].webkitRelativePath).toBe('a')

const dirA = dir.children[0] as Directory
expect(dirA.children).toHaveLength(1)
expect(await dirA.children[0].text()).toBe('I am bar.txt')
})

it('Reads the size from the content', () => {
const dir = new Directory('base', [new File(['a'.repeat(1024)], 'my-file')])
// expected no exception
expect(dir.children).toHaveLength(1)
expect(dir.size).toBe(1024)
})

it('Reads the lastModified from the content', () => {
const dir = new Directory('base', [new File(['a'.repeat(1024)], 'my-file', { lastModified: 999 })])
// expected no exception
expect(dir.children).toHaveLength(1)
expect(dir.lastModified).toBe(999)
})

it('Keeps its orginal name', () => {
// The conflict picker will force-overwrite the name attribute so we emulate this
const dir = new Directory('base')
expect(dir.name).toBe('base')
expect(dir.originalName).toBe('base')

Object.defineProperty(dir, 'name', { value: 'other-base' })
expect(dir.name).toBe('other-base')
expect(dir.originalName).toBe('base')
})

it('can add a child', async () => {
const dir = new Directory('base')
expect(dir.children).toHaveLength(0)

await dir.addChild(new File(['a'.repeat(1024)], 'my-file'))
expect(dir.children).toHaveLength(1)
expect(dir.children[0].name).toBe('my-file')
})

it('can add a child to existing ones', async () => {
const dir = new Directory('base', [new File(['a'.repeat(1024)], 'my-file')])
expect(dir.children).toHaveLength(1)

await dir.addChild(new File(['a'.repeat(1024)], 'my-other-file'))
expect(dir.children).toHaveLength(2)
expect(dir.children[0].name).toBe('my-file')
expect(dir.children[1].name).toBe('my-other-file')
})

it('Can detect invalid children added', async () => {
const dir = new Directory('base/valid')

await expect(() => dir.addChild(new File([], 'base/invalid/foo.txt'))).rejects.toThrowError(/File .+ is not a child of .+/)
})

it('Can get a child', async () => {
const dir = new Directory('base', [new File([], 'base/file')])

expect(dir.getChild('file')).toBeInstanceOf(File)
})

it('returns null if child is not found', async () => {
const dir = new Directory('base/valid')

expect(dir.getChild('foo')).toBeNull()
})

it('Can add nested children', async () => {
const dir = new Directory('a')
dir.addChild(new File(['I am file D'], 'a/b/c/d.txt'))

expect(dir.children).toHaveLength(1)
expect(dir.children[0]).toBeInstanceOf(Directory)
const dirB = dir.children[0] as Directory
expect(dirB.webkitRelativePath).toBe('a/b')
expect(dirB.name).toBe('b')

expect(dirB.children).toHaveLength(1)
expect(dirB.children[0]).toBeInstanceOf(Directory)
const dirC = dirB.children[0] as Directory
expect(dirC.name).toBe('c')
expect(dirC.webkitRelativePath).toBe('a/b/c')

expect(dirC.children).toHaveLength(1)
expect(await dirC.children[0].text()).toBe('I am file D')
})

it('Can add to existing nested children', async () => {
// First we start like the "can add nested" test
const dir = new Directory('a')
dir.addChild(new File(['I am file D'], 'a/b/c/d.txt'))

// But now we add a second file
dir.addChild(new File(['I am file E'], 'a/b/e.txt'))

expect(dir.children).toHaveLength(1)
expect(dir.children[0]).toBeInstanceOf(Directory)
const dirB = dir.children[0] as Directory
expect(dirB.webkitRelativePath).toBe('a/b')
expect(dirB.name).toBe('b')

expect(dirB.children).toHaveLength(2)
expect(dirB.getChild('c')).toBeInstanceOf(Directory)
expect(dirB.getChild('e.txt')).toBeInstanceOf(File)
expect(await dirB.getChild('e.txt')!.text()).toBe('I am file E')
})

it('updates the stats when adding new children', async () => {
const dir = new Directory('base')

expect(dir.size).toBe(0)

await dir.addChild(new File(['a'.repeat(1024)], 'my-file', { lastModified: 999 }))
expect(dir.size).toBe(1024)
expect(dir.lastModified).toBe(999)

await dir.addChild(new File(['a'.repeat(1024)], 'my-other-file', { lastModified: 8888 }))
expect(dir.size).toBe(2048)
expect(dir.lastModified).toBe(8888)

await dir.addChild(new File(['a'.repeat(1024)], 'my-older-file', { lastModified: 500 }))
expect(dir.size).toBe(3072)
expect(dir.lastModified).toBe(8888)
})
})
82 changes: 81 additions & 1 deletion cypress/components/UploadPicker.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { Folder, Permission, addNewFileMenuEntry, type Entry } from '@nextcloud/files'
import { generateRemoteUrl } from '@nextcloud/router'
import { UploadPicker, getUploader } from '../../lib/index.ts'
import { basename } from 'path'

describe('UploadPicker rendering', () => {
afterEach(() => {
Expand Down Expand Up @@ -106,6 +107,10 @@ describe('UploadPicker valid uploads', () => {

describe('UploadPicker invalid uploads', () => {

// Cypress shares the module state between tests, we need to reset it
// ref: https://github.com/cypress-io/cypress/issues/25441
beforeEach(() => getUploader(true))

afterEach(() => {
// Make sure we clear the body
cy.window().then((win) => {
Expand Down Expand Up @@ -171,7 +176,82 @@ describe('UploadPicker invalid uploads', () => {
cy.wait('@upload')
// Should not have been called more than once as the first file is invalid
cy.get('@upload.all').should('have.length', 1)
cy.get('body').should('contain', '"$" is not allowed inside a file name.')
cy.contains('[role="dialog"]', 'Invalid file name')
.should('be.visible')
})

it('Can rename invalid files', () => {
// Make sure we reset the destination
// so other tests do not interfere
const propsData = {
destination: new Folder({
id: 56,
owner: 'user',
source: generateRemoteUrl('dav/files/user'),
permissions: Permission.ALL,
root: '/files/user',
}),
forbiddenCharacters: ['$', '#', '~', '&'],
}

// Mount picker
cy.mount(UploadPicker, { propsData }).as('uploadPicker')

// Label is displayed before upload
cy.get('[data-cy-upload-picker]').contains('New').should('be.visible')

// Check and init aliases
cy.get('[data-cy-upload-picker] [data-cy-upload-picker-input]').as('input').should('exist')
cy.get('[data-cy-upload-picker] .upload-picker__progress').as('progress').should('exist')

// Intercept single upload
cy.intercept('PUT', '/remote.php/dav/files/*/*', (req) => {
req.reply({
statusCode: 201,
delay: 2000,
})
}).as('upload')

// Upload 2 files
cy.get('@input').attachFile({
// Fake file of 5 MB
fileContent: new Blob([new ArrayBuffer(2 * 1024 * 1024)]),
fileName: 'invalid-image$.jpg',
mimeType: 'image/jpeg',
encoding: 'utf8',
lastModified: new Date().getTime(),
})

cy.get('@input').attachFile({
// Fake file of 5 MB
fileContent: new Blob([new ArrayBuffer(2 * 1024 * 1024)]),
fileName: 'valid-image.jpg',
mimeType: 'image/jpeg',
encoding: 'utf8',
lastModified: new Date().getTime(),
})

cy.get('[data-cy-upload-picker] .upload-picker__progress')
.as('progress')
.should('not.be.visible')

cy.contains('[role="dialog"]', 'Invalid file name')
.should('be.visible')
.contains('button', 'Rename')
.click()

cy.wait('@upload')
// Should have been called two times with an valid name now
cy.get('@upload.all').should('have.length', 2).then((array): void => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const requests = (array as unknown as any[]).map(({ request }) => basename(request.url))
// The valid one is included
expect(requests).to.contain('valid-image.jpg')
// The invalid is NOT included
expect(requests).to.not.contain('invalid-image$.jpg')
// The invalid was made valid
expect(requests).to.contain('invalid-image-.jpg')
})
})
})

Expand Down
25 changes: 23 additions & 2 deletions l10n/messages.pot
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ msgid ""
msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"

msgid "\"{filename}\" contains invalid characters, how do you want to continue?"
msgstr ""

msgid "{count} file conflict"
msgid_plural "{count} files conflict"
msgstr[0] ""
Expand Down Expand Up @@ -34,6 +37,9 @@ msgstr ""
msgid "Continue"
msgstr ""

msgid "Create new"
msgstr ""

msgid "estimating time left"
msgstr ""

Expand All @@ -43,6 +49,9 @@ msgstr ""
msgid "If you select both versions, the incoming file will have a number added to its name."
msgstr ""

msgid "Invalid file name"
msgstr ""

msgid "Last modified date unknown"
msgstr ""

Expand All @@ -58,6 +67,9 @@ msgstr ""
msgid "Preview image"
msgstr ""

msgid "Rename"
msgstr ""

msgid "Select all checkboxes"
msgstr ""

Expand All @@ -67,6 +79,9 @@ msgstr ""
msgid "Select all new files"
msgstr ""

msgid "Skip"
msgstr ""

msgid "Skip this file"
msgid_plural "Skip {count} files"
msgstr[0] ""
Expand All @@ -75,10 +90,16 @@ msgstr[1] ""
msgid "Unknown size"
msgstr ""

msgid "Upload cancelled"
msgid "Upload files"
msgstr ""

msgid "Upload files"
msgid "Upload folders"
msgstr ""

msgid "Upload from device"
msgstr ""

msgid "Upload has been cancelled"
msgstr ""

msgid "Upload progress"
Expand Down
Loading
Loading