Skip to content

Commit ea8bf2a

Browse files
loivseniOvergaard
andcommitted
feat: dropzone should support folder upload (#841)
* 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 <[email protected]>
1 parent 7833d43 commit ea8bf2a

File tree

3 files changed

+155
-52
lines changed

3 files changed

+155
-52
lines changed

packages/uui-file-dropzone/lib/UUIFileDropzoneEvent.ts

+5-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import { UUIEvent } from '@umbraco-ui/uui-base/lib/events';
2-
import { UUIFileDropzoneElement } from './uui-file-dropzone.element';
2+
import {
3+
UUIFileDropzoneElement,
4+
UUIFileFolder,
5+
} from './uui-file-dropzone.element';
36

47
export class UUIFileDropzoneEvent extends UUIEvent<
5-
{ files: File[] },
8+
{ files: File[]; folders: UUIFileFolder[] },
69
UUIFileDropzoneElement
710
> {
811
public static readonly CHANGE: string = 'change';

packages/uui-file-dropzone/lib/uui-file-dropzone.element.ts

+99-48
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,14 @@ import { css, html, LitElement } from 'lit';
33
import { query, property } from 'lit/decorators.js';
44
import { UUIFileDropzoneEvent } from './UUIFileDropzoneEvent';
55
import { LabelMixin } from '@umbraco-ui/uui-base/lib/mixins';
6-
import { demandCustomElement } from '@umbraco-ui/uui-base/lib/utils';
6+
7+
import '@umbraco-ui/uui-symbol-file-dropzone/lib';
8+
9+
export interface UUIFileFolder {
10+
folderName: string;
11+
folders: UUIFileFolder[];
12+
files: File[];
13+
}
714

815
/**
916
* @element uui-file-dropzone
@@ -66,6 +73,13 @@ export class UUIFileDropzoneElement extends LabelMixin('', LitElement) {
6673
return this._accept;
6774
}
6875

76+
@property({
77+
type: Boolean,
78+
reflect: true,
79+
attribute: 'disallow-folder-upload',
80+
})
81+
public disallowFolderUpload: boolean = false;
82+
6983
/**
7084
* Allows for multiple files to be selected.
7185
* @type {boolean}
@@ -92,64 +106,95 @@ export class UUIFileDropzoneElement extends LabelMixin('', LitElement) {
92106
this.addEventListener('drop', this._onDrop, false);
93107
}
94108

95-
connectedCallback(): void {
96-
super.connectedCallback();
97-
demandCustomElement(this, 'uui-symbol-file-dropzone');
98-
}
99-
100-
private async _getAllFileEntries(
101-
dataTransferItemList: DataTransferItemList,
102-
): Promise<File[]> {
103-
const fileEntries: File[] = [];
109+
private async _getAllEntries(dataTransferItemList: DataTransferItemList) {
104110
// Use BFS to traverse entire directory/file structure
105111
const queue = [...dataTransferItemList];
106112

107-
while (queue.length > 0) {
108-
const entry = queue.shift()!;
113+
const folders: UUIFileFolder[] = [];
114+
const files: File[] = [];
115+
116+
for (const entry of queue) {
117+
if (entry?.kind !== 'file') continue;
109118

110-
if (entry.kind === 'file') {
119+
if (entry.type) {
120+
// Entry is a file
111121
const file = entry.getAsFile();
112122
if (!file) continue;
113123
if (this._isAccepted(file)) {
114-
fileEntries.push(file);
124+
files.push(file);
125+
}
126+
} else if (!this.disallowFolderUpload) {
127+
// Entry is a directory
128+
const dir = this._getEntry(entry);
129+
130+
if (dir) {
131+
const structure = await this._mkdir(dir);
132+
folders.push(structure);
115133
}
116-
} else if (entry.kind === 'directory') {
117-
if ('webkitGetAsEntry' in entry === false) continue;
118-
const directory = entry.webkitGetAsEntry()! as FileSystemDirectoryEntry;
119-
queue.push(
120-
...(await this._readAllDirectoryEntries(directory.createReader())),
121-
);
122134
}
123135
}
124-
125-
return fileEntries;
136+
return { files, folders };
126137
}
127138

128-
// Get all the entries (files or sub-directories) in a directory
129-
// by calling readEntries until it returns empty array
130-
private async _readAllDirectoryEntries(
131-
directoryReader: FileSystemDirectoryReader,
132-
) {
133-
const entries: any = [];
134-
let readEntries: any = await this._readEntriesPromise(directoryReader);
135-
while (readEntries.length > 0) {
136-
entries.push(...readEntries);
137-
readEntries = await this._readEntriesPromise(directoryReader);
139+
/**
140+
* Get the directory entry from a DataTransferItem.
141+
* @remark Supports both WebKit and non-WebKit browsers.
142+
*/
143+
private _getEntry(entry: DataTransferItem): FileSystemDirectoryEntry | null {
144+
let dir: FileSystemDirectoryEntry | null = null;
145+
146+
if ('webkitGetAsEntry' in entry) {
147+
dir = entry.webkitGetAsEntry() as FileSystemDirectoryEntry;
148+
} else if ('getAsEntry' in entry) {
149+
// non-WebKit browsers may rename webkitGetAsEntry to getAsEntry. MDN recommends looking for both.
150+
dir = (entry as any).getAsEntry();
138151
}
139-
return entries;
152+
153+
return dir;
140154
}
141155

142-
private async _readEntriesPromise(
143-
directoryReader: FileSystemDirectoryReader,
144-
) {
145-
return new Promise((resolve, reject) => {
146-
try {
147-
directoryReader.readEntries(resolve, reject);
148-
} catch (err) {
149-
console.log(err);
150-
reject(err);
151-
}
152-
});
156+
// Make directory structure
157+
private async _mkdir(
158+
entry: FileSystemDirectoryEntry,
159+
): Promise<UUIFileFolder> {
160+
const reader = entry.createReader();
161+
const folders: UUIFileFolder[] = [];
162+
const files: File[] = [];
163+
164+
const readEntries = (reader: FileSystemDirectoryReader) => {
165+
return new Promise<void>((resolve, reject) => {
166+
reader.readEntries(async entries => {
167+
if (!entries.length) {
168+
resolve();
169+
return;
170+
}
171+
172+
for (const en of entries) {
173+
if (en.isFile) {
174+
const file = await this._getAsFile(en as FileSystemFileEntry);
175+
if (this._isAccepted(file)) {
176+
files.push(file);
177+
}
178+
} else if (en.isDirectory) {
179+
const directory = await this._mkdir(
180+
en as FileSystemDirectoryEntry,
181+
);
182+
folders.push(directory);
183+
}
184+
}
185+
186+
// readEntries only reads up to 100 entries at a time. It is on purpose we call readEntries recursively.
187+
readEntries(reader);
188+
189+
resolve();
190+
}, reject);
191+
});
192+
};
193+
194+
await readEntries(reader);
195+
196+
const result: UUIFileFolder = { folderName: entry.name, folders, files };
197+
return result;
153198
}
154199

155200
private _isAccepted(file: File) {
@@ -184,22 +229,28 @@ export class UUIFileDropzoneElement extends LabelMixin('', LitElement) {
184229
return false;
185230
}
186231

232+
private async _getAsFile(fileEntry: FileSystemFileEntry): Promise<File> {
233+
return new Promise((resolve, reject) => fileEntry.file(resolve, reject));
234+
}
235+
187236
private async _onDrop(e: DragEvent) {
188237
e.preventDefault();
189238
this._dropzone.classList.remove('hover');
190239

191240
const items = e.dataTransfer?.items;
192241

193242
if (items) {
194-
let result = await this._getAllFileEntries(items);
243+
const fileSystemResult = await this._getAllEntries(items);
195244

196-
if (this.multiple === false && result.length) {
197-
result = [result[0]];
245+
if (this.multiple === false && fileSystemResult.files.length) {
246+
fileSystemResult.files = [fileSystemResult.files[0]];
198247
}
199248

249+
this._getAllEntries(items);
250+
200251
this.dispatchEvent(
201252
new UUIFileDropzoneEvent(UUIFileDropzoneEvent.CHANGE, {
202-
detail: { files: result },
253+
detail: fileSystemResult,
203254
}),
204255
);
205256
}
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { html, fixture, expect } from '@open-wc/testing';
22
import { UUIFileDropzoneElement } from './uui-file-dropzone.element';
3-
import '.';
3+
import { UUIFileDropzoneEvent } from './UUIFileDropzoneEvent';
44

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

88
beforeEach(async () => {
9-
element = await fixture(html` <uui-file-dropzone></uui-file-dropzone> `);
9+
element = await fixture(html`
10+
<uui-file-dropzone label="Dropzone"></uui-file-dropzone>
11+
`);
1012
});
1113

1214
it('is defined with its own instance', () => {
@@ -16,4 +18,51 @@ describe('UUIFileDropzoneElement', () => {
1618
it('passes the a11y audit', async () => {
1719
await expect(element).shadowDom.to.be.accessible();
1820
});
21+
22+
describe('dragover', () => {
23+
it('supports dropping a single file', done => {
24+
const file1 = new File([''], 'file1.txt', { type: 'text/plain' });
25+
const file2 = new File([''], 'file2.txt', { type: 'text/plain' });
26+
const dataTransfer = new DataTransfer();
27+
dataTransfer.items.add(file1);
28+
dataTransfer.items.add(file2);
29+
30+
element.addEventListener('change', e => {
31+
const { files, folders } = (e as UUIFileDropzoneEvent).detail;
32+
expect(files.length, 'There should be one file uploaded').to.equal(1);
33+
expect(folders.length, 'There should be no folders uploaded').to.equal(
34+
0,
35+
);
36+
done();
37+
});
38+
39+
element.dispatchEvent(new DragEvent('drop', { dataTransfer }));
40+
});
41+
42+
it('can drop multiple files', done => {
43+
const file1 = new File([''], 'file1.txt', { type: 'text/plain' });
44+
const file2 = new File([''], 'file2.txt', { type: 'text/plain' });
45+
const dataTransfer = new DataTransfer();
46+
dataTransfer.items.add(file1);
47+
dataTransfer.items.add(file2);
48+
49+
element.multiple = true;
50+
51+
element.addEventListener('change', e => {
52+
const { files, folders } = (e as UUIFileDropzoneEvent).detail;
53+
expect(files.length, 'There should be two files uploaded').to.equal(2);
54+
expect(folders.length, 'There should be no folders uploaded').to.equal(
55+
0,
56+
);
57+
done();
58+
});
59+
60+
element.dispatchEvent(new DragEvent('drop', { dataTransfer }));
61+
});
62+
63+
it('can drop a folder with multiple files', () => {
64+
// TODO: Need to find a way to simulate a folder drop
65+
expect(true).to.equal(true);
66+
});
67+
});
1968
});

0 commit comments

Comments
 (0)