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 <leekelleher@gmail.com>
This commit is contained in:
Jacob Overgaard
2025-02-03 16:44:00 +01:00
committed by GitHub
parent 1e5bfd8c3a
commit 1f4b3e2599
8 changed files with 185 additions and 53 deletions

View File

@@ -21,5 +21,5 @@ export function isCancelError(error: unknown): error is CancelError {
* @param promise
*/
export function isCancelablePromise<T>(promise: unknown): promise is CancelablePromise<T> {
return (promise as CancelablePromise<T>).cancel !== undefined;
return (promise as CancelablePromise<T>)?.cancel !== undefined;
}

View File

@@ -281,6 +281,12 @@ export class UmbResourceController extends UmbControllerBase {
});
});
if (options.abortSignal) {
options.abortSignal.addEventListener('abort', () => {
promise.cancel();
});
}
return promise;
}

View File

@@ -7,4 +7,5 @@ export interface XhrRequestOptions {
headers?: Record<string, string>;
responseHeader?: string;
onProgress?: (event: ProgressEvent) => void;
abortSignal?: AbortSignal;
}

View File

@@ -128,14 +128,22 @@ export class UmbTemporaryFileManager<
const isValid = await this.#validateItem(item);
if (!isValid) {
this.#queue.updateOne(item.temporaryUnique, { ...item, status: TemporaryFileStatus.ERROR });
this.#queue.updateOne(item.temporaryUnique, {
...item,
status: TemporaryFileStatus.ERROR,
});
return { ...item, status: TemporaryFileStatus.ERROR };
}
const { error } = await this.#temporaryFileRepository.upload(item.temporaryUnique, item.file, (evt) => {
// Update progress in percent if a callback is provided
if (item.onProgress) item.onProgress((evt.loaded / evt.total) * 100);
});
const { error } = await this.#temporaryFileRepository.upload(
item.temporaryUnique,
item.file,
(evt) => {
// Update progress in percent if a callback is provided
if (item.onProgress) item.onProgress((evt.loaded / evt.total) * 100);
},
item.abortSignal,
);
const status = error ? TemporaryFileStatus.ERROR : TemporaryFileStatus.SUCCESS;
this.#queue.updateOne(item.temporaryUnique, { ...item, status });

View File

@@ -27,8 +27,8 @@ export class UmbTemporaryFileRepository extends UmbRepositoryBase {
* @returns {*}
* @memberof UmbTemporaryFileRepository
*/
upload(id: string, file: File, onProgress?: (progress: ProgressEvent) => void) {
return this.#source.create(id, file, onProgress);
upload(id: string, file: File, onProgress?: (progress: ProgressEvent) => void, abortSignal?: AbortSignal) {
return this.#source.create(id, file, onProgress, abortSignal);
}
/**

View File

@@ -31,6 +31,7 @@ export class UmbTemporaryFileServerDataSource {
id: string,
file: File,
onProgress?: (progress: ProgressEvent) => void,
abortSignal?: AbortSignal,
): Promise<UmbDataSourceResponse<PostTemporaryFileResponse>> {
const body = new FormData();
body.append('Id', id);
@@ -41,6 +42,7 @@ export class UmbTemporaryFileServerDataSource {
responseHeader: 'Umb-Generated-Resource',
body,
onProgress,
abortSignal,
});
return tryExecuteAndNotify(this.#host, xhrRequest);
}

View File

@@ -11,6 +11,7 @@ export interface UmbTemporaryFileModel {
temporaryUnique: string;
status?: TemporaryFileStatus;
onProgress?: (progress: number) => void;
abortSignal?: AbortSignal;
}
export type UmbQueueHandlerCallback<TItem extends UmbTemporaryFileModel> = (item: TItem) => Promise<void>;

View File

@@ -1,9 +1,6 @@
import type { MediaValueType } from '../../property-editors/upload-field/types.js';
import { getMimeTypeFromExtension } from './utils.js';
import type { ManifestFileUploadPreview } from './file-upload-preview.extension.js';
import { TemporaryFileStatus, UmbTemporaryFileManager } from '@umbraco-cms/backoffice/temporary-file';
import type { UmbTemporaryFileModel } from '@umbraco-cms/backoffice/temporary-file';
import { UmbId } from '@umbraco-cms/backoffice/id';
import { getMimeTypeFromExtension } from './utils.js';
import {
css,
html,
@@ -13,15 +10,18 @@ import {
property,
query,
state,
type PropertyValueMap,
when,
} from '@umbraco-cms/backoffice/external/lit';
import type { UUIFileDropzoneElement, UUIFileDropzoneEvent } from '@umbraco-cms/backoffice/external/uui';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { UmbChangeEvent } from '@umbraco-cms/backoffice/event';
import { UmbExtensionsManifestInitializer } from '@umbraco-cms/backoffice/extension-api';
import { formatBytes, stringOrStringArrayContains } from '@umbraco-cms/backoffice/utils';
import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';
import { stringOrStringArrayContains } from '@umbraco-cms/backoffice/utils';
import { UmbChangeEvent } from '@umbraco-cms/backoffice/event';
import { UmbExtensionsManifestInitializer } from '@umbraco-cms/backoffice/extension-api';
import { UmbId } from '@umbraco-cms/backoffice/id';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { UmbTemporaryFileManager, TemporaryFileStatus } from '@umbraco-cms/backoffice/temporary-file';
import type { PropertyValueMap } from '@umbraco-cms/backoffice/external/lit';
import type { UmbTemporaryFileModel } from '@umbraco-cms/backoffice/temporary-file';
import type { UUIFileDropzoneElement, UUIFileDropzoneEvent } from '@umbraco-cms/backoffice/external/uui';
@customElement('umb-input-upload-field')
export class UmbInputUploadFieldElement extends UmbLitElement {
@@ -35,7 +35,6 @@ export class UmbInputUploadFieldElement extends UmbLitElement {
temporaryFileId: this.temporaryFile?.temporaryUnique,
};
}
#src = '';
/**
@@ -54,6 +53,9 @@ export class UmbInputUploadFieldElement extends UmbLitElement {
@state()
public temporaryFile?: UmbTemporaryFileModel;
@state()
private _progress = 0;
@state()
private _extensions?: string[];
@@ -67,12 +69,11 @@ export class UmbInputUploadFieldElement extends UmbLitElement {
#manifests: Array<ManifestFileUploadPreview> = [];
constructor() {
super();
}
#uploadAbort?: AbortController;
override updated(changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>) {
super.updated(changedProperties);
if (changedProperties.has('value') && changedProperties.get('value')?.src !== this.value.src) {
this.#setPreviewAlias();
}
@@ -108,7 +109,13 @@ export class UmbInputUploadFieldElement extends UmbLitElement {
stringOrStringArrayContains(manifest.forMimeTypes, '*/*'),
)?.alias;
const mimeType = this.#getMimeTypeFromPath(this.value.src);
let mimeType: string | null = null;
if (this.temporaryFile?.file) {
mimeType = this.temporaryFile.file.type;
} else {
mimeType = this.#getMimeTypeFromPath(this.value.src);
}
if (!mimeType) return fallbackAlias;
// Check for an exact match
@@ -148,23 +155,43 @@ export class UmbInputUploadFieldElement extends UmbLitElement {
async #onUpload(e: UUIFileDropzoneEvent) {
//Property Editor for Upload field will always only have one file.
const item: UmbTemporaryFileModel = {
this.temporaryFile = {
temporaryUnique: UmbId.new(),
status: TemporaryFileStatus.WAITING,
file: e.detail.files[0],
};
const upload = this.#manager.uploadOne(item);
try {
this.#uploadAbort = new AbortController();
const uploaded = await this.#manager.uploadOne({
...this.temporaryFile,
onProgress: (p) => {
this._progress = Math.ceil(p);
},
abortSignal: this.#uploadAbort.signal,
});
const reader = new FileReader();
reader.onload = () => {
this.value = { src: reader.result as string };
};
reader.readAsDataURL(item.file);
if (uploaded.status === TemporaryFileStatus.SUCCESS) {
this.temporaryFile.status = TemporaryFileStatus.SUCCESS;
const uploaded = await upload;
if (uploaded.status === TemporaryFileStatus.SUCCESS) {
this.temporaryFile = { temporaryUnique: item.temporaryUnique, file: item.file };
this.dispatchEvent(new UmbChangeEvent());
const blobUrl = URL.createObjectURL(this.temporaryFile.file);
this.value = { src: blobUrl };
this.dispatchEvent(new UmbChangeEvent());
} else {
this.temporaryFile.status = TemporaryFileStatus.ERROR;
this.requestUpdate('temporaryFile');
}
} catch {
// If we still have a temporary file, set it to error.
if (this.temporaryFile) {
this.temporaryFile.status = TemporaryFileStatus.ERROR;
this.requestUpdate('temporaryFile');
}
// If the error was caused by the upload being aborted, do not show an error message.
} finally {
this.#uploadAbort = undefined;
}
}
@@ -175,39 +202,81 @@ export class UmbInputUploadFieldElement extends UmbLitElement {
}
override render() {
if (this.value.src && this._previewAlias) {
return this.#renderFile(this.value.src, this._previewAlias, this.temporaryFile?.file);
} else {
if (!this.temporaryFile && !this.value.src) {
return this.#renderDropzone();
}
return html`
${this.temporaryFile ? this.#renderUploader() : nothing}
${this.value.src && this._previewAlias ? this.#renderFile(this.value.src) : nothing}
`;
}
#renderDropzone() {
return html`
<uui-file-dropzone
@click=${this.#handleBrowse}
id="dropzone"
label="dropzone"
@change="${this.#onUpload}"
accept="${ifDefined(this._extensions?.join(', '))}">
<uui-button label=${this.localize.term('media_clickToUpload')} @click="${this.#handleBrowse}"></uui-button>
disallowFolderUpload
accept=${ifDefined(this._extensions?.join(', '))}
@change=${this.#onUpload}
@click=${this.#handleBrowse}>
<uui-button label=${this.localize.term('media_clickToUpload')} @click=${this.#handleBrowse}></uui-button>
</uui-file-dropzone>
`;
}
#renderFile(src: string, previewAlias: string, file?: File) {
if (!previewAlias) return 'An error occurred. No previewer found for the file type.';
#renderUploader() {
if (!this.temporaryFile) return nothing;
return html`
<div id="temporaryFile">
<div id="fileIcon">
${when(
this.temporaryFile.status === TemporaryFileStatus.SUCCESS,
() => html`<umb-icon name="check" color="green"></umb-icon>`,
)}
${when(
this.temporaryFile.status === TemporaryFileStatus.ERROR,
() => html`<umb-icon name="wrong" color="red"></umb-icon>`,
)}
</div>
<div id="fileDetails">
<div id="fileName">${this.temporaryFile.file.name}</div>
<div id="fileSize">${formatBytes(this.temporaryFile.file.size, { decimals: 2 })}: ${this._progress}%</div>
${when(
this.temporaryFile.status === TemporaryFileStatus.WAITING,
() => html`<div id="progress"><uui-loader-bar progress=${this._progress}></uui-loader-bar></div>`,
)}
${when(
this.temporaryFile.status === TemporaryFileStatus.ERROR,
() => html`<div id="error">An error occured</div>`,
)}
</div>
<div id="fileActions">
${when(
this.temporaryFile.status === TemporaryFileStatus.WAITING,
() => html`
<uui-button compact @click=${this.#handleRemove} label=${this.localize.term('general_cancel')}>
<uui-icon name="remove"></uui-icon>${this.localize.term('general_cancel')}
</uui-button>
`,
() => this.#renderButtonRemove(),
)}
</div>
</div>
`;
}
#renderFile(src: string) {
return html`
<div id="wrapper">
<div style="position:relative; display: flex; width: fit-content; max-width: 100%">
<div id="wrapperInner">
<umb-extension-slot
type="fileUploadPreview"
.props=${{ path: src, file: file }}
.filter=${(manifest: ManifestFileUploadPreview) => manifest.alias === previewAlias}>
.props=${{ path: src, file: this.temporaryFile?.file }}
.filter=${(manifest: ManifestFileUploadPreview) => manifest.alias === this._previewAlias}>
</umb-extension-slot>
${this.temporaryFile?.status === TemporaryFileStatus.WAITING
? html`<umb-temporary-file-badge></umb-temporary-file-badge>`
: nothing}
</div>
</div>
${this.#renderButtonRemove()}
@@ -215,15 +284,21 @@ export class UmbInputUploadFieldElement extends UmbLitElement {
}
#renderButtonRemove() {
return html`<uui-button compact @click=${this.#handleRemove} label=${this.localize.term('content_uploadClear')}>
<uui-icon name="icon-trash"></uui-icon>${this.localize.term('content_uploadClear')}
</uui-button>`;
return html`
<uui-button compact @click=${this.#handleRemove} label=${this.localize.term('content_uploadClear')}>
<uui-icon name="icon-trash"></uui-icon>${this.localize.term('content_uploadClear')}
</uui-button>
`;
}
#handleRemove() {
this.value = { src: undefined };
this.temporaryFile = undefined;
this._progress = 0;
this.dispatchEvent(new UmbChangeEvent());
// If the upload promise happens to be in progress, cancel it.
this.#uploadAbort?.abort();
}
static override readonly styles = [
@@ -249,6 +324,45 @@ export class UmbInputUploadFieldElement extends UmbLitElement {
border-radius: var(--uui-border-radius);
}
#wrapperInner {
position: relative;
display: flex;
width: fit-content;
max-width: 100%;
}
#temporaryFile {
display: grid;
grid-template-columns: auto auto auto;
width: fit-content;
max-width: 100%;
margin: var(--uui-size-layout-1) 0;
padding: var(--uui-size-space-3);
border: 1px dashed var(--uui-color-divider-emphasis);
}
#fileIcon,
#fileActions {
place-self: center center;
padding: 0 var(--uui-size-layout-1);
}
#fileName {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: var(--uui-size-5);
}
#fileSize {
font-size: var(--uui-font-size-small);
color: var(--uui-color-text-alt);
}
#error {
color: var(--uui-color-danger);
}
uui-file-dropzone {
position: relative;
display: block;