Skip to content

Commit 1f4b3e2

Browse files
V15: Add progress UI to the Upload Field property editor (#18188)
* feat: uses a blob url over a FileReader to get the temporary file's blob and mimetype * feat: adds styling to temporary file badge * use correct styling * feat: adds uploader ui to track progress of uploaded files * chore: check for potentially undefined promises * feat: adds an `AbortSignal` to the xhr requests to allow for the request to be aborted * feat: adds an `AbortSignal` through the stack to upload a file * feat: cancel the ongoing request if the user removes the file during an upload * Code/markup tidy-up Added "Cancel" button, from @bjarnef's suggestion. --------- Co-authored-by: leekelleher <[email protected]>
1 parent 1e5bfd8 commit 1f4b3e2

File tree

8 files changed

+184
-52
lines changed

8 files changed

+184
-52
lines changed

src/Umbraco.Web.UI.Client/src/packages/core/resources/apiTypeValidators.function.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,5 @@ export function isCancelError(error: unknown): error is CancelError {
2121
* @param promise
2222
*/
2323
export function isCancelablePromise<T>(promise: unknown): promise is CancelablePromise<T> {
24-
return (promise as CancelablePromise<T>).cancel !== undefined;
24+
return (promise as CancelablePromise<T>)?.cancel !== undefined;
2525
}

src/Umbraco.Web.UI.Client/src/packages/core/resources/resource.controller.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,12 @@ export class UmbResourceController extends UmbControllerBase {
281281
});
282282
});
283283

284+
if (options.abortSignal) {
285+
options.abortSignal.addEventListener('abort', () => {
286+
promise.cancel();
287+
});
288+
}
289+
284290
return promise;
285291
}
286292

src/Umbraco.Web.UI.Client/src/packages/core/resources/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@ export interface XhrRequestOptions {
77
headers?: Record<string, string>;
88
responseHeader?: string;
99
onProgress?: (event: ProgressEvent) => void;
10+
abortSignal?: AbortSignal;
1011
}

src/Umbraco.Web.UI.Client/src/packages/core/temporary-file/temporary-file-manager.class.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -128,14 +128,22 @@ export class UmbTemporaryFileManager<
128128

129129
const isValid = await this.#validateItem(item);
130130
if (!isValid) {
131-
this.#queue.updateOne(item.temporaryUnique, { ...item, status: TemporaryFileStatus.ERROR });
131+
this.#queue.updateOne(item.temporaryUnique, {
132+
...item,
133+
status: TemporaryFileStatus.ERROR,
134+
});
132135
return { ...item, status: TemporaryFileStatus.ERROR };
133136
}
134137

135-
const { error } = await this.#temporaryFileRepository.upload(item.temporaryUnique, item.file, (evt) => {
136-
// Update progress in percent if a callback is provided
137-
if (item.onProgress) item.onProgress((evt.loaded / evt.total) * 100);
138-
});
138+
const { error } = await this.#temporaryFileRepository.upload(
139+
item.temporaryUnique,
140+
item.file,
141+
(evt) => {
142+
// Update progress in percent if a callback is provided
143+
if (item.onProgress) item.onProgress((evt.loaded / evt.total) * 100);
144+
},
145+
item.abortSignal,
146+
);
139147
const status = error ? TemporaryFileStatus.ERROR : TemporaryFileStatus.SUCCESS;
140148

141149
this.#queue.updateOne(item.temporaryUnique, { ...item, status });

src/Umbraco.Web.UI.Client/src/packages/core/temporary-file/temporary-file.repository.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ export class UmbTemporaryFileRepository extends UmbRepositoryBase {
2727
* @returns {*}
2828
* @memberof UmbTemporaryFileRepository
2929
*/
30-
upload(id: string, file: File, onProgress?: (progress: ProgressEvent) => void) {
31-
return this.#source.create(id, file, onProgress);
30+
upload(id: string, file: File, onProgress?: (progress: ProgressEvent) => void, abortSignal?: AbortSignal) {
31+
return this.#source.create(id, file, onProgress, abortSignal);
3232
}
3333

3434
/**

src/Umbraco.Web.UI.Client/src/packages/core/temporary-file/temporary-file.server.data-source.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export class UmbTemporaryFileServerDataSource {
3131
id: string,
3232
file: File,
3333
onProgress?: (progress: ProgressEvent) => void,
34+
abortSignal?: AbortSignal,
3435
): Promise<UmbDataSourceResponse<PostTemporaryFileResponse>> {
3536
const body = new FormData();
3637
body.append('Id', id);
@@ -41,6 +42,7 @@ export class UmbTemporaryFileServerDataSource {
4142
responseHeader: 'Umb-Generated-Resource',
4243
body,
4344
onProgress,
45+
abortSignal,
4446
});
4547
return tryExecuteAndNotify(this.#host, xhrRequest);
4648
}

src/Umbraco.Web.UI.Client/src/packages/core/temporary-file/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export interface UmbTemporaryFileModel {
1111
temporaryUnique: string;
1212
status?: TemporaryFileStatus;
1313
onProgress?: (progress: number) => void;
14+
abortSignal?: AbortSignal;
1415
}
1516

1617
export type UmbQueueHandlerCallback<TItem extends UmbTemporaryFileModel> = (item: TItem) => Promise<void>;

src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-upload-field/input-upload-field.element.ts

Lines changed: 158 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
import type { MediaValueType } from '../../property-editors/upload-field/types.js';
2-
import { getMimeTypeFromExtension } from './utils.js';
32
import type { ManifestFileUploadPreview } from './file-upload-preview.extension.js';
4-
import { TemporaryFileStatus, UmbTemporaryFileManager } from '@umbraco-cms/backoffice/temporary-file';
5-
import type { UmbTemporaryFileModel } from '@umbraco-cms/backoffice/temporary-file';
6-
import { UmbId } from '@umbraco-cms/backoffice/id';
3+
import { getMimeTypeFromExtension } from './utils.js';
74
import {
85
css,
96
html,
@@ -13,15 +10,18 @@ import {
1310
property,
1411
query,
1512
state,
16-
type PropertyValueMap,
13+
when,
1714
} from '@umbraco-cms/backoffice/external/lit';
18-
import type { UUIFileDropzoneElement, UUIFileDropzoneEvent } from '@umbraco-cms/backoffice/external/uui';
19-
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
15+
import { formatBytes, stringOrStringArrayContains } from '@umbraco-cms/backoffice/utils';
16+
import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';
2017
import { UmbChangeEvent } from '@umbraco-cms/backoffice/event';
21-
2218
import { UmbExtensionsManifestInitializer } from '@umbraco-cms/backoffice/extension-api';
23-
import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';
24-
import { stringOrStringArrayContains } from '@umbraco-cms/backoffice/utils';
19+
import { UmbId } from '@umbraco-cms/backoffice/id';
20+
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
21+
import { UmbTemporaryFileManager, TemporaryFileStatus } from '@umbraco-cms/backoffice/temporary-file';
22+
import type { PropertyValueMap } from '@umbraco-cms/backoffice/external/lit';
23+
import type { UmbTemporaryFileModel } from '@umbraco-cms/backoffice/temporary-file';
24+
import type { UUIFileDropzoneElement, UUIFileDropzoneEvent } from '@umbraco-cms/backoffice/external/uui';
2525

2626
@customElement('umb-input-upload-field')
2727
export class UmbInputUploadFieldElement extends UmbLitElement {
@@ -35,7 +35,6 @@ export class UmbInputUploadFieldElement extends UmbLitElement {
3535
temporaryFileId: this.temporaryFile?.temporaryUnique,
3636
};
3737
}
38-
3938
#src = '';
4039

4140
/**
@@ -54,6 +53,9 @@ export class UmbInputUploadFieldElement extends UmbLitElement {
5453
@state()
5554
public temporaryFile?: UmbTemporaryFileModel;
5655

56+
@state()
57+
private _progress = 0;
58+
5759
@state()
5860
private _extensions?: string[];
5961

@@ -67,12 +69,11 @@ export class UmbInputUploadFieldElement extends UmbLitElement {
6769

6870
#manifests: Array<ManifestFileUploadPreview> = [];
6971

70-
constructor() {
71-
super();
72-
}
72+
#uploadAbort?: AbortController;
7373

7474
override updated(changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>) {
7575
super.updated(changedProperties);
76+
7677
if (changedProperties.has('value') && changedProperties.get('value')?.src !== this.value.src) {
7778
this.#setPreviewAlias();
7879
}
@@ -108,7 +109,13 @@ export class UmbInputUploadFieldElement extends UmbLitElement {
108109
stringOrStringArrayContains(manifest.forMimeTypes, '*/*'),
109110
)?.alias;
110111

111-
const mimeType = this.#getMimeTypeFromPath(this.value.src);
112+
let mimeType: string | null = null;
113+
if (this.temporaryFile?.file) {
114+
mimeType = this.temporaryFile.file.type;
115+
} else {
116+
mimeType = this.#getMimeTypeFromPath(this.value.src);
117+
}
118+
112119
if (!mimeType) return fallbackAlias;
113120

114121
// Check for an exact match
@@ -148,23 +155,43 @@ export class UmbInputUploadFieldElement extends UmbLitElement {
148155

149156
async #onUpload(e: UUIFileDropzoneEvent) {
150157
//Property Editor for Upload field will always only have one file.
151-
const item: UmbTemporaryFileModel = {
158+
this.temporaryFile = {
152159
temporaryUnique: UmbId.new(),
160+
status: TemporaryFileStatus.WAITING,
153161
file: e.detail.files[0],
154162
};
155163

156-
const upload = this.#manager.uploadOne(item);
164+
try {
165+
this.#uploadAbort = new AbortController();
166+
const uploaded = await this.#manager.uploadOne({
167+
...this.temporaryFile,
168+
onProgress: (p) => {
169+
this._progress = Math.ceil(p);
170+
},
171+
abortSignal: this.#uploadAbort.signal,
172+
});
173+
174+
if (uploaded.status === TemporaryFileStatus.SUCCESS) {
175+
this.temporaryFile.status = TemporaryFileStatus.SUCCESS;
157176

158-
const reader = new FileReader();
159-
reader.onload = () => {
160-
this.value = { src: reader.result as string };
161-
};
162-
reader.readAsDataURL(item.file);
177+
const blobUrl = URL.createObjectURL(this.temporaryFile.file);
178+
this.value = { src: blobUrl };
163179

164-
const uploaded = await upload;
165-
if (uploaded.status === TemporaryFileStatus.SUCCESS) {
166-
this.temporaryFile = { temporaryUnique: item.temporaryUnique, file: item.file };
167-
this.dispatchEvent(new UmbChangeEvent());
180+
this.dispatchEvent(new UmbChangeEvent());
181+
} else {
182+
this.temporaryFile.status = TemporaryFileStatus.ERROR;
183+
this.requestUpdate('temporaryFile');
184+
}
185+
} catch {
186+
// If we still have a temporary file, set it to error.
187+
if (this.temporaryFile) {
188+
this.temporaryFile.status = TemporaryFileStatus.ERROR;
189+
this.requestUpdate('temporaryFile');
190+
}
191+
192+
// If the error was caused by the upload being aborted, do not show an error message.
193+
} finally {
194+
this.#uploadAbort = undefined;
168195
}
169196
}
170197

@@ -175,55 +202,103 @@ export class UmbInputUploadFieldElement extends UmbLitElement {
175202
}
176203

177204
override render() {
178-
if (this.value.src && this._previewAlias) {
179-
return this.#renderFile(this.value.src, this._previewAlias, this.temporaryFile?.file);
180-
} else {
205+
if (!this.temporaryFile && !this.value.src) {
181206
return this.#renderDropzone();
182207
}
208+
209+
return html`
210+
${this.temporaryFile ? this.#renderUploader() : nothing}
211+
${this.value.src && this._previewAlias ? this.#renderFile(this.value.src) : nothing}
212+
`;
183213
}
184214

185215
#renderDropzone() {
186216
return html`
187217
<uui-file-dropzone
188-
@click=${this.#handleBrowse}
189218
id="dropzone"
190219
label="dropzone"
191-
@change="${this.#onUpload}"
192-
accept="${ifDefined(this._extensions?.join(', '))}">
193-
<uui-button label=${this.localize.term('media_clickToUpload')} @click="${this.#handleBrowse}"></uui-button>
220+
disallowFolderUpload
221+
accept=${ifDefined(this._extensions?.join(', '))}
222+
@change=${this.#onUpload}
223+
@click=${this.#handleBrowse}>
224+
<uui-button label=${this.localize.term('media_clickToUpload')} @click=${this.#handleBrowse}></uui-button>
194225
</uui-file-dropzone>
195226
`;
196227
}
197228

198-
#renderFile(src: string, previewAlias: string, file?: File) {
199-
if (!previewAlias) return 'An error occurred. No previewer found for the file type.';
229+
#renderUploader() {
230+
if (!this.temporaryFile) return nothing;
231+
232+
return html`
233+
<div id="temporaryFile">
234+
<div id="fileIcon">
235+
${when(
236+
this.temporaryFile.status === TemporaryFileStatus.SUCCESS,
237+
() => html`<umb-icon name="check" color="green"></umb-icon>`,
238+
)}
239+
${when(
240+
this.temporaryFile.status === TemporaryFileStatus.ERROR,
241+
() => html`<umb-icon name="wrong" color="red"></umb-icon>`,
242+
)}
243+
</div>
244+
<div id="fileDetails">
245+
<div id="fileName">${this.temporaryFile.file.name}</div>
246+
<div id="fileSize">${formatBytes(this.temporaryFile.file.size, { decimals: 2 })}: ${this._progress}%</div>
247+
${when(
248+
this.temporaryFile.status === TemporaryFileStatus.WAITING,
249+
() => html`<div id="progress"><uui-loader-bar progress=${this._progress}></uui-loader-bar></div>`,
250+
)}
251+
${when(
252+
this.temporaryFile.status === TemporaryFileStatus.ERROR,
253+
() => html`<div id="error">An error occured</div>`,
254+
)}
255+
</div>
256+
<div id="fileActions">
257+
${when(
258+
this.temporaryFile.status === TemporaryFileStatus.WAITING,
259+
() => html`
260+
<uui-button compact @click=${this.#handleRemove} label=${this.localize.term('general_cancel')}>
261+
<uui-icon name="remove"></uui-icon>${this.localize.term('general_cancel')}
262+
</uui-button>
263+
`,
264+
() => this.#renderButtonRemove(),
265+
)}
266+
</div>
267+
</div>
268+
`;
269+
}
270+
271+
#renderFile(src: string) {
200272
return html`
201273
<div id="wrapper">
202-
<div style="position:relative; display: flex; width: fit-content; max-width: 100%">
274+
<div id="wrapperInner">
203275
<umb-extension-slot
204276
type="fileUploadPreview"
205-
.props=${{ path: src, file: file }}
206-
.filter=${(manifest: ManifestFileUploadPreview) => manifest.alias === previewAlias}>
277+
.props=${{ path: src, file: this.temporaryFile?.file }}
278+
.filter=${(manifest: ManifestFileUploadPreview) => manifest.alias === this._previewAlias}>
207279
</umb-extension-slot>
208-
${this.temporaryFile?.status === TemporaryFileStatus.WAITING
209-
? html`<umb-temporary-file-badge></umb-temporary-file-badge>`
210-
: nothing}
211280
</div>
212281
</div>
213282
${this.#renderButtonRemove()}
214283
`;
215284
}
216285

217286
#renderButtonRemove() {
218-
return html`<uui-button compact @click=${this.#handleRemove} label=${this.localize.term('content_uploadClear')}>
219-
<uui-icon name="icon-trash"></uui-icon>${this.localize.term('content_uploadClear')}
220-
</uui-button>`;
287+
return html`
288+
<uui-button compact @click=${this.#handleRemove} label=${this.localize.term('content_uploadClear')}>
289+
<uui-icon name="icon-trash"></uui-icon>${this.localize.term('content_uploadClear')}
290+
</uui-button>
291+
`;
221292
}
222293

223294
#handleRemove() {
224295
this.value = { src: undefined };
225296
this.temporaryFile = undefined;
297+
this._progress = 0;
226298
this.dispatchEvent(new UmbChangeEvent());
299+
300+
// If the upload promise happens to be in progress, cancel it.
301+
this.#uploadAbort?.abort();
227302
}
228303

229304
static override readonly styles = [
@@ -249,6 +324,45 @@ export class UmbInputUploadFieldElement extends UmbLitElement {
249324
border-radius: var(--uui-border-radius);
250325
}
251326
327+
#wrapperInner {
328+
position: relative;
329+
display: flex;
330+
width: fit-content;
331+
max-width: 100%;
332+
}
333+
334+
#temporaryFile {
335+
display: grid;
336+
grid-template-columns: auto auto auto;
337+
width: fit-content;
338+
max-width: 100%;
339+
margin: var(--uui-size-layout-1) 0;
340+
padding: var(--uui-size-space-3);
341+
border: 1px dashed var(--uui-color-divider-emphasis);
342+
}
343+
344+
#fileIcon,
345+
#fileActions {
346+
place-self: center center;
347+
padding: 0 var(--uui-size-layout-1);
348+
}
349+
350+
#fileName {
351+
white-space: nowrap;
352+
overflow: hidden;
353+
text-overflow: ellipsis;
354+
font-size: var(--uui-size-5);
355+
}
356+
357+
#fileSize {
358+
font-size: var(--uui-font-size-small);
359+
color: var(--uui-color-text-alt);
360+
}
361+
362+
#error {
363+
color: var(--uui-color-danger);
364+
}
365+
252366
uui-file-dropzone {
253367
position: relative;
254368
display: block;

0 commit comments

Comments
 (0)