Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,12 @@ export class UmbResourceController extends UmbControllerBase {
});
});

if (options.abortSignal) {
options.abortSignal.addEventListener('abort', () => {
promise.cancel();
});
}

return promise;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ export interface XhrRequestOptions {
headers?: Record<string, string>;
responseHeader?: string;
onProgress?: (event: ProgressEvent) => void;
abortSignal?: AbortSignal;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -41,6 +42,7 @@ export class UmbTemporaryFileServerDataSource {
responseHeader: 'Umb-Generated-Resource',
body,
onProgress,
abortSignal,
});
return tryExecuteAndNotify(this.#host, xhrRequest);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 { formatBytes, stringOrStringArrayContains } from '@umbraco-cms/backoffice/utils';
import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';
import { UmbChangeEvent } from '@umbraco-cms/backoffice/event';

import { UmbExtensionsManifestInitializer } from '@umbraco-cms/backoffice/extension-api';
import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';
import { stringOrStringArrayContains } from '@umbraco-cms/backoffice/utils';
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 {
Expand All @@ -35,7 +35,6 @@ export class UmbInputUploadFieldElement extends UmbLitElement {
temporaryFileId: this.temporaryFile?.temporaryUnique,
};
}

#src = '';

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

@state()
private _progress = 0;

@state()
private _extensions?: string[];

Expand All @@ -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();
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
});

if (uploaded.status === TemporaryFileStatus.SUCCESS) {
this.temporaryFile.status = TemporaryFileStatus.SUCCESS;

const reader = new FileReader();
reader.onload = () => {
this.value = { src: reader.result as string };
};
reader.readAsDataURL(item.file);
const blobUrl = URL.createObjectURL(this.temporaryFile.file);
this.value = { src: blobUrl };

const uploaded = await upload;
if (uploaded.status === TemporaryFileStatus.SUCCESS) {
this.temporaryFile = { temporaryUnique: item.temporaryUnique, file: item.file };
this.dispatchEvent(new UmbChangeEvent());
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;
}
}

Expand All @@ -175,55 +202,103 @@ 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()}
`;
}

#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 = [
Expand All @@ -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;
Expand Down