From 16a5f8d138c1c5c2987ae83a835b6c7c54d681c5 Mon Sep 17 00:00:00 2001 From: Mads Rasmussen Date: Mon, 25 Nov 2024 18:16:19 +0100 Subject: [PATCH] Media Picker: only allow navigating to folders/media with children + other fixes (#17617) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * only allow navigating into folders or item with children * export media search provider * mark search on media item repo as deprecated * use media search provider for searching * rename method * change the look of the upload button * only render checkbox if we are not in the root * clear search when navigating * add type * set value so it gets updated when cleared * default to search within an item * hide breadcrumb if searching in root * scope search on server * Update media-picker-modal.element.ts * hide breadcrumb when doing a global search within another item * add selection mode * remove unused state * handle start node when searching * fix if wrong order * fix type error * pass start node to breadcrumb * handle start node in breadcrumb * make start node optional * map data * clean up * Update media-picker-folder-path.element.ts * add searching load indicator * don't show unique in detail * Add information to item response model * Update OpenApi.json * generate new server models * update mocks * move interface to types * add hasChildren and parent to media item model interface * fix import * map data * map media item * treat tree item and search result the same * Fix: bump uui version (#17626) * lint fix * temp fix for media selection * UX corrections for media selection * temp uui fix for media picker modal * fix table selection mode * fix search from when having a start node * remove private * wait for all missing parts before create table items --------- Co-authored-by: nikolajlauridsen Co-authored-by: Niels Lyngsø --- .../document-blueprint.db.ts | 2 + .../src/mocks/data/document/document.db.ts | 2 + .../src/mocks/data/media/media.db.ts | 2 + .../core/components/table/table.element.ts | 33 ++- .../content-type-workspace-context-base.ts | 7 +- .../src/packages/core/models/index.ts | 1 + .../packages/core/sorter/sorter.controller.ts | 2 +- .../entity-detail-workspace-editor.element.ts | 2 +- .../document-type-workspace.context.ts | 5 +- .../item/document-item.server.data-source.ts | 16 +- .../documents/repository/item/types.ts | 13 +- .../search/document-search.repository.ts | 2 +- .../document-search.server.data-source.ts | 20 +- .../search/document.search-provider.ts | 5 +- .../documents/documents/search/types.ts | 5 + .../language/workspace/language-root/paths.ts | 2 +- .../language-workspace-editor.element.ts | 2 +- .../workspace/media-type-workspace.context.ts | 4 +- .../media-grid-collection-view.element.ts | 7 +- .../media-table-collection-view.element.ts | 12 +- .../input-media/input-media.element.ts | 1 - .../src/packages/media/media/index.ts | 7 +- .../media-picker-folder-path.element.ts | 45 ++- .../media-picker-modal.element.ts | 271 +++++++++++++----- .../repository/item/media-item.repository.ts | 7 + .../item/media-item.server.data-source.ts | 15 +- .../media/media/repository/item/types.ts | 7 +- .../src/packages/media/media/search/index.ts | 2 + .../media/search/media-search.repository.ts | 2 +- .../search/media-search.server.data-source.ts | 15 +- .../media/search/media.search-provider.ts | 6 +- .../src/packages/media/media/search/types.ts | 5 + .../member-type-workspace.context.ts | 2 +- 33 files changed, 371 insertions(+), 158 deletions(-) create mode 100644 src/Umbraco.Web.UI.Client/src/packages/documents/documents/search/types.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/media/media/search/index.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/media/media/search/types.ts diff --git a/src/Umbraco.Web.UI.Client/src/mocks/data/document-blueprint/document-blueprint.db.ts b/src/Umbraco.Web.UI.Client/src/mocks/data/document-blueprint/document-blueprint.db.ts index 6bc4f57c42db..dd0416a99a76 100644 --- a/src/Umbraco.Web.UI.Client/src/mocks/data/document-blueprint/document-blueprint.db.ts +++ b/src/Umbraco.Web.UI.Client/src/mocks/data/document-blueprint/document-blueprint.db.ts @@ -101,9 +101,11 @@ const itemMapper = (model: UmbMockDocumentBlueprintModel): DocumentItemResponseM icon: model.documentType.icon, id: model.documentType.id, }, + hasChildren: model.hasChildren, id: model.id, isProtected: model.isProtected, isTrashed: model.isTrashed, + parent: model.parent, variants: model.variants, hasChildren: model.hasChildren, parent: model.parent, diff --git a/src/Umbraco.Web.UI.Client/src/mocks/data/document/document.db.ts b/src/Umbraco.Web.UI.Client/src/mocks/data/document/document.db.ts index 82a086af6f33..79d16d747eb1 100644 --- a/src/Umbraco.Web.UI.Client/src/mocks/data/document/document.db.ts +++ b/src/Umbraco.Web.UI.Client/src/mocks/data/document/document.db.ts @@ -126,9 +126,11 @@ const itemMapper = (model: UmbMockDocumentModel): DocumentItemResponseModel => { icon: model.documentType.icon, id: model.documentType.id, }, + hasChildren: model.hasChildren, id: model.id, isProtected: model.isProtected, isTrashed: model.isTrashed, + parent: model.parent, variants: model.variants, hasChildren: model.hasChildren, parent: model.parent, diff --git a/src/Umbraco.Web.UI.Client/src/mocks/data/media/media.db.ts b/src/Umbraco.Web.UI.Client/src/mocks/data/media/media.db.ts index 05fd677fb8ae..bbc564af50d6 100644 --- a/src/Umbraco.Web.UI.Client/src/mocks/data/media/media.db.ts +++ b/src/Umbraco.Web.UI.Client/src/mocks/data/media/media.db.ts @@ -99,8 +99,10 @@ const itemMapper = (model: UmbMockMediaModel): MediaItemResponseModel => { icon: model.mediaType.icon, id: model.mediaType.id, }, + hasChildren: model.hasChildren, id: model.id, isTrashed: model.isTrashed, + parent: model.parent, variants: model.variants, hasChildren: model.hasChildren, parent: model.parent, diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/components/table/table.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/components/table/table.element.ts index aab20a4fe606..468d7782f3ff 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/components/table/table.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/components/table/table.element.ts @@ -146,6 +146,18 @@ export class UmbTableElement extends LitElement { this.dispatchEvent(new UmbTableOrderedEvent()); } + private _selectAllRows() { + this.selection = this.items.map((item: UmbTableItem) => item.id); + this._selectionMode = true; + this.dispatchEvent(new UmbTableSelectedEvent()); + } + + private _deselectAllRows() { + this.selection = []; + this._selectionMode = false; + this.dispatchEvent(new UmbTableDeselectedEvent()); + } + private _selectRow(key: string) { this.selection = [...this.selection, key]; this._selectionMode = this.selection.length > 0; @@ -158,16 +170,14 @@ export class UmbTableElement extends LitElement { this.dispatchEvent(new UmbTableDeselectedEvent()); } - private _selectAllRows() { - this.selection = this.items.map((item: UmbTableItem) => item.id); - this._selectionMode = true; - this.dispatchEvent(new UmbTableSelectedEvent()); - } - - private _deselectAllRows() { - this.selection = []; - this._selectionMode = false; - this.dispatchEvent(new UmbTableDeselectedEvent()); + #onClickRow(key: string) { + if (this._selectionMode) { + if (this._isSelected(key)) { + this._deselectRow(key); + } else { + this._selectRow(key); + } + } } override render() { @@ -234,7 +244,8 @@ export class UmbTableElement extends LitElement { ?select-only=${this._selectionMode} ?selected=${this._isSelected(item.id)} @selected=${() => this._selectRow(item.id)} - @deselected=${() => this._deselectRow(item.id)}> + @deselected=${() => this._deselectRow(item.id)} + @click=${() => this.#onClickRow(item.id)}> ${this._renderRowCheckboxCell(item)} ${this.columns.map((column) => this._renderRowCell(column, item))} `; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/content-type/workspace/content-type-workspace-context-base.ts b/src/Umbraco.Web.UI.Client/src/packages/core/content-type/workspace/content-type-workspace-context-base.ts index 88088bd72fde..93dee1c48c5b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/content-type/workspace/content-type-workspace-context-base.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/content-type/workspace/content-type-workspace-context-base.ts @@ -1,3 +1,6 @@ +import type { UmbContentTypeCompositionModel, UmbContentTypeDetailModel, UmbContentTypeSortModel } from '../types.js'; +import { UmbContentTypeStructureManager } from '../structure/index.js'; +import type { UmbContentTypeWorkspaceContext } from './content-type-workspace-context.interface.js'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import type { UmbDetailRepository } from '@umbraco-cms/backoffice/repository'; import { @@ -6,10 +9,7 @@ import { type UmbEntityDetailWorkspaceContextCreateArgs, type UmbRoutableWorkspaceContext, } from '@umbraco-cms/backoffice/workspace'; -import type { UmbContentTypeWorkspaceContext } from './content-type-workspace-context.interface.js'; -import type { UmbContentTypeCompositionModel, UmbContentTypeDetailModel, UmbContentTypeSortModel } from '../types.js'; import { UmbValidationContext } from '@umbraco-cms/backoffice/validation'; -import { UmbContentTypeStructureManager } from '../structure/index.js'; import type { UmbReferenceByUnique } from '@umbraco-cms/backoffice/models'; import { jsonStringComparison, type Observable } from '@umbraco-cms/backoffice/observable-api'; import { UMB_ACTION_EVENT_CONTEXT } from '@umbraco-cms/backoffice/action'; @@ -221,7 +221,6 @@ export abstract class UmbContentTypeWorkspaceContextBase< * Sets the compositions of the content type * @param { string } compositions The compositions of the content type * @returns { void } - * */ public setCompositions(compositions: Array) { this.structure.updateOwnerContentType({ compositions } as Partial); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/models/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/models/index.ts index c1f1a5034e84..c992d6388f2e 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/models/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/models/index.ts @@ -14,6 +14,7 @@ export interface UmbNumberRangeValueType { max?: number; } +// TODO: this needs to use the UmbEntityUnique so we ensure that unique can be null export interface UmbReferenceByUnique { unique: string; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/sorter/sorter.controller.ts b/src/Umbraco.Web.UI.Client/src/packages/core/sorter/sorter.controller.ts index c8e22a1f75a5..d8546c5e4ddc 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/sorter/sorter.controller.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/sorter/sorter.controller.ts @@ -203,7 +203,7 @@ export type UmbSorterConfig = Partial, 'ignorerSelector' | 'containerSelector' | 'identifier'>>; /** - + * @class UmbSorterController * @implements {UmbControllerInterface} * @description This controller can make user able to sort items. diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/entity-detail/global-components/entity-detail-workspace-editor.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/entity-detail/global-components/entity-detail-workspace-editor.element.ts index 8add4ff8b902..e57bc1d2c7d5 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/entity-detail/global-components/entity-detail-workspace-editor.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/entity-detail/global-components/entity-detail-workspace-editor.element.ts @@ -1,5 +1,5 @@ -import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { UMB_ENTITY_DETAIL_WORKSPACE_CONTEXT } from '../entity-detail-workspace.context-token.js'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { css, customElement, html, ifDefined, nothing, property, state } from '@umbraco-cms/backoffice/external/lit'; @customElement('umb-entity-detail-workspace-editor') diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/workspace/document-type/document-type-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/workspace/document-type/document-type-workspace.context.ts index 2c1c805d2a23..625c98657679 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/workspace/document-type/document-type-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/workspace/document-type/document-type-workspace.context.ts @@ -7,7 +7,9 @@ import { } from '../../paths.js'; import type { UmbDocumentTypeDetailModel } from '../../types.js'; import { UMB_DOCUMENT_TYPE_ENTITY_TYPE } from '../../entity.js'; +import { UMB_DOCUMENT_TYPE_DETAIL_REPOSITORY_ALIAS } from '../../repository/index.js'; import { UmbDocumentTypeWorkspaceEditorElement } from './document-type-workspace-editor.element.js'; +import { UMB_DOCUMENT_TYPE_WORKSPACE_ALIAS } from './constants.js'; import { UmbContentTypeWorkspaceContextBase } from '@umbraco-cms/backoffice/content-type'; import { UmbWorkspaceIsNewRedirectController, @@ -18,8 +20,6 @@ import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import type { UmbReferenceByUnique } from '@umbraco-cms/backoffice/models'; import type { UmbRoutableWorkspaceContext } from '@umbraco-cms/backoffice/workspace'; import type { UmbPathPatternTypeAsEncodedParamsType } from '@umbraco-cms/backoffice/router'; -import { UMB_DOCUMENT_TYPE_WORKSPACE_ALIAS } from './constants.js'; -import { UMB_DOCUMENT_TYPE_DETAIL_REPOSITORY_ALIAS } from '../../repository/index.js'; import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity'; import { UmbTemplateDetailRepository } from '@umbraco-cms/backoffice/template'; @@ -185,6 +185,7 @@ export class UmbDocumentTypeWorkspaceContext /** * @deprecated Use the createScaffold method instead. Will be removed in 17. + * @param presetAlias * @param {UmbEntityModel} parent * @memberof UmbMediaTypeWorkspaceContext */ diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/repository/item/document-item.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/repository/item/document-item.server.data-source.ts index 7b0370f60bb3..14e808545fa8 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/repository/item/document-item.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/repository/item/document-item.server.data-source.ts @@ -32,15 +32,18 @@ const getItems = (uniques: Array) => DocumentService.getItemDocument({ i const mapper = (item: DocumentItemResponseModel): UmbDocumentItemModel => { return { - entityType: UMB_DOCUMENT_ENTITY_TYPE, - unique: item.id, - isTrashed: item.isTrashed, - isProtected: item.isProtected, documentType: { - unique: item.documentType.id, - icon: item.documentType.icon, collection: item.documentType.collection ? { unique: item.documentType.collection.id } : null, + icon: item.documentType.icon, + unique: item.documentType.id, }, + entityType: UMB_DOCUMENT_ENTITY_TYPE, + hasChildren: item.hasChildren, + isProtected: item.isProtected, + isTrashed: item.isTrashed, + name: item.variants[0]?.name, // TODO: this is not correct. We need to get it from the variants. This is a temp solution. + parent: item.parent ? { unique: item.parent.id } : null, + unique: item.id, variants: item.variants.map((variant) => { return { culture: variant.culture || null, @@ -48,6 +51,5 @@ const mapper = (item: DocumentItemResponseModel): UmbDocumentItemModel => { state: variant.state, }; }), - name: item.variants[0]?.name, // TODO: this is not correct. We need to get it from the variants. This is a temp solution. }; }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/repository/item/types.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/repository/item/types.ts index 18e03a54622a..f1077ccaf85f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/repository/item/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/repository/item/types.ts @@ -1,18 +1,21 @@ import type { UmbDocumentEntityType } from '../../entity.js'; +import type { UmbEntityUnique } from '@umbraco-cms/backoffice/entity'; import type { DocumentVariantStateModel } from '@umbraco-cms/backoffice/external/backend-api'; import type { UmbReferenceByUnique } from '@umbraco-cms/backoffice/models'; export interface UmbDocumentItemModel { - entityType: UmbDocumentEntityType; - name: string; // TODO: this is not correct. We need to get it from the variants. This is a temp solution. - unique: string; - isTrashed: boolean; - isProtected: boolean; documentType: { unique: string; icon: string; collection: UmbReferenceByUnique | null; }; + entityType: UmbDocumentEntityType; + hasChildren: boolean; + isProtected: boolean; + isTrashed: boolean; + name: string; // TODO: this is not correct. We need to get it from the variants. This is a temp solution. + parent: { unique: UmbEntityUnique } | null; // TODO: Use UmbReferenceByUnique when it support unique as null + unique: string; variants: Array; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/search/document-search.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/search/document-search.repository.ts index c5471d0e5d80..3e34af40ff38 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/search/document-search.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/search/document-search.repository.ts @@ -1,5 +1,5 @@ import { UmbDocumentSearchServerDataSource } from './document-search.server.data-source.js'; -import type { UmbDocumentSearchItemModel } from './document.search-provider.js'; +import type { UmbDocumentSearchItemModel } from './types.js'; import type { UmbSearchRepository, UmbSearchRequestArgs } from '@umbraco-cms/backoffice/search'; import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/search/document-search.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/search/document-search.server.data-source.ts index aeb27b182288..961fdfd54da8 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/search/document-search.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/search/document-search.server.data-source.ts @@ -1,5 +1,5 @@ import { UMB_DOCUMENT_ENTITY_TYPE } from '../entity.js'; -import type { UmbDocumentSearchItemModel } from './document.search-provider.js'; +import type { UmbDocumentSearchItemModel } from './types.js'; import type { UmbSearchDataSource, UmbSearchRequestArgs } from '@umbraco-cms/backoffice/search'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { DocumentService } from '@umbraco-cms/backoffice/external/backend-api'; @@ -40,16 +40,19 @@ export class UmbDocumentSearchServerDataSource implements UmbSearchDataSource = data.items.map((item) => { return { - href: '/section/content/workspace/document/edit/' + item.id, - entityType: UMB_DOCUMENT_ENTITY_TYPE, - unique: item.id, - isTrashed: item.isTrashed, - isProtected: item.isProtected, documentType: { - unique: item.documentType.id, - icon: item.documentType.icon, collection: item.documentType.collection ? { unique: item.documentType.collection.id } : null, + icon: item.documentType.icon, + unique: item.documentType.id, }, + entityType: UMB_DOCUMENT_ENTITY_TYPE, + hasChildren: item.hasChildren, + href: '/section/content/workspace/document/edit/' + item.id, + isProtected: item.isProtected, + isTrashed: item.isTrashed, + name: item.variants[0]?.name, // TODO: this is not correct. We need to get it from the variants. This is a temp solution. + parent: item.parent ? { unique: item.parent.id } : null, + unique: item.id, variants: item.variants.map((variant) => { return { culture: variant.culture || null, @@ -57,7 +60,6 @@ export class UmbDocumentSearchServerDataSource implements UmbSearchDataSource 0} + ?select-only=${this._selection.length > 0} ?selected=${this.#isSelected(item)} href=${this.#getEditUrl(item)} @selected=${() => this.#onSelect(item)} @@ -136,6 +136,11 @@ export class UmbMediaGridCollectionViewElement extends UmbLitElement { font-style: italic; } + /** TODO: Remove this fix when UUI gets upgrade to 1.3 */ + umb-imaging-thumbnail { + pointer-events: none; + } + #media-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/collection/views/table/media-table-collection-view.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/collection/views/table/media-table-collection-view.element.ts index 7e1206cb2e05..24f349cec207 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/collection/views/table/media-table-collection-view.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/collection/views/table/media-table-collection-view.element.ts @@ -64,6 +64,7 @@ export class UmbMediaTableCollectionViewElement extends UmbLitElement { collectionContext.workspacePathBuilder, (builder) => { this._workspacePathBuilder = builder; + this.#createTableItems(); }, 'observePath', ); @@ -86,7 +87,7 @@ export class UmbMediaTableCollectionViewElement extends UmbLitElement { this.#collectionContext.items, (items) => { this._items = items; - this.#createTableItems(this._items); + this.#createTableItems(); }, '_observeItems', ); @@ -117,12 +118,17 @@ export class UmbMediaTableCollectionViewElement extends UmbLitElement { } } - #createTableItems(items: Array) { + #createTableItems() { + this._tableItems = []; + + if (this._items === undefined) return; + if (this._workspacePathBuilder === undefined) return; + if (this._tableColumns.length === 0) { this.#createTableHeadings(); } - this._tableItems = items.map((item) => { + this._tableItems = this._items.map((item) => { if (!item.unique) throw new Error('Item id is missing.'); const data = diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-media/input-media.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-media/input-media.element.ts index 01223f0fdd6e..6eeaf1c8090d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-media/input-media.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-media/input-media.element.ts @@ -246,7 +246,6 @@ export class UmbInputMediaElement extends UmbFormControlMixin ({ name: item.name, unique: item.unique, entityType: item.entityType })), - ]; - return; + ).data || [] + : []; + + const paths: Array = items.map((item) => ({ + name: item.name, + unique: item.unique, + entityType: item.entityType, + })); + + if (!this.startNode) { + paths.unshift(root); } - this._paths = [root]; + + this._paths = [...paths]; } #goToFolder(entity: UmbMediaPathModel) { diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/media-picker-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/media-picker-modal.element.ts index eac3c2bfa30c..4336c76a65ad 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/media-picker-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/media-picker-modal.element.ts @@ -1,40 +1,57 @@ -import { UmbMediaItemRepository } from '../../repository/index.js'; +import { UmbMediaItemRepository, type UmbMediaItemModel } from '../../repository/index.js'; import { UmbMediaTreeRepository } from '../../tree/media-tree.repository.js'; import { UMB_MEDIA_ROOT_ENTITY_TYPE } from '../../entity.js'; import type { UmbDropzoneElement } from '../../dropzone/dropzone.element.js'; -import type { UmbMediaCardItemModel, UmbMediaPathModel } from './types.js'; +import type { UmbMediaTreeItemModel } from '../../tree/index.js'; +import { UmbMediaSearchProvider, type UmbMediaSearchItemModel } from '../../search/index.js'; +import type { UmbMediaPathModel } from './types.js'; import type { UmbMediaPickerFolderPathElement } from './components/media-picker-folder-path.element.js'; import type { UmbMediaPickerModalData, UmbMediaPickerModalValue } from './media-picker-modal.token.js'; -import { css, html, customElement, state, repeat, ifDefined, query } from '@umbraco-cms/backoffice/external/lit'; +import { + css, + html, + customElement, + state, + repeat, + ifDefined, + query, + type PropertyValues, + nothing, +} from '@umbraco-cms/backoffice/external/lit'; import { debounce } from '@umbraco-cms/backoffice/utils'; import { UmbModalBaseElement } from '@umbraco-cms/backoffice/modal'; import { UMB_CONTENT_PROPERTY_CONTEXT } from '@umbraco-cms/backoffice/content'; import type { UUIInputEvent } from '@umbraco-cms/backoffice/external/uui'; +import { isUmbracoFolder } from '@umbraco-cms/backoffice/media-type'; +import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity'; import '@umbraco-cms/backoffice/imaging'; const root: UmbMediaPathModel = { name: 'Media', unique: null, entityType: UMB_MEDIA_ROOT_ENTITY_TYPE }; +// TODO: investigate how we can reuse the picker-search-field element, picker context etc. @customElement('umb-media-picker-modal') export class UmbMediaPickerModalElement extends UmbModalBaseElement< UmbMediaPickerModalData, UmbMediaPickerModalValue > { - #mediaTreeRepository = new UmbMediaTreeRepository(this); // used to get file structure - #mediaItemRepository = new UmbMediaItemRepository(this); // used to search + #mediaTreeRepository = new UmbMediaTreeRepository(this); + #mediaItemRepository = new UmbMediaItemRepository(this); + #mediaSearchProvider = new UmbMediaSearchProvider(this); #dataType?: { unique: string }; @state() - private _selectableFilter: (item: UmbMediaCardItemModel) => boolean = () => true; + private _selectableFilter: (item: UmbMediaTreeItemModel | UmbMediaSearchItemModel) => boolean = () => true; - #mediaItemsCurrentFolder: Array = []; + @state() + private _currentChildren: Array = []; @state() - private _mediaFilteredList: Array = []; + private _searchResult: Array = []; @state() - private _searchOnlyThisFolder = false; + private _searchFrom: UmbEntityModel | undefined; @state() private _searchQuery = ''; @@ -42,6 +59,15 @@ export class UmbMediaPickerModalElement extends UmbModalBaseElement< @state() private _currentMediaEntity: UmbMediaPathModel = root; + @state() + private _isSelectionMode = false; + + @state() + private _startNode: UmbMediaItemModel | undefined; + + @state() + _searching: boolean = false; + @query('#dropzone') private _dropzone!: UmbDropzoneElement; @@ -57,21 +83,33 @@ export class UmbMediaPickerModalElement extends UmbModalBaseElement< override async connectedCallback(): Promise { super.connectedCallback(); + if (this.data?.pickableFilter) { + this._selectableFilter = this.data?.pickableFilter; + } + } - if (this.data?.pickableFilter) this._selectableFilter = this.data?.pickableFilter; + protected override async firstUpdated(_changedProperties: PropertyValues): Promise { + super.firstUpdated(_changedProperties); if (this.data?.startNode) { const { data } = await this.#mediaItemRepository.requestItems([this.data.startNode]); + this._startNode = data?.[0]; + + if (this._startNode) { + this._currentMediaEntity = { + name: this._startNode.name, + unique: this._startNode.unique, + entityType: this._startNode.entityType, + }; - if (data?.length) { - this._currentMediaEntity = { name: data[0].name, unique: data[0].unique, entityType: data[0].entityType }; + this._searchFrom = { unique: this._startNode.unique, entityType: this._startNode.entityType }; } } - this.#loadMediaFolder(); + this.#loadChildrenOfCurrentMediaItem(); } - async #loadMediaFolder() { + async #loadChildrenOfCurrentMediaItem() { const { data } = await this.#mediaTreeRepository.requestTreeItemsOf({ parent: { unique: this._currentMediaEntity.unique, @@ -82,78 +120,117 @@ export class UmbMediaPickerModalElement extends UmbModalBaseElement< take: 100, }); - this.#mediaItemsCurrentFolder = data?.items ?? []; - this.#filterMediaItems(); + this._currentChildren = data?.items ?? []; } - #onOpen(item: UmbMediaCardItemModel) { + #onOpen(item: UmbMediaTreeItemModel | UmbMediaSearchItemModel) { + this.#clearSearch(); + this._currentMediaEntity = { name: item.name, unique: item.unique, entityType: UMB_MEDIA_ROOT_ENTITY_TYPE, }; - this.#loadMediaFolder(); + + // If the user has navigated into an item, we default to search only within that item. + this._searchFrom = this._currentMediaEntity.unique + ? { unique: this._currentMediaEntity.unique, entityType: this._currentMediaEntity.entityType } + : undefined; + + this.#loadChildrenOfCurrentMediaItem(); } - #onSelected(item: UmbMediaCardItemModel) { + #onSelected(item: UmbMediaTreeItemModel | UmbMediaSearchItemModel) { const selection = this.data?.multiple ? [...this.value.selection, item.unique!] : [item.unique!]; + this._isSelectionMode = selection.length > 0; this.modalContext?.setValue({ selection }); } - #onDeselected(item: UmbMediaCardItemModel) { + #onDeselected(item: UmbMediaTreeItemModel | UmbMediaSearchItemModel) { const selection = this.value.selection.filter((value) => value !== item.unique); + this._isSelectionMode = selection.length > 0; this.modalContext?.setValue({ selection }); } - async #filterMediaItems() { + #clearSearch() { + this._searchQuery = ''; + this._searchResult = []; + } + + async #searchMedia() { if (!this._searchQuery) { - // No search query, show all media items in current folder. - this._mediaFilteredList = this.#mediaItemsCurrentFolder; + this.#clearSearch(); + this._searching = false; return; } const query = this._searchQuery; - const { data } = await this.#mediaItemRepository.search({ query, skip: 0, take: 100 }); + const { data } = await this.#mediaSearchProvider.search({ query, searchFrom: this._searchFrom }); if (!data) { // No search results. - this._mediaFilteredList = []; - return; - } - - if (this._searchOnlyThisFolder) { - // Don't have to map urls here, because we already have everything loaded within this folder. - this._mediaFilteredList = this.#mediaItemsCurrentFolder.filter((media) => - data.find((item) => item.unique === media.unique), - ); + this._searchResult = []; + this._searching = false; return; } // Map urls for search results as we are going to show for all folders (as long they aren't trashed). - this._mediaFilteredList = data.filter((found) => found.isTrashed === false); + this._searchResult = data.items; + this._searching = false; } #debouncedSearch = debounce(() => { - this.#filterMediaItems(); + this.#searchMedia(); }, 500); #onSearch(e: UUIInputEvent) { this._searchQuery = (e.target.value as string).toLocaleLowerCase(); + this._searching = true; this.#debouncedSearch(); } #onPathChange(e: CustomEvent) { - this._currentMediaEntity = (e.target as UmbMediaPickerFolderPathElement).currentMedia || { - unique: null, - entityType: UMB_MEDIA_ROOT_ENTITY_TYPE, - }; - this.#loadMediaFolder(); + const newPath = e.target as UmbMediaPickerFolderPathElement; + + if (newPath.currentMedia) { + this._currentMediaEntity = newPath.currentMedia; + } else if (this._startNode) { + this._currentMediaEntity = { + name: this._startNode.name, + unique: this._startNode.unique, + entityType: this._startNode.entityType, + }; + } else { + this._currentMediaEntity = root; + } + + if (this._currentMediaEntity.unique) { + this._searchFrom = { unique: this._currentMediaEntity.unique, entityType: this._currentMediaEntity.entityType }; + } else { + this._searchFrom = undefined; + } + + this.#loadChildrenOfCurrentMediaItem(); + } + + #allowNavigateToMedia(item: UmbMediaTreeItemModel | UmbMediaSearchItemModel): boolean { + return isUmbracoFolder(item.mediaType.unique) || item.hasChildren; + } + + #onSearchFromChange(e: CustomEvent) { + const checked = (e.target as HTMLInputElement).checked; + + if (checked) { + this._searchFrom = { unique: this._currentMediaEntity.unique, entityType: this._currentMediaEntity.entityType }; + } else { + this._searchFrom = undefined; + } } override render() { return html` - ${this.#renderBody()} ${this.#renderPath()} + ${this.#renderBody()} ${this.#renderBreadcrumb()}
this.#loadMediaFolder()} .parentUnique=${this._currentMediaEntity.unique}> - ${ - !this._mediaFilteredList.length - ? html`

${this.localize.term('content_listViewNoItems')}

` - : html`
- ${repeat( - this._mediaFilteredList, - (item) => item.unique, - (item) => this.#renderCard(item), - )} -
` - } -
`; + this.#loadChildrenOfCurrentMediaItem()} + .parentUnique=${this._currentMediaEntity.unique}> + ${this._searchQuery ? this.#renderSearchResult() : this.#renderCurrentChildren()} `; + } + + #renderSearchResult() { + return html` + ${!this._searchResult.length && !this._searching + ? html`

${this.localize.term('content_listViewNoItems')}

` + : html`
+ ${repeat( + this._searchResult, + (item) => item.unique, + (item) => this.#renderCard(item), + )} +
`} + `; + } + + #renderCurrentChildren() { + return html` + ${!this._currentChildren.length + ? html`

${this.localize.term('content_listViewNoItems')}

` + : html`
+ ${repeat( + this._currentChildren, + (item) => item.unique, + (item) => this.#renderCard(item), + )} +
`} + `; } #renderToolbar() { @@ -192,23 +290,35 @@ export class UmbMediaPickerModalElement extends UmbModalBaseElement< - + @input=${this.#onSearch} + value=${this._searchQuery}> +
+ ${this._searching + ? html`` + : html``} +
- (this._searchOnlyThisFolder = !this._searchOnlyThisFolder)} - label=${this.localize.term('general_excludeFromSubFolders')}> + + ${this._currentMediaEntity.unique && this._currentMediaEntity.unique !== this._startNode?.unique + ? html`` + : nothing} this._dropzone.browse()} label=${this.localize.term('general_upload')} - look="primary"> + look="outline" + color="default"> `; } - #renderCard(item: UmbMediaCardItemModel) { - const disabled = !this._selectableFilter(item); + #renderCard(item: UmbMediaTreeItemModel | UmbMediaSearchItemModel) { + const canNavigate = this.#allowNavigateToMedia(item); + const selectable = this._selectableFilter(item); + const disabled = !(selectable || canNavigate); return html` this.#onSelected(item)} @deselected=${() => this.#onDeselected(item)} ?selected=${this.value?.selection?.find((value) => value === item.unique)} - ?selectable=${!disabled}> + ?selectable=${selectable} + ?select-only=${this._isSelectionMode || canNavigate === false}> `; } @@ -244,16 +370,17 @@ export class UmbMediaPickerModalElement extends UmbModalBaseElement< #search { flex: 1; } + #search uui-input { width: 100%; margin-bottom: var(--uui-size-3); } - #search uui-icon { - height: 100%; - display: flex; - align-items: stretch; - padding-left: var(--uui-size-3); + + #searching-indicator { + margin-left: 7px; + margin-top: 4px; } + #media-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); @@ -261,6 +388,12 @@ export class UmbMediaPickerModalElement extends UmbModalBaseElement< gap: var(--uui-size-space-5); padding-bottom: 5px; /** The modal is a bit jumpy due to the img card focus/hover border. This fixes the issue. */ } + + /** TODO: Remove this fix when UUI gets upgrade to 1.3 */ + umb-imaging-thumbnail { + pointer-events: none; + } + umb-icon { font-size: var(--uui-size-8); } diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/repository/item/media-item.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/repository/item/media-item.repository.ts index 032103dc2387..178d7a6e17ff 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/repository/item/media-item.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/repository/item/media-item.repository.ts @@ -12,6 +12,13 @@ export class UmbMediaItemRepository extends UmbItemRepositoryBase) => MediaService.getItemMedia({ id: uni const mapper = (item: MediaItemResponseModel): UmbMediaItemModel => { return { entityType: UMB_MEDIA_ENTITY_TYPE, - unique: item.id, + hasChildren: item.hasChildren, isTrashed: item.isTrashed, + unique: item.id, mediaType: { unique: item.mediaType.id, icon: item.mediaType.icon, collection: item.mediaType.collection ? { unique: item.mediaType.collection.id } : null, }, + name: item.variants[0]?.name, // TODO: get correct variant name + parent: item.parent ? { unique: item.parent.id } : null, variants: item.variants.map((variant) => { return { culture: variant.culture || null, name: variant.name, }; }), - name: item.variants[0]?.name, // TODO: get correct variant name }; }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/repository/item/types.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/repository/item/types.ts index 5dd1780a064a..8aa5ce0232ca 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/repository/item/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/repository/item/types.ts @@ -1,17 +1,20 @@ import type { UmbMediaEntityType } from '../../entity.js'; +import type { UmbEntityUnique } from '@umbraco-cms/backoffice/entity'; import type { UmbReferenceByUnique } from '@umbraco-cms/backoffice/models'; export interface UmbMediaItemModel { entityType: UmbMediaEntityType; - unique: string; + hasChildren: boolean; isTrashed: boolean; + unique: string; mediaType: { unique: string; icon: string; collection: UmbReferenceByUnique | null; }; - variants: Array; name: string; // TODO: get correct variant name + parent: { unique: UmbEntityUnique } | null; // TODO: Use UmbReferenceByUnique when it support unique as null + variants: Array; } export interface UmbMediaItemVariantModel { diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/search/index.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/search/index.ts new file mode 100644 index 000000000000..b00dda3055b6 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/search/index.ts @@ -0,0 +1,2 @@ +export * from './media.search-provider.js'; +export type * from './types.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/search/media-search.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/search/media-search.repository.ts index cda2d907f4c9..d47162bcbe7b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/search/media-search.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/search/media-search.repository.ts @@ -1,5 +1,5 @@ import { UmbMediaSearchServerDataSource } from './media-search.server.data-source.js'; -import type { UmbMediaSearchItemModel } from './media.search-provider.js'; +import type { UmbMediaSearchItemModel } from './types.js'; import type { UmbSearchRepository, UmbSearchRequestArgs } from '@umbraco-cms/backoffice/search'; import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/search/media-search.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/search/media-search.server.data-source.ts index 93f8357e0427..5edc2a27373d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/search/media-search.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/search/media-search.server.data-source.ts @@ -1,5 +1,5 @@ import { UMB_MEDIA_ENTITY_TYPE } from '../entity.js'; -import type { UmbMediaSearchItemModel } from './media.search-provider.js'; +import type { UmbMediaSearchItemModel } from './types.js'; import type { UmbSearchDataSource, UmbSearchRequestArgs } from '@umbraco-cms/backoffice/search'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { MediaService } from '@umbraco-cms/backoffice/external/backend-api'; @@ -33,28 +33,31 @@ export class UmbMediaSearchServerDataSource implements UmbSearchDataSource = data.items.map((item) => { return { - href: '/section/media/workspace/media/edit/' + item.id, entityType: UMB_MEDIA_ENTITY_TYPE, - unique: item.id, + hasChildren: item.hasChildren, + href: '/section/media/workspace/media/edit/' + item.id, isTrashed: item.isTrashed, + unique: item.id, mediaType: { - unique: item.mediaType.id, - icon: item.mediaType.icon, collection: item.mediaType.collection ? { unique: item.mediaType.collection.id } : null, + icon: item.mediaType.icon, + unique: item.mediaType.id, }, + name: item.variants[0]?.name, // TODO: get correct variant name + parent: item.parent ? { unique: item.parent.id } : null, variants: item.variants.map((variant) => { return { culture: variant.culture || null, name: variant.name, }; }), - name: item.variants[0]?.name, // TODO: get correct variant name }; }); diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/search/media.search-provider.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/search/media.search-provider.ts index 973ab4df26aa..fa9439674e67 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/search/media.search-provider.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/search/media.search-provider.ts @@ -1,12 +1,8 @@ -import type { UmbMediaItemModel } from '../index.js'; import { UmbMediaSearchRepository } from './media-search.repository.js'; +import type { UmbMediaSearchItemModel } from './types.js'; import type { UmbSearchProvider, UmbSearchRequestArgs } from '@umbraco-cms/backoffice/search'; import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; -export interface UmbMediaSearchItemModel extends UmbMediaItemModel { - href: string; -} - export class UmbMediaSearchProvider extends UmbControllerBase implements UmbSearchProvider { #repository = new UmbMediaSearchRepository(this); diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/search/types.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/search/types.ts new file mode 100644 index 000000000000..a0d28611aa2e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/search/types.ts @@ -0,0 +1,5 @@ +import type { UmbMediaItemModel } from '../repository/index.js'; + +export interface UmbMediaSearchItemModel extends UmbMediaItemModel { + href: string; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member-type/workspace/member-type-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member-type/workspace/member-type-workspace.context.ts index a32e6ff96dd9..c96f5a1c212b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member-type/workspace/member-type-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member-type/workspace/member-type-workspace.context.ts @@ -2,6 +2,7 @@ import { UMB_MEMBER_TYPE_DETAIL_REPOSITORY_ALIAS } from '../repository/detail/in import type { UmbMemberTypeDetailModel } from '../types.js'; import { UMB_MEMBER_TYPE_ENTITY_TYPE } from '../index.js'; import { UmbMemberTypeWorkspaceEditorElement } from './member-type-workspace-editor.element.js'; +import { UMB_MEMBER_TYPE_WORKSPACE_ALIAS } from './manifests.js'; import { type UmbRoutableWorkspaceContext, UmbWorkspaceIsNewRedirectController, @@ -11,7 +12,6 @@ import { type UmbContentTypeWorkspaceContext, UmbContentTypeWorkspaceContextBase, } from '@umbraco-cms/backoffice/content-type'; -import { UMB_MEMBER_TYPE_WORKSPACE_ALIAS } from './manifests.js'; import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity'; type EntityDetailModel = UmbMemberTypeDetailModel;