From 4087f3fab7a25e0eef57413466fed3d198ac63d9 Mon Sep 17 00:00:00 2001 From: Mads Rasmussen Date: Tue, 15 Apr 2025 09:52:05 +0200 Subject: [PATCH] V16: Split media handling from UmbDropzoneManager (#19031) * move media handling into separate class * override manager + remove deprecated code * remove export * fix order * fix export of consts * Update src/Umbraco.Web.UI.Client/src/packages/media/media/dropzone/media-dropzone.manager.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix imports * fix wrong dropzone manager instance * remove unused * remove unused * lower MAX_CIRCULAR_DEPENDENCIES --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../devops/circular/index.js | 2 +- .../document-type-import-modal.element.ts | 3 +- .../input-dropzone/input-dropzone.element.ts | 11 +- .../src/packages/media/dropzone/constants.ts | 2 - .../media/dropzone/dropzone-manager.class.ts | 272 +----------------- .../src/packages/media/dropzone/index.ts | 5 +- .../src/packages/media/dropzone/manifests.ts | 2 +- .../src/packages/media/dropzone/types.ts | 11 - .../modal/media-type-import-modal.element.ts | 3 +- .../src/packages/media/media-types/types.ts | 11 + .../media/dropzone/dropzone-media.element.ts | 32 +-- .../packages/media/media/dropzone/index.ts | 1 + .../media/dropzone/media-dropzone.manager.ts | 230 +++++++++++++++ ...ropzone-media-type-picker-modal.element.ts | 0 .../dropzone-media-type-picker-modal.token.ts | 0 .../dropzone-media-type-picker/index.ts | 0 .../{ => media}/dropzone/modals/index.ts | 0 .../{ => media}/dropzone/modals/manifests.ts | 0 .../src/packages/media/media/index.ts | 5 - 19 files changed, 284 insertions(+), 306 deletions(-) create mode 100644 src/Umbraco.Web.UI.Client/src/packages/media/media/dropzone/media-dropzone.manager.ts rename src/Umbraco.Web.UI.Client/src/packages/media/{ => media}/dropzone/modals/dropzone-media-type-picker/dropzone-media-type-picker-modal.element.ts (100%) rename src/Umbraco.Web.UI.Client/src/packages/media/{ => media}/dropzone/modals/dropzone-media-type-picker/dropzone-media-type-picker-modal.token.ts (100%) rename src/Umbraco.Web.UI.Client/src/packages/media/{ => media}/dropzone/modals/dropzone-media-type-picker/index.ts (100%) rename src/Umbraco.Web.UI.Client/src/packages/media/{ => media}/dropzone/modals/index.ts (100%) rename src/Umbraco.Web.UI.Client/src/packages/media/{ => media}/dropzone/modals/manifests.ts (100%) diff --git a/src/Umbraco.Web.UI.Client/devops/circular/index.js b/src/Umbraco.Web.UI.Client/devops/circular/index.js index 6afc3511da..1a993925b9 100644 --- a/src/Umbraco.Web.UI.Client/devops/circular/index.js +++ b/src/Umbraco.Web.UI.Client/devops/circular/index.js @@ -12,7 +12,7 @@ import { join } from 'path'; //const __dirname = import.meta.dirname; // Adjust this number as needed. -const MAX_CIRCULAR_DEPENDENCIES = 5; +const MAX_CIRCULAR_DEPENDENCIES = 4; const IS_GITHUB_ACTIONS = process.env.GITHUB_ACTIONS === 'true'; const IS_AZURE_PIPELINES = process.env.TF_BUILD === 'true'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/import/modal/document-type-import-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/import/modal/document-type-import-modal.element.ts index 57473a2735..ac86e93ca7 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/import/modal/document-type-import-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/import/modal/document-type-import-modal.element.ts @@ -6,7 +6,8 @@ import type { import { css, html, customElement, state, when } from '@umbraco-cms/backoffice/external/lit'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import { UmbModalBaseElement } from '@umbraco-cms/backoffice/modal'; -import type { UmbDropzoneChangeEvent, UmbDropzoneMediaElement } from '@umbraco-cms/backoffice/media'; +import type { UmbDropzoneMediaElement } from '@umbraco-cms/backoffice/media'; +import type { UmbDropzoneChangeEvent } from '@umbraco-cms/backoffice/dropzone'; interface UmbDocumentTypePreview { unique: string; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/dropzone/components/input-dropzone/input-dropzone.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/dropzone/components/input-dropzone/input-dropzone.element.ts index 4eef81687e..b9aa852a88 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/dropzone/components/input-dropzone/input-dropzone.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/dropzone/components/input-dropzone/input-dropzone.element.ts @@ -93,14 +93,21 @@ export class UmbInputDropzoneElement extends UmbFormControlMixin - this.dispatchEvent(new ProgressEvent('progress', { loaded: progress.completed, total: progress.total })), + (progress) => { + this.dispatchEvent(new ProgressEvent('progress', { loaded: progress.completed, total: progress.total })); + }, '_observeProgress', ); + } + protected _observeProgressItems() { this.observe( this._manager.progressItems, (progressItems) => { diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/dropzone/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/media/dropzone/constants.ts index df1c3a9d8b..8f31d55bd4 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/dropzone/constants.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/dropzone/constants.ts @@ -1,5 +1,3 @@ -export { UMB_DROPZONE_MEDIA_TYPE_PICKER_MODAL } from './modals/dropzone-media-type-picker/dropzone-media-type-picker-modal.token.js'; - export enum UmbFileDropzoneItemStatus { WAITING = 'waiting', COMPLETE = 'complete', diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/dropzone/dropzone-manager.class.ts b/src/Umbraco.Web.UI.Client/src/packages/media/dropzone/dropzone-manager.class.ts index b40fda6a8c..41c117162f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/dropzone/dropzone-manager.class.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/dropzone/dropzone-manager.class.ts @@ -1,13 +1,9 @@ import { UmbFileDropzoneItemStatus } from './constants.js'; -import { UMB_DROPZONE_MEDIA_TYPE_PICKER_MODAL } from './modals/index.js'; import type { UmbUploadableFile, - UmbUploadableFolder, UmbFileDropzoneDroppedItems, UmbFileDropzoneProgress, UmbUploadableItem, - UmbAllowedMediaTypesOfExtension, - UmbAllowedChildrenOfMediaType, } from './types.js'; import { TemporaryFileStatus, @@ -17,18 +13,6 @@ import { import { UmbArrayState, UmbObjectState } from '@umbraco-cms/backoffice/observable-api'; import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; import { UmbId } from '@umbraco-cms/backoffice/id'; -import { UmbMediaTypeStructureRepository } from '@umbraco-cms/backoffice/media-type'; -import { umbOpenModal } from '@umbraco-cms/backoffice/modal'; -import type { UmbAllowedMediaTypeModel } from '@umbraco-cms/backoffice/media-type'; -import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; -import { UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification'; -import { UmbLocalizationController } from '@umbraco-cms/backoffice/localization-api'; -import { - UMB_MEDIA_PROPERTY_VALUE_ENTITY_TYPE, - UmbMediaDetailRepository, - type UmbMediaDetailModel, - type UmbMediaValueModel, -} from '@umbraco-cms/backoffice/media'; /** * Manages the dropzone and uploads folders and files to the server. @@ -38,22 +22,7 @@ import { * @observable progressItems - Emits the items with their current status. */ export class UmbDropzoneManager extends UmbControllerBase { - readonly #host: UmbControllerHost; - /** - * @deprecated Not used anymore; this method will be removed in Umbraco 17. - */ - #isFoldersAllowed = true; - - #mediaTypeStructure = new UmbMediaTypeStructureRepository(this); - #mediaDetailRepository = new UmbMediaDetailRepository(this); - - #tempFileManager = new UmbTemporaryFileManager(this); - - // The available media types for a file extension. - readonly #availableMediaTypesOf = new UmbArrayState([], (x) => x.extension); - - // The media types that the parent will allow to be created under it. - readonly #allowedChildrenOf = new UmbArrayState([], (x) => x.mediaTypeUnique); + protected readonly _tempFileManager = new UmbTemporaryFileManager(this); readonly #progress = new UmbObjectState({ total: 0, completed: 0 }); public readonly progress = this.#progress.asObservable(); @@ -61,83 +30,27 @@ export class UmbDropzoneManager extends UmbControllerBase { readonly #progressItems = new UmbArrayState([], (x) => x.unique); public readonly progressItems = this.#progressItems.asObservable(); - #notificationContext?: typeof UMB_NOTIFICATION_CONTEXT.TYPE; - #localization = new UmbLocalizationController(this); - - constructor(host: UmbControllerHost) { - super(host); - this.#host = host; - - this.consumeContext(UMB_NOTIFICATION_CONTEXT, (context) => { - this.#notificationContext = context; - }); - } - - /** - * @param isAllowed - * @deprecated Not used anymore; this method will be removed in Umbraco 17. - */ - public setIsFoldersAllowed(isAllowed: boolean) { - this.#isFoldersAllowed = isAllowed; - } - - /** - * @deprecated Not used anymore; this method will be removed in Umbraco 17. - */ - public getIsFoldersAllowed(): boolean { - return this.#isFoldersAllowed; - } - - /** @deprecated Please use `createMediaItems()` instead; this method will be removed in Umbraco 17. */ - public createFilesAsMedia = this.createMediaItems; - - /** - * Uploads files and folders to the server and creates the media items with corresponding media type.\ - * Allows the user to pick a media type option if multiple types are allowed. - * @param {UmbFileDropzoneDroppedItems} items - The files and folders to upload. - * @param {string | null} parentUnique - Where the items should be uploaded. - * @returns {Array} - The items about to be uploaded. - */ - public createMediaItems(items: UmbFileDropzoneDroppedItems, parentUnique: string | null = null) { - const uploadableItems = this.#setupProgress(items, parentUnique); - - if (!uploadableItems.length) return []; - - if (uploadableItems.length === 1) { - // When there is only one item being uploaded, allow the user to pick the media type, if more than one is allowed. - this.#createOneMediaItem(uploadableItems[0]); - } else { - // When there are multiple items being uploaded, automatically pick the media types for each item. We probably want to allow the user to pick the media type in the future. - this.#createMediaItems(uploadableItems); - } - - return uploadableItems; - } - - /** @deprecated Please use `createTemporaryFiles()` instead; this method will be removed in Umbraco 17. */ - public createFilesAsTemporary = this.createTemporaryFiles; - /** * Uploads the files as temporary files and returns the data. * @param { File[] } files - The files to upload. * @returns {Promise>} - Files as temporary files. */ public async createTemporaryFiles(files: Array): Promise> { - const uploadableItems = this.#setupProgress({ files, folders: [] }, null) as Array; + const uploadableItems = this._setupProgress({ files, folders: [] }, null) as Array; const uploadedItems: Array = []; for (const item of uploadableItems) { // Upload as temp file - const uploaded = await this.#tempFileManager.uploadOne(item.temporaryFile); + const uploaded = await this._tempFileManager.uploadOne(item.temporaryFile); // Update progress if (uploaded.status === TemporaryFileStatus.CANCELLED) { - this.#updateStatus(item, UmbFileDropzoneItemStatus.CANCELLED); + this._updateStatus(item, UmbFileDropzoneItemStatus.CANCELLED); } else if (uploaded.status === TemporaryFileStatus.SUCCESS) { - this.#updateStatus(item, UmbFileDropzoneItemStatus.COMPLETE); + this._updateStatus(item, UmbFileDropzoneItemStatus.COMPLETE); } else { - this.#updateStatus(item, UmbFileDropzoneItemStatus.ERROR); + this._updateStatus(item, UmbFileDropzoneItemStatus.ERROR); } // Add to return value @@ -151,7 +64,7 @@ export class UmbDropzoneManager extends UmbControllerBase { item.temporaryFile?.abortController?.abort(); this.#progressItems.removeOne(item.unique); if (item.temporaryFile) { - this.#tempFileManager.removeOne(item.temporaryFile.temporaryUnique); + this._tempFileManager.removeOne(item.temporaryFile.temporaryUnique); } } @@ -165,7 +78,7 @@ export class UmbDropzoneManager extends UmbControllerBase { } this.#progressItems.remove(uniques); const temporaryUniques = items.map((x) => x.temporaryFile?.temporaryUnique).filter((x): x is string => !!x); - this.#tempFileManager.remove(temporaryUniques); + this._tempFileManager.remove(temporaryUniques); } public removeAll() { @@ -173,170 +86,11 @@ export class UmbDropzoneManager extends UmbControllerBase { item.temporaryFile?.abortController?.abort(); } this.#progressItems.setValue([]); - this.#tempFileManager.removeAll(); - } - - async #showDialogMediaTypePicker(options: Array) { - const value = await umbOpenModal(this.#host, UMB_DROPZONE_MEDIA_TYPE_PICKER_MODAL, { data: { options } }).catch( - () => undefined, - ); - return value?.mediaTypeUnique; - } - - async #createOneMediaItem(item: UmbUploadableItem) { - const options = await this.#getMediaTypeOptions(item); - if (!options.length) { - this.#notificationContext?.peek('warning', { - data: { - message: `${this.#localization.term('media_disallowedFileType')}: ${item.temporaryFile?.file.name}.`, - }, - }); - return this.#updateStatus(item, UmbFileDropzoneItemStatus.NOT_ALLOWED); - } - - const mediaTypeUnique = options.length > 1 ? await this.#showDialogMediaTypePicker(options) : options[0].unique; - - if (!mediaTypeUnique) { - return this.#updateStatus(item, UmbFileDropzoneItemStatus.CANCELLED); - } - - if (item.temporaryFile) { - this.#handleFile(item as UmbUploadableFile, mediaTypeUnique); - } else if (item.folder) { - this.#handleFolder(item as UmbUploadableFolder, mediaTypeUnique); - } - } - - async #createMediaItems(uploadableItems: Array) { - for (const item of uploadableItems) { - const options = await this.#getMediaTypeOptions(item); - if (!options.length) { - this.#updateStatus(item, UmbFileDropzoneItemStatus.NOT_ALLOWED); - continue; - } - - const mediaTypeUnique = options[0].unique; - - if (!mediaTypeUnique) { - throw new Error('Media type unique is not defined'); - } - - // Handle files and folders differently: a file is uploaded as temp then created as a media item, and a folder is created as a media item directly - if (item.temporaryFile) { - this.#handleFile(item as UmbUploadableFile, mediaTypeUnique); - } else if (item.folder) { - this.#handleFolder(item as UmbUploadableFolder, mediaTypeUnique); - } - } - } - - async #handleFile(item: UmbUploadableFile, mediaTypeUnique: string) { - // Upload the file as a temporary file and update progress. - const temporaryFile = await this.#tempFileManager.uploadOne(item.temporaryFile); - - if (temporaryFile.status === TemporaryFileStatus.CANCELLED) { - this.#updateStatus(item, UmbFileDropzoneItemStatus.CANCELLED); - return; - } - if (temporaryFile.status !== TemporaryFileStatus.SUCCESS) { - this.#updateStatus(item, UmbFileDropzoneItemStatus.ERROR); - return; - } - - // Create the media item. - const scaffold = await this.#getItemScaffold(item, mediaTypeUnique); - const { data } = await this.#mediaDetailRepository.create(scaffold, item.parentUnique); - - if (data) { - this.#updateStatus(item, UmbFileDropzoneItemStatus.COMPLETE); - } else { - this.#updateStatus(item, UmbFileDropzoneItemStatus.ERROR); - } - } - - async #handleFolder(item: UmbUploadableFolder, mediaTypeUnique: string) { - const scaffold = await this.#getItemScaffold(item, mediaTypeUnique); - const { data } = await this.#mediaDetailRepository.create(scaffold, item.parentUnique); - if (data) { - this.#updateStatus(item, UmbFileDropzoneItemStatus.COMPLETE); - } else { - this.#updateStatus(item, UmbFileDropzoneItemStatus.ERROR); - } - } - - // Media types - async #getMediaTypeOptions(item: UmbUploadableItem): Promise> { - // Check the parent which children media types are allowed - const parent = item.parentUnique ? await this.#mediaDetailRepository.requestByUnique(item.parentUnique) : null; - const allowedChildren = await this.#getAllowedChildrenOf(parent?.data?.mediaType.unique ?? null, item.parentUnique); - - const extension = item.temporaryFile?.file.name.split('.').pop() ?? null; - - // Check which media types allow the file's extension - const availableMediaType = await this.#getAvailableMediaTypesOf(extension); - - if (!availableMediaType.length) return []; - - const options = allowedChildren.filter((x) => availableMediaType.find((y) => y.unique === x.unique)); - return options; - } - - async #getAvailableMediaTypesOf(extension: string | null) { - // Check if we already have information on this file extension. - const available = this.#availableMediaTypesOf - .getValue() - .find((x) => x.extension === extension)?.availableMediaTypes; - if (available) return available; - - // Request information on this file extension - const availableMediaTypes = extension - ? await this.#mediaTypeStructure.requestMediaTypesOf({ fileExtension: extension }) - : await this.#mediaTypeStructure.requestMediaTypesOfFolders(); - - this.#availableMediaTypesOf.appendOne({ extension, availableMediaTypes }); - return availableMediaTypes; - } - - async #getAllowedChildrenOf(mediaTypeUnique: string | null, parentUnique: string | null) { - //Check if we already got information on this media type. - const allowed = this.#allowedChildrenOf - .getValue() - .find((x) => x.mediaTypeUnique === mediaTypeUnique)?.allowedChildren; - if (allowed) return allowed; - - // Request information on this media type. - const { data } = await this.#mediaTypeStructure.requestAllowedChildrenOf(mediaTypeUnique, parentUnique); - if (!data) throw new Error('Parent media type does not exists'); - - this.#allowedChildrenOf.appendOne({ mediaTypeUnique, allowedChildren: data.items }); - return data.items; - } - - // Scaffold - async #getItemScaffold(item: UmbUploadableItem, mediaTypeUnique: string): Promise { - // TODO: Use a scaffolding feature to ensure consistency. [NL] - const name = item.temporaryFile ? item.temporaryFile.file.name : (item.folder?.name ?? ''); - const umbracoFile: UmbMediaValueModel = { - editorAlias: '', - alias: 'umbracoFile', - value: { temporaryFileId: item.temporaryFile?.temporaryUnique }, - culture: null, - segment: null, - entityType: UMB_MEDIA_PROPERTY_VALUE_ENTITY_TYPE, - }; - - const preset: Partial = { - unique: item.unique, - mediaType: { unique: mediaTypeUnique, collection: null }, - variants: [{ culture: null, segment: null, createDate: null, updateDate: null, name }], - values: item.temporaryFile ? [umbracoFile] : undefined, - }; - const { data } = await this.#mediaDetailRepository.createScaffold(preset); - return data!; + this._tempFileManager.removeAll(); } // Progress handling - #setupProgress(items: UmbFileDropzoneDroppedItems, parent: string | null) { + protected _setupProgress(items: UmbFileDropzoneDroppedItems, parent: string | null) { const current = this.#progress.getValue(); const currentItems = this.#progressItems.getValue(); @@ -348,7 +102,7 @@ export class UmbDropzoneManager extends UmbControllerBase { return uploadableItems; } - #updateStatus(item: UmbUploadableItem, status: UmbFileDropzoneItemStatus) { + protected _updateStatus(item: UmbUploadableItem, status: UmbFileDropzoneItemStatus) { this.#progressItems.updateOne(item.unique, { status }); const progress = this.#progress.getValue(); this.#progress.update({ completed: progress.completed + 1 }); @@ -381,7 +135,7 @@ export class UmbDropzoneManager extends UmbControllerBase { }; temporaryFile.abortController?.signal.addEventListener('abort', () => { - this.#updateStatus(uploadableItem, UmbFileDropzoneItemStatus.CANCELLED); + this._updateStatus(uploadableItem, UmbFileDropzoneItemStatus.CANCELLED); }); items.push(uploadableItem); @@ -403,7 +157,7 @@ export class UmbDropzoneManager extends UmbControllerBase { }; public override destroy() { - this.#tempFileManager.destroy(); + this._tempFileManager.destroy(); super.destroy(); } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/dropzone/index.ts b/src/Umbraco.Web.UI.Client/src/packages/media/dropzone/index.ts index 9a15a46dc6..5a39d58aa6 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/dropzone/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/dropzone/index.ts @@ -1,7 +1,6 @@ -export * from './constants.js'; export * from './components/index.js'; -export * from './modals/index.js'; +export * from './constants.js'; +export * from './dropzone-change.event.js'; export * from './dropzone-manager.class.js'; export * from './dropzone-submitted.event.js'; -export * from './dropzone-change.event.js'; export type * from './types.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/dropzone/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/media/dropzone/manifests.ts index 6e046c0210..ee8a1922bc 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/dropzone/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/dropzone/manifests.ts @@ -1,3 +1,3 @@ -import { manifests as modalManifests } from './modals/manifests.js'; +import { manifests as modalManifests } from '../media/dropzone/modals/manifests.js'; export const manifests: Array = [...modalManifests]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/dropzone/types.ts b/src/Umbraco.Web.UI.Client/src/packages/media/dropzone/types.ts index 24ed77b094..21d22dd726 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/dropzone/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/dropzone/types.ts @@ -1,6 +1,5 @@ import type { UmbFileDropzoneItemStatus } from './constants.js'; import type { UUIFileFolder } from '@umbraco-cms/backoffice/external/uui'; -import type { UmbAllowedMediaTypeModel } from '@umbraco-cms/backoffice/media-type'; import type { UmbTemporaryFileModel } from '@umbraco-cms/backoffice/temporary-file'; export interface UmbFileDropzoneDroppedItems { @@ -25,16 +24,6 @@ export interface UmbUploadableFolder extends UmbUploadableItem { folder: { name: string }; } -export interface UmbAllowedMediaTypesOfExtension { - extension: string | null; // Null is considered a folder. - availableMediaTypes: Array; -} - -export interface UmbAllowedChildrenOfMediaType { - mediaTypeUnique: string | null; - allowedChildren: Array; -} - export interface UmbFileDropzoneProgress { total: number; completed: number; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/entity-actions/import/modal/media-type-import-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/entity-actions/import/modal/media-type-import-modal.element.ts index 46d4c64ba3..2ebb3c35e7 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/entity-actions/import/modal/media-type-import-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/entity-actions/import/modal/media-type-import-modal.element.ts @@ -3,7 +3,8 @@ import type { UmbMediaTypeImportModalData, UmbMediaTypeImportModalValue } from ' import { css, html, customElement, state, when } from '@umbraco-cms/backoffice/external/lit'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import { UmbModalBaseElement } from '@umbraco-cms/backoffice/modal'; -import type { UmbDropzoneChangeEvent, UmbDropzoneMediaElement } from '@umbraco-cms/backoffice/media'; +import type { UmbDropzoneMediaElement } from '@umbraco-cms/backoffice/media'; +import type { UmbDropzoneChangeEvent } from '@umbraco-cms/backoffice/dropzone'; interface UmbMediaTypePreview { unique: string; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/types.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/types.ts index d6ecac3eee..a46169e2b6 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/types.ts @@ -1,4 +1,5 @@ import type { UmbMediaTypeEntityType } from './entity.js'; +import type { UmbAllowedMediaTypeModel } from './repository/types.js'; import type { UmbContentTypeAvailableCompositionRequestModel, UmbContentTypeCompositionCompatibleModel, @@ -23,3 +24,13 @@ export interface UmbMediaTypeCompositionCompatibleModel extends UmbContentTypeCo // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface UmbMediaTypeCompositionReferenceModel extends UmbContentTypeCompositionReferenceModel {} + +export interface UmbAllowedMediaTypesOfExtension { + extension: string | null; // Null is considered a folder. + availableMediaTypes: Array; +} + +export interface UmbAllowedChildrenOfMediaType { + mediaTypeUnique: string | null; + allowedChildren: Array; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/dropzone/dropzone-media.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/dropzone/dropzone-media.element.ts index 75adf08d70..ac998a0d7d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/dropzone/dropzone-media.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/dropzone/dropzone-media.element.ts @@ -1,3 +1,4 @@ +import { UmbMediaDropzoneManager } from './media-dropzone.manager.js'; import { UmbInputDropzoneElement, UmbFileDropzoneItemStatus, @@ -21,19 +22,6 @@ export class UmbDropzoneMediaElement extends UmbInputDropzoneElement { @property({ attribute: 'parent-unique' }) parentUnique: string | null = null; - /** - * Determines if the dropzone should create temporary files or media items directly. - * @deprecated Use the {@link UmbInputDropzoneElement} instead. - */ - @property({ type: Boolean, attribute: 'create-as-temporary' }) - createAsTemporary: boolean = false; - - /** - * @deprecated Please use `getItems()` instead; this method will be removed in Umbraco 17. - * @returns {Array} An array of uploadable items. - */ - public getFiles = this.getItems; - /** * Gets the current value of the uploaded items. * @returns {Array} An array of uploadable items. @@ -42,6 +30,7 @@ export class UmbDropzoneMediaElement extends UmbInputDropzoneElement { return this._progressItems; } + protected override _manager = new UmbMediaDropzoneManager(this); public progressItems = () => this._manager.progressItems; public progress = () => this._manager.progress; @@ -52,6 +41,14 @@ export class UmbDropzoneMediaElement extends UmbInputDropzoneElement { document.addEventListener('dragleave', this.#handleDragLeave.bind(this)); document.addEventListener('drop', this.#handleDrop.bind(this)); + // TODO: Revisit this. I am not sure why it is needed to call these methods here when they are already called in the constructor of the parent class. + // If we do not call them here, the observer will use the wrong instance of the dropzone manager (UmbDropZoneManager instead of UmbMediaDropzoneManager). + this._observeProgress(); + this._observeProgressItems(); + } + + protected override _observeProgressItems() { + super._observeProgressItems(); this.observe( this._manager.progressItems, (progressItems: Array) => { @@ -75,13 +72,8 @@ export class UmbDropzoneMediaElement extends UmbInputDropzoneElement { if (this.disabled) return; if (!event.detail.files.length && !event.detail.folders.length) return; - if (this.createAsTemporary) { - const uploadable = this._manager.createTemporaryFiles(event.detail.files); - this.dispatchEvent(new UmbDropzoneSubmittedEvent(await uploadable)); - } else { - const uploadable = this._manager.createMediaItems(event.detail, this.parentUnique); - this.dispatchEvent(new UmbDropzoneSubmittedEvent(uploadable)); - } + const uploadable = this._manager.createMediaItems(event.detail, this.parentUnique); + this.dispatchEvent(new UmbDropzoneSubmittedEvent(uploadable)); } #handleDragEnter(e: DragEvent) { diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/dropzone/index.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/dropzone/index.ts index 587d8c7b20..aa20b9cfc2 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/dropzone/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/dropzone/index.ts @@ -1,2 +1,3 @@ export * from './dropzone-media.element.js'; export * from './dropzone.element.js'; +export * from './modals/index.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/dropzone/media-dropzone.manager.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/dropzone/media-dropzone.manager.ts new file mode 100644 index 0000000000..2fd41b6421 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/dropzone/media-dropzone.manager.ts @@ -0,0 +1,230 @@ +import { UmbMediaDetailRepository } from '../repository/detail/index.js'; +import type { UmbMediaDetailModel, UmbMediaValueModel } from '../types.js'; +import { UMB_MEDIA_PROPERTY_VALUE_ENTITY_TYPE } from '../entity.js'; +import { UMB_DROPZONE_MEDIA_TYPE_PICKER_MODAL } from './modals/index.js'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { + UmbDropzoneManager, + UmbFileDropzoneItemStatus, + type UmbFileDropzoneDroppedItems, + type UmbUploadableFile, + type UmbUploadableFolder, + type UmbUploadableItem, +} from '@umbraco-cms/backoffice/dropzone'; +import { + UmbMediaTypeStructureRepository, + type UmbAllowedChildrenOfMediaType, + type UmbAllowedMediaTypeModel, + type UmbAllowedMediaTypesOfExtension, +} from '@umbraco-cms/backoffice/media-type'; +import { UmbArrayState } from '@umbraco-cms/backoffice/observable-api'; +import { TemporaryFileStatus } from '@umbraco-cms/backoffice/temporary-file'; +import { umbOpenModal } from '@umbraco-cms/backoffice/modal'; +import { UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification'; +import { UmbLocalizationController } from '@umbraco-cms/backoffice/localization-api'; + +export class UmbMediaDropzoneManager extends UmbDropzoneManager { + // The available media types for a file extension. + readonly #availableMediaTypesOf = new UmbArrayState([], (x) => x.extension); + + // The media types that the parent will allow to be created under it. + readonly #allowedChildrenOf = new UmbArrayState([], (x) => x.mediaTypeUnique); + + #mediaTypeStructure = new UmbMediaTypeStructureRepository(this); + #mediaDetailRepository = new UmbMediaDetailRepository(this); + #notificationContext?: typeof UMB_NOTIFICATION_CONTEXT.TYPE; + #localization = new UmbLocalizationController(this); + + constructor(host: UmbControllerHost) { + super(host); + + this.consumeContext(UMB_NOTIFICATION_CONTEXT, (context) => { + this.#notificationContext = context; + }); + } + + /** + * Uploads files and folders to the server and creates the media items with corresponding media type.\ + * Allows the user to pick a media type option if multiple types are allowed. + * @param {UmbFileDropzoneDroppedItems} items - The files and folders to upload. + * @param {string | null} parentUnique - Where the items should be uploaded. + * @returns {Array} - The items about to be uploaded. + */ + public createMediaItems( + items: UmbFileDropzoneDroppedItems, + parentUnique: string | null = null, + ): Array { + const uploadableItems = this._setupProgress(items, parentUnique); + + if (!uploadableItems.length) return []; + + if (uploadableItems.length === 1) { + // When there is only one item being uploaded, allow the user to pick the media type, if more than one is allowed. + this.#createOneMediaItem(uploadableItems[0]); + } else { + // When there are multiple items being uploaded, automatically pick the media types for each item. We probably want to allow the user to pick the media type in the future. + this.#createMediaItems(uploadableItems); + } + + return uploadableItems; + } + + async #createMediaItems(uploadableItems: Array) { + for (const item of uploadableItems) { + const options = await this.#getMediaTypeOptions(item); + if (!options.length) { + this._updateStatus(item, UmbFileDropzoneItemStatus.NOT_ALLOWED); + continue; + } + + const mediaTypeUnique = options[0].unique; + + if (!mediaTypeUnique) { + throw new Error('Media type unique is not defined'); + } + + // Handle files and folders differently: a file is uploaded as temp then created as a media item, and a folder is created as a media item directly + if (item.temporaryFile) { + this.#handleFile(item as UmbUploadableFile, mediaTypeUnique); + } else if (item.folder) { + this.#handleFolder(item as UmbUploadableFolder, mediaTypeUnique); + } + } + } + + async #handleFile(item: UmbUploadableFile, mediaTypeUnique: string) { + // Upload the file as a temporary file and update progress. + const temporaryFile = await this._tempFileManager.uploadOne(item.temporaryFile); + + if (temporaryFile.status === TemporaryFileStatus.CANCELLED) { + this._updateStatus(item, UmbFileDropzoneItemStatus.CANCELLED); + return; + } + if (temporaryFile.status !== TemporaryFileStatus.SUCCESS) { + this._updateStatus(item, UmbFileDropzoneItemStatus.ERROR); + return; + } + + // Create the media item. + const scaffold = await this.#getItemScaffold(item, mediaTypeUnique); + const { data } = await this.#mediaDetailRepository.create(scaffold, item.parentUnique); + + if (data) { + this._updateStatus(item, UmbFileDropzoneItemStatus.COMPLETE); + } else { + this._updateStatus(item, UmbFileDropzoneItemStatus.ERROR); + } + } + + async #handleFolder(item: UmbUploadableFolder, mediaTypeUnique: string) { + const scaffold = await this.#getItemScaffold(item, mediaTypeUnique); + const { data } = await this.#mediaDetailRepository.create(scaffold, item.parentUnique); + if (data) { + this._updateStatus(item, UmbFileDropzoneItemStatus.COMPLETE); + } else { + this._updateStatus(item, UmbFileDropzoneItemStatus.ERROR); + } + } + + // Media types + async #getMediaTypeOptions(item: UmbUploadableItem): Promise> { + // Check the parent which children media types are allowed + const parent = item.parentUnique ? await this.#mediaDetailRepository.requestByUnique(item.parentUnique) : null; + const allowedChildren = await this.#getAllowedChildrenOf(parent?.data?.mediaType.unique ?? null, item.parentUnique); + + const extension = item.temporaryFile?.file.name.split('.').pop() ?? null; + + // Check which media types allow the file's extension + const availableMediaType = await this.#getAvailableMediaTypesOf(extension); + + if (!availableMediaType.length) return []; + + const options = allowedChildren.filter((x) => availableMediaType.find((y) => y.unique === x.unique)); + return options; + } + + async #getAvailableMediaTypesOf(extension: string | null) { + // Check if we already have information on this file extension. + const available = this.#availableMediaTypesOf + .getValue() + .find((x) => x.extension === extension)?.availableMediaTypes; + if (available) return available; + + // Request information on this file extension + const availableMediaTypes = extension + ? await this.#mediaTypeStructure.requestMediaTypesOf({ fileExtension: extension }) + : await this.#mediaTypeStructure.requestMediaTypesOfFolders(); + + this.#availableMediaTypesOf.appendOne({ extension, availableMediaTypes }); + return availableMediaTypes; + } + + async #getAllowedChildrenOf(mediaTypeUnique: string | null, parentUnique: string | null) { + //Check if we already got information on this media type. + const allowed = this.#allowedChildrenOf + .getValue() + .find((x) => x.mediaTypeUnique === mediaTypeUnique)?.allowedChildren; + if (allowed) return allowed; + + // Request information on this media type. + const { data } = await this.#mediaTypeStructure.requestAllowedChildrenOf(mediaTypeUnique, parentUnique); + if (!data) throw new Error('Parent media type does not exist'); + + this.#allowedChildrenOf.appendOne({ mediaTypeUnique, allowedChildren: data.items }); + return data.items; + } + + // Scaffold + async #getItemScaffold(item: UmbUploadableItem, mediaTypeUnique: string): Promise { + // TODO: Use a scaffolding feature to ensure consistency. [NL] + const name = item.temporaryFile ? item.temporaryFile.file.name : (item.folder?.name ?? ''); + const umbracoFile: UmbMediaValueModel = { + editorAlias: '', + alias: 'umbracoFile', + value: { temporaryFileId: item.temporaryFile?.temporaryUnique }, + culture: null, + segment: null, + entityType: UMB_MEDIA_PROPERTY_VALUE_ENTITY_TYPE, + }; + + const preset: Partial = { + unique: item.unique, + mediaType: { unique: mediaTypeUnique, collection: null }, + variants: [{ culture: null, segment: null, createDate: null, updateDate: null, name }], + values: item.temporaryFile ? [umbracoFile] : undefined, + }; + const { data } = await this.#mediaDetailRepository.createScaffold(preset); + return data!; + } + + async #showDialogMediaTypePicker(options: Array) { + const value = await umbOpenModal(this, UMB_DROPZONE_MEDIA_TYPE_PICKER_MODAL, { data: { options } }).catch( + () => undefined, + ); + return value?.mediaTypeUnique; + } + + async #createOneMediaItem(item: UmbUploadableItem) { + const options = await this.#getMediaTypeOptions(item); + if (!options.length) { + this.#notificationContext?.peek('warning', { + data: { + message: `${this.#localization.term('media_disallowedFileType')}: ${item.temporaryFile?.file.name}.`, + }, + }); + return this._updateStatus(item, UmbFileDropzoneItemStatus.NOT_ALLOWED); + } + + const mediaTypeUnique = options.length > 1 ? await this.#showDialogMediaTypePicker(options) : options[0].unique; + + if (!mediaTypeUnique) { + return this._updateStatus(item, UmbFileDropzoneItemStatus.CANCELLED); + } + + if (item.temporaryFile) { + this.#handleFile(item as UmbUploadableFile, mediaTypeUnique); + } else if (item.folder) { + this.#handleFolder(item as UmbUploadableFolder, mediaTypeUnique); + } + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/dropzone/modals/dropzone-media-type-picker/dropzone-media-type-picker-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/dropzone/modals/dropzone-media-type-picker/dropzone-media-type-picker-modal.element.ts similarity index 100% rename from src/Umbraco.Web.UI.Client/src/packages/media/dropzone/modals/dropzone-media-type-picker/dropzone-media-type-picker-modal.element.ts rename to src/Umbraco.Web.UI.Client/src/packages/media/media/dropzone/modals/dropzone-media-type-picker/dropzone-media-type-picker-modal.element.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/dropzone/modals/dropzone-media-type-picker/dropzone-media-type-picker-modal.token.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/dropzone/modals/dropzone-media-type-picker/dropzone-media-type-picker-modal.token.ts similarity index 100% rename from src/Umbraco.Web.UI.Client/src/packages/media/dropzone/modals/dropzone-media-type-picker/dropzone-media-type-picker-modal.token.ts rename to src/Umbraco.Web.UI.Client/src/packages/media/media/dropzone/modals/dropzone-media-type-picker/dropzone-media-type-picker-modal.token.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/dropzone/modals/dropzone-media-type-picker/index.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/dropzone/modals/dropzone-media-type-picker/index.ts similarity index 100% rename from src/Umbraco.Web.UI.Client/src/packages/media/dropzone/modals/dropzone-media-type-picker/index.ts rename to src/Umbraco.Web.UI.Client/src/packages/media/media/dropzone/modals/dropzone-media-type-picker/index.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/dropzone/modals/index.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/dropzone/modals/index.ts similarity index 100% rename from src/Umbraco.Web.UI.Client/src/packages/media/dropzone/modals/index.ts rename to src/Umbraco.Web.UI.Client/src/packages/media/media/dropzone/modals/index.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/dropzone/modals/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/dropzone/modals/manifests.ts similarity index 100% rename from src/Umbraco.Web.UI.Client/src/packages/media/dropzone/modals/manifests.ts rename to src/Umbraco.Web.UI.Client/src/packages/media/media/dropzone/modals/manifests.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/index.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/index.ts index 5b67cd5326..3b40d852fc 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/index.ts @@ -9,8 +9,3 @@ export * from './url/index.js'; export { UmbMediaAuditLogRepository } from './audit-log/index.js'; export type * from './types.js'; - -/** - * @deprecated Please import directly from the `@umbraco-cms/backoffice/dropzone` package instead. This package will be removed in Umbraco 18. - */ -export * from '@umbraco-cms/backoffice/dropzone';