Skip to content

Commit

Permalink
feat: dropzone should support folder upload (#841)
Browse files Browse the repository at this point in the history
* Feature: dropzone now supports folder upload

* fix: stop requiring a symbol and just import it

* test: add tests for drag and drop

* fix: only support files

---------

Co-authored-by: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com>
  • Loading branch information
loivsen and iOvergaard authored Jul 9, 2024
1 parent 089430a commit dc7594d
Show file tree
Hide file tree
Showing 3 changed files with 155 additions and 52 deletions.
7 changes: 5 additions & 2 deletions packages/uui-file-dropzone/lib/UUIFileDropzoneEvent.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { UUIEvent } from '@umbraco-ui/uui-base/lib/events';
import { UUIFileDropzoneElement } from './uui-file-dropzone.element';
import {
UUIFileDropzoneElement,
UUIFileFolder,
} from './uui-file-dropzone.element';

export class UUIFileDropzoneEvent extends UUIEvent<
{ files: File[] },
{ files: File[]; folders: UUIFileFolder[] },
UUIFileDropzoneElement
> {
public static readonly CHANGE: string = 'change';
Expand Down
147 changes: 99 additions & 48 deletions packages/uui-file-dropzone/lib/uui-file-dropzone.element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@ import { css, html, LitElement } from 'lit';
import { query, property } from 'lit/decorators.js';
import { UUIFileDropzoneEvent } from './UUIFileDropzoneEvent';
import { LabelMixin } from '@umbraco-ui/uui-base/lib/mixins';
import { demandCustomElement } from '@umbraco-ui/uui-base/lib/utils';

import '@umbraco-ui/uui-symbol-file-dropzone/lib';

export interface UUIFileFolder {
folderName: string;
folders: UUIFileFolder[];
files: File[];
}

/**
* @element uui-file-dropzone
Expand Down Expand Up @@ -66,6 +73,13 @@ export class UUIFileDropzoneElement extends LabelMixin('', LitElement) {
return this._accept;
}

@property({
type: Boolean,
reflect: true,
attribute: 'disallow-folder-upload',
})
public disallowFolderUpload: boolean = false;

/**
* Allows for multiple files to be selected.
* @type {boolean}
Expand All @@ -92,64 +106,95 @@ export class UUIFileDropzoneElement extends LabelMixin('', LitElement) {
this.addEventListener('drop', this._onDrop, false);
}

connectedCallback(): void {
super.connectedCallback();
demandCustomElement(this, 'uui-symbol-file-dropzone');
}

private async _getAllFileEntries(
dataTransferItemList: DataTransferItemList,
): Promise<File[]> {
const fileEntries: File[] = [];
private async _getAllEntries(dataTransferItemList: DataTransferItemList) {
// Use BFS to traverse entire directory/file structure
const queue = [...dataTransferItemList];

while (queue.length > 0) {
const entry = queue.shift()!;
const folders: UUIFileFolder[] = [];
const files: File[] = [];

for (const entry of queue) {
if (entry?.kind !== 'file') continue;

if (entry.kind === 'file') {
if (entry.type) {
// Entry is a file
const file = entry.getAsFile();
if (!file) continue;
if (this._isAccepted(file)) {
fileEntries.push(file);
files.push(file);
}
} else if (!this.disallowFolderUpload) {
// Entry is a directory
const dir = this._getEntry(entry);

if (dir) {
const structure = await this._mkdir(dir);
folders.push(structure);
}
} else if (entry.kind === 'directory') {
if ('webkitGetAsEntry' in entry === false) continue;
const directory = entry.webkitGetAsEntry()! as FileSystemDirectoryEntry;
queue.push(
...(await this._readAllDirectoryEntries(directory.createReader())),
);
}
}

return fileEntries;
return { files, folders };
}

// Get all the entries (files or sub-directories) in a directory
// by calling readEntries until it returns empty array
private async _readAllDirectoryEntries(
directoryReader: FileSystemDirectoryReader,
) {
const entries: any = [];
let readEntries: any = await this._readEntriesPromise(directoryReader);
while (readEntries.length > 0) {
entries.push(...readEntries);
readEntries = await this._readEntriesPromise(directoryReader);
/**
* Get the directory entry from a DataTransferItem.
* @remark Supports both WebKit and non-WebKit browsers.
*/
private _getEntry(entry: DataTransferItem): FileSystemDirectoryEntry | null {
let dir: FileSystemDirectoryEntry | null = null;

if ('webkitGetAsEntry' in entry) {
dir = entry.webkitGetAsEntry() as FileSystemDirectoryEntry;
} else if ('getAsEntry' in entry) {
// non-WebKit browsers may rename webkitGetAsEntry to getAsEntry. MDN recommends looking for both.
dir = (entry as any).getAsEntry();
}
return entries;

return dir;
}

private async _readEntriesPromise(
directoryReader: FileSystemDirectoryReader,
) {
return new Promise((resolve, reject) => {
try {
directoryReader.readEntries(resolve, reject);
} catch (err) {
console.log(err);
reject(err);
}
});
// Make directory structure
private async _mkdir(
entry: FileSystemDirectoryEntry,
): Promise<UUIFileFolder> {
const reader = entry.createReader();
const folders: UUIFileFolder[] = [];
const files: File[] = [];

const readEntries = (reader: FileSystemDirectoryReader) => {
return new Promise<void>((resolve, reject) => {
reader.readEntries(async entries => {
if (!entries.length) {
resolve();
return;
}

for (const en of entries) {
if (en.isFile) {
const file = await this._getAsFile(en as FileSystemFileEntry);
if (this._isAccepted(file)) {
files.push(file);
}
} else if (en.isDirectory) {
const directory = await this._mkdir(
en as FileSystemDirectoryEntry,
);
folders.push(directory);
}
}

// readEntries only reads up to 100 entries at a time. It is on purpose we call readEntries recursively.
readEntries(reader);

resolve();
}, reject);
});
};

await readEntries(reader);

const result: UUIFileFolder = { folderName: entry.name, folders, files };
return result;
}

private _isAccepted(file: File) {
Expand Down Expand Up @@ -184,22 +229,28 @@ export class UUIFileDropzoneElement extends LabelMixin('', LitElement) {
return false;
}

private async _getAsFile(fileEntry: FileSystemFileEntry): Promise<File> {
return new Promise((resolve, reject) => fileEntry.file(resolve, reject));
}

private async _onDrop(e: DragEvent) {
e.preventDefault();
this._dropzone.classList.remove('hover');

const items = e.dataTransfer?.items;

if (items) {
let result = await this._getAllFileEntries(items);
const fileSystemResult = await this._getAllEntries(items);

if (this.multiple === false && result.length) {
result = [result[0]];
if (this.multiple === false && fileSystemResult.files.length) {
fileSystemResult.files = [fileSystemResult.files[0]];
}

this._getAllEntries(items);

this.dispatchEvent(
new UUIFileDropzoneEvent(UUIFileDropzoneEvent.CHANGE, {
detail: { files: result },
detail: fileSystemResult,
}),
);
}
Expand Down
53 changes: 51 additions & 2 deletions packages/uui-file-dropzone/lib/uui-file-dropzone.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { html, fixture, expect } from '@open-wc/testing';
import { UUIFileDropzoneElement } from './uui-file-dropzone.element';
import '.';
import { UUIFileDropzoneEvent } from './UUIFileDropzoneEvent';

describe('UUIFileDropzoneElement', () => {
let element: UUIFileDropzoneElement;

beforeEach(async () => {
element = await fixture(html` <uui-file-dropzone></uui-file-dropzone> `);
element = await fixture(html`
<uui-file-dropzone label="Dropzone"></uui-file-dropzone>
`);
});

it('is defined with its own instance', () => {
Expand All @@ -16,4 +18,51 @@ describe('UUIFileDropzoneElement', () => {
it('passes the a11y audit', async () => {
await expect(element).shadowDom.to.be.accessible();
});

describe('dragover', () => {
it('supports dropping a single file', done => {
const file1 = new File([''], 'file1.txt', { type: 'text/plain' });
const file2 = new File([''], 'file2.txt', { type: 'text/plain' });
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file1);
dataTransfer.items.add(file2);

element.addEventListener('change', e => {
const { files, folders } = (e as UUIFileDropzoneEvent).detail;
expect(files.length, 'There should be one file uploaded').to.equal(1);
expect(folders.length, 'There should be no folders uploaded').to.equal(
0,
);
done();
});

element.dispatchEvent(new DragEvent('drop', { dataTransfer }));
});

it('can drop multiple files', done => {
const file1 = new File([''], 'file1.txt', { type: 'text/plain' });
const file2 = new File([''], 'file2.txt', { type: 'text/plain' });
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file1);
dataTransfer.items.add(file2);

element.multiple = true;

element.addEventListener('change', e => {
const { files, folders } = (e as UUIFileDropzoneEvent).detail;
expect(files.length, 'There should be two files uploaded').to.equal(2);
expect(folders.length, 'There should be no folders uploaded').to.equal(
0,
);
done();
});

element.dispatchEvent(new DragEvent('drop', { dataTransfer }));
});

it('can drop a folder with multiple files', () => {
// TODO: Need to find a way to simulate a folder drop
expect(true).to.equal(true);
});
});
});

0 comments on commit dc7594d

Please sign in to comment.