diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/resources/apiTypeValidators.function.ts b/src/Umbraco.Web.UI.Client/src/packages/core/resources/apiTypeValidators.function.ts index b30736d2b4..bb5ba98d23 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/resources/apiTypeValidators.function.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/resources/apiTypeValidators.function.ts @@ -21,5 +21,5 @@ export function isCancelError(error: unknown): error is CancelError { * @param promise */ export function isCancelablePromise(promise: unknown): promise is CancelablePromise { - return (promise as CancelablePromise).cancel !== undefined; + return (promise as CancelablePromise)?.cancel !== undefined; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/resources/resource.controller.ts b/src/Umbraco.Web.UI.Client/src/packages/core/resources/resource.controller.ts index d92a68a38c..8274db8ce8 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/resources/resource.controller.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/resources/resource.controller.ts @@ -281,6 +281,12 @@ export class UmbResourceController extends UmbControllerBase { }); }); + if (options.abortSignal) { + options.abortSignal.addEventListener('abort', () => { + promise.cancel(); + }); + } + return promise; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/resources/types.ts b/src/Umbraco.Web.UI.Client/src/packages/core/resources/types.ts index 2ff6371c63..8b5c34c3f1 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/resources/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/resources/types.ts @@ -7,4 +7,5 @@ export interface XhrRequestOptions { headers?: Record; responseHeader?: string; onProgress?: (event: ProgressEvent) => void; + abortSignal?: AbortSignal; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/temporary-file/temporary-file-manager.class.ts b/src/Umbraco.Web.UI.Client/src/packages/core/temporary-file/temporary-file-manager.class.ts index 2af1555268..8641b8fde4 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/temporary-file/temporary-file-manager.class.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/temporary-file/temporary-file-manager.class.ts @@ -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 }); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/temporary-file/temporary-file.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/core/temporary-file/temporary-file.repository.ts index 519dc77652..2707572586 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/temporary-file/temporary-file.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/temporary-file/temporary-file.repository.ts @@ -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); } /** diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/temporary-file/temporary-file.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/core/temporary-file/temporary-file.server.data-source.ts index 644eaa7dcd..415995f20a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/temporary-file/temporary-file.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/temporary-file/temporary-file.server.data-source.ts @@ -31,6 +31,7 @@ export class UmbTemporaryFileServerDataSource { id: string, file: File, onProgress?: (progress: ProgressEvent) => void, + abortSignal?: AbortSignal, ): Promise> { 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); } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/temporary-file/types.ts b/src/Umbraco.Web.UI.Client/src/packages/core/temporary-file/types.ts index 9d417c4bf4..b471f033f0 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/temporary-file/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/temporary-file/types.ts @@ -11,6 +11,7 @@ export interface UmbTemporaryFileModel { temporaryUnique: string; status?: TemporaryFileStatus; onProgress?: (progress: number) => void; + abortSignal?: AbortSignal; } export type UmbQueueHandlerCallback = (item: TItem) => Promise; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-upload-field/input-upload-field.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-upload-field/input-upload-field.element.ts index cff92c6807..50c283acec 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-upload-field/input-upload-field.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-upload-field/input-upload-field.element.ts @@ -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 = []; - constructor() { - super(); - } + #uploadAbort?: AbortController; override updated(changedProperties: PropertyValueMap | Map) { 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` - + disallowFolderUpload + accept=${ifDefined(this._extensions?.join(', '))} + @change=${this.#onUpload} + @click=${this.#handleBrowse}> + `; } - #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` +
+
+ ${when( + this.temporaryFile.status === TemporaryFileStatus.SUCCESS, + () => html``, + )} + ${when( + this.temporaryFile.status === TemporaryFileStatus.ERROR, + () => html``, + )} +
+
+
${this.temporaryFile.file.name}
+
${formatBytes(this.temporaryFile.file.size, { decimals: 2 })}: ${this._progress}%
+ ${when( + this.temporaryFile.status === TemporaryFileStatus.WAITING, + () => html`
`, + )} + ${when( + this.temporaryFile.status === TemporaryFileStatus.ERROR, + () => html`
An error occured
`, + )} +
+
+ ${when( + this.temporaryFile.status === TemporaryFileStatus.WAITING, + () => html` + + ${this.localize.term('general_cancel')} + + `, + () => this.#renderButtonRemove(), + )} +
+
+ `; + } + + #renderFile(src: string) { return html`
-
+
manifest.alias === previewAlias}> + .props=${{ path: src, file: this.temporaryFile?.file }} + .filter=${(manifest: ManifestFileUploadPreview) => manifest.alias === this._previewAlias}> - ${this.temporaryFile?.status === TemporaryFileStatus.WAITING - ? html`` - : nothing}
${this.#renderButtonRemove()} @@ -215,15 +284,21 @@ export class UmbInputUploadFieldElement extends UmbLitElement { } #renderButtonRemove() { - return html` - ${this.localize.term('content_uploadClear')} - `; + return html` + + ${this.localize.term('content_uploadClear')} + + `; } #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;