Backoffice: Pre-expand tree to target entity when opening Duplicate and Move To modals (closes #22015) (#23063)

* Support tree expansion in generic Duplicate To modal

* Add expansion prop to tree picker modal types

* Apply expansion from data to picker context

* move to action: populate tree picker expansion with ancestors

* Pass tree expansion to duplicate document modal

* Extract ancestor fetching into private method

* Use UmbDocumentTreeRepository directly

* Exclude self from ancestor results

* make name more explicit

* Guard ancestor fetch and simplify expansion

* Only set treeExpansion when ancestors exist

* fix type issues

* Parallelize ancestor and pickable filter fetch

* Use getter for treeExpansion; remove unused imports
This commit is contained in:
Mads Rasmussen
2026-06-09 16:53:37 +02:00
committed by GitHub
parent 28cdbe5317
commit aa854da3f4
9 changed files with 96 additions and 6 deletions
@@ -1,21 +1,26 @@
import { UMB_DUPLICATE_TO_MODAL } from './modal/duplicate-to-modal.token.js';
import type { MetaEntityActionDuplicateToKind, UmbDuplicateToRepository } from './types.js';
import type { UmbTreeRepository } from '../../data/tree-repository.interface.js';
import { UmbEntityActionBase, UmbRequestReloadStructureForEntityEvent } from '@umbraco-cms/backoffice/entity-action';
import { umbOpenModal } from '@umbraco-cms/backoffice/modal';
import { createExtensionApiByAlias } from '@umbraco-cms/backoffice/extension-registry';
import { UMB_ACTION_EVENT_CONTEXT } from '@umbraco-cms/backoffice/action';
import { linkEntityExpansionEntries } from '@umbraco-cms/backoffice/utils';
export class UmbDuplicateToEntityAction extends UmbEntityActionBase<MetaEntityActionDuplicateToKind> {
override async execute() {
if (!this.args.unique) throw new Error('Unique is not available');
if (!this.args.entityType) throw new Error('Entity Type is not available');
const ancestors = await this.#requestAncestors();
const value = await umbOpenModal(this, UMB_DUPLICATE_TO_MODAL, {
data: {
unique: this.args.unique,
entityType: this.args.entityType,
treeAlias: this.args.meta.treeAlias,
foldersOnly: this.args.meta.foldersOnly,
treeExpansion: ancestors.length ? linkEntityExpansionEntries(ancestors) : undefined,
},
});
@@ -40,6 +45,21 @@ export class UmbDuplicateToEntityAction extends UmbEntityActionBase<MetaEntityAc
this.#reloadMenu();
}
async #requestAncestors() {
try {
const treeRepository = await createExtensionApiByAlias<UmbTreeRepository>(this, this.args.meta.treeRepositoryAlias);
const { data } =
(await treeRepository?.requestTreeItemAncestors({
treeItem: { unique: this.args.unique!, entityType: this.args.entityType! },
})) ?? {};
// Exclude self — the API returns the descendant as part of the ancestors list, but we only want to expand its parents.
return data?.filter((item) => item.unique !== this.args.unique) ?? [];
} catch {
// Tree pre-expansion is a UX convenience — if it fails the modal still opens normally.
return [];
}
}
async #reloadMenu() {
const actionEventContext = await this.getContext(UMB_ACTION_EVENT_CONTEXT);
if (!actionEventContext) throw new Error('Action event context is not available');
@@ -11,6 +11,10 @@ export class UmbDuplicateToModalElement extends UmbModalBaseElement<UmbDuplicate
@state()
private _destinationUnique?: string | null;
private get _treeExpansion() {
return this.data?.treeExpansion ?? [];
}
#onTreeSelectionChange(event: UmbSelectionChangeEvent) {
const target = event.target as UmbTreeElement;
const selection = target.getSelection();
@@ -32,6 +36,7 @@ export class UmbDuplicateToModalElement extends UmbModalBaseElement<UmbDuplicate
.props=${{
foldersOnly: this.data?.foldersOnly,
expandTreeRoot: true,
expansion: this._treeExpansion,
}}
@selection-change=${this.#onTreeSelectionChange}></umb-tree>
</uui-box>
@@ -43,7 +48,10 @@ export class UmbDuplicateToModalElement extends UmbModalBaseElement<UmbDuplicate
#renderActions() {
return html`
<uui-button slot="actions" label=${this.localize.term('general_cancel')} @click="${this._rejectModal}"></uui-button>
<uui-button
slot="actions"
label=${this.localize.term('general_cancel')}
@click="${this._rejectModal}"></uui-button>
<uui-button
slot="actions"
color="positive"
@@ -1,11 +1,13 @@
import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity';
import { UmbModalToken } from '@umbraco-cms/backoffice/modal';
import type { UmbEntityExpansionModel } from '@umbraco-cms/backoffice/utils';
export const UMB_DUPLICATE_TO_MODAL_ALIAS = 'Umb.Modal.DuplicateTo';
export interface UmbDuplicateToModalData extends UmbEntityModel {
treeAlias: string;
foldersOnly?: boolean;
treeExpansion?: UmbEntityExpansionModel;
}
export interface UmbDuplicateToModalValue {
@@ -1,11 +1,13 @@
import { UMB_TREE_PICKER_MODAL } from '../../tree-picker-modal/index.js';
import type { UmbTreeItemModel } from '../../types.js';
import type { UmbTreeRepository } from '../../data/tree-repository.interface.js';
import type { UmbMoveRepository } from './move-repository.interface.js';
import type { MetaEntityActionMoveToKind } from './types.js';
import { UmbEntityActionBase, UmbRequestReloadStructureForEntityEvent } from '@umbraco-cms/backoffice/entity-action';
import { umbOpenModal } from '@umbraco-cms/backoffice/modal';
import { createExtensionApiByAlias } from '@umbraco-cms/backoffice/extension-registry';
import { UMB_ACTION_EVENT_CONTEXT } from '@umbraco-cms/backoffice/action';
import { linkEntityExpansionEntries } from '@umbraco-cms/backoffice/utils';
export class UmbMoveToEntityAction extends UmbEntityActionBase<MetaEntityActionMoveToKind> {
protected async _getPickableFilter(unique: string): Promise<((item: UmbTreeItemModel) => boolean) | undefined> {
@@ -16,12 +18,18 @@ export class UmbMoveToEntityAction extends UmbEntityActionBase<MetaEntityActionM
if (!this.args.unique) throw new Error('Unique is not available');
if (!this.args.entityType) throw new Error('Entity Type is not available');
const [ancestors, pickableFilter] = await Promise.all([
this.#requestAncestors(),
this._getPickableFilter(this.args.unique),
]);
const value = await umbOpenModal(this, UMB_TREE_PICKER_MODAL, {
data: {
treeAlias: this.args.meta.treeAlias,
foldersOnly: this.args.meta.foldersOnly,
expandTreeRoot: true,
pickableFilter: await this._getPickableFilter(this.args.unique),
treeExpansion: ancestors.length ? linkEntityExpansionEntries(ancestors) : undefined,
pickableFilter,
},
});
@@ -43,6 +51,21 @@ export class UmbMoveToEntityAction extends UmbEntityActionBase<MetaEntityActionM
this.#reloadMenu();
}
async #requestAncestors() {
try {
const treeRepository = await createExtensionApiByAlias<UmbTreeRepository>(this, this.args.meta.treeRepositoryAlias);
const { data } =
(await treeRepository?.requestTreeItemAncestors({
treeItem: { unique: this.args.unique!, entityType: this.args.entityType! },
})) ?? {};
// Exclude self — the API returns the descendant as part of the ancestors list, but we only want to expand its parents.
return data?.filter((item) => item.unique !== this.args.unique) ?? [];
} catch {
// Tree pre-expansion is a UX convenience — if it fails the modal still opens normally.
return [];
}
}
async #reloadMenu() {
const actionEventContext = await this.getContext(UMB_ACTION_EVENT_CONTEXT);
if (!actionEventContext) throw new Error('Action Event Context is not available');
@@ -75,6 +75,10 @@ export class UmbTreePickerModalElement<TreeItemType extends UmbTreeItemModelBase
...this._selectionConfiguration,
multiple,
};
if (this.data?.treeExpansion !== undefined) {
this._pickerContext.expansion.setExpansion(this.data.treeExpansion);
}
}
if (_changedProperties.has('value')) {
@@ -2,6 +2,7 @@ import type { UmbTreeItemModel, UmbTreeStartNode } from '../types.js';
import type { UmbPathPattern, UmbPathPatternParamsType } from '@umbraco-cms/backoffice/router';
import type { UmbModalToken, UmbPickerModalData, UmbPickerModalValue } from '@umbraco-cms/backoffice/modal';
import type { UmbWorkspaceModalData } from '@umbraco-cms/backoffice/workspace';
import type { UmbEntityExpansionModel } from '@umbraco-cms/backoffice/utils';
export interface UmbTreePickerModalCreateActionData<PathPatternParamsType extends UmbPathPatternParamsType> {
label: string;
@@ -17,6 +18,7 @@ export interface UmbTreePickerModalData<
> extends UmbPickerModalData<TreeItemType> {
hideTreeRoot?: boolean;
expandTreeRoot?: boolean;
treeExpansion?: UmbEntityExpansionModel;
treeAlias?: string;
// TODO: create action should be replaces by entity actions in the pickers. Then we also open up for creating folders, choosing where to place items etc. [MR]
createAction?: UmbTreePickerModalCreateActionData<PathPatternParamsType>;
@@ -2,6 +2,7 @@ import { UMB_DOCUMENT_ENTITY_TYPE, UMB_DOCUMENT_ROOT_ENTITY_TYPE } from '../../e
import { UmbDocumentItemRepository } from '../../item/index.js';
import { UMB_DUPLICATE_DOCUMENT_MODAL } from './modal/index.js';
import { UmbDuplicateDocumentRepository } from './repository/index.js';
import { UmbDocumentTreeRepository } from '../../tree/index.js';
import { umbOpenModal } from '@umbraco-cms/backoffice/modal';
import { UMB_ACTION_EVENT_CONTEXT } from '@umbraco-cms/backoffice/action';
import { UmbEntityActionBase, UmbRequestReloadChildrenOfEntityEvent } from '@umbraco-cms/backoffice/entity-action';
@@ -9,6 +10,7 @@ import {
UmbDocumentTypeDetailRepository,
UmbDocumentTypeStructureRepository,
} from '@umbraco-cms/backoffice/document-type';
import { linkEntityExpansionEntries } from '@umbraco-cms/backoffice/utils';
export class UmbDuplicateDocumentEntityAction extends UmbEntityActionBase<never> {
override async execute() {
@@ -16,13 +18,17 @@ export class UmbDuplicateDocumentEntityAction extends UmbEntityActionBase<never>
if (!this.args.entityType) throw new Error('Entity Type is not available');
const duplicateRepository = new UmbDuplicateDocumentRepository(this);
const selectableFilter = await this.#getSelectableFilterByDocumentUnique(this.args.unique);
const [selectableFilter, ancestors] = await Promise.all([
this.#getSelectableFilterByDocumentUnique(this.args.unique),
this.#requestAncestors(),
]);
const value = await umbOpenModal(this, UMB_DUPLICATE_DOCUMENT_MODAL, {
data: {
unique: this.args.unique,
entityType: this.args.entityType,
selectableFilter,
treeExpansion: ancestors.length ? linkEntityExpansionEntries(ancestors) : undefined,
},
});
@@ -43,6 +49,20 @@ export class UmbDuplicateDocumentEntityAction extends UmbEntityActionBase<never>
this.#reloadMenu(destinationUnique);
}
async #requestAncestors() {
try {
const treeRepository = new UmbDocumentTreeRepository(this);
const { data } = await treeRepository.requestTreeItemAncestors({
treeItem: { unique: this.args.unique!, entityType: this.args.entityType! },
});
// Exclude self — the API returns the descendant as part of the ancestors list, but we only want to expand its parents.
return data?.filter((item) => item.unique !== this.args.unique) ?? [];
} catch {
// Tree pre-expansion is a UX convenience — if it fails the modal still opens normally.
return [];
}
}
async #getSelectableFilterByDocumentUnique(documentUnique: string) {
// 1. Get the document to find its type
const itemRepository = new UmbDocumentItemRepository(this);
@@ -21,6 +21,10 @@ export class UmbDocumentDuplicateToModalElement extends UmbModalBaseElement<
@state()
private _destinationUnique?: string | null;
private get _treeExpansion() {
return this.data?.treeExpansion ?? [];
}
#onTreeSelectionChange(event: UmbSelectionChangeEvent) {
const target = event.target as UmbTreeElement;
const selection = target.getSelection();
@@ -58,11 +62,14 @@ export class UmbDocumentDuplicateToModalElement extends UmbModalBaseElement<
expandTreeRoot: true,
hideTreeItemActions: true,
selectableFilter: this.#selectableFilter,
expansion: this._treeExpansion,
}}
@selection-change=${this.#onTreeSelectionChange}></umb-tree>
</uui-box>
<uui-box headline=${this.localize.term('general_options')}>
<umb-property-layout label=${this.localize.term('defaultdialogs_relateToOriginalLabel')} orientation="vertical"
<umb-property-layout
label=${this.localize.term('defaultdialogs_relateToOriginalLabel')}
orientation="vertical"
><div slot="editor">
<uui-toggle
@change=${this.#onRelateToOriginalChange}
@@ -85,7 +92,10 @@ export class UmbDocumentDuplicateToModalElement extends UmbModalBaseElement<
#renderActions() {
return html`
<uui-button slot="actions" label=${this.localize.term('general_cancel')} @click="${this._rejectModal}"></uui-button>
<uui-button
slot="actions"
label=${this.localize.term('general_cancel')}
@click="${this._rejectModal}"></uui-button>
<uui-button
slot="actions"
color="positive"
@@ -2,10 +2,11 @@ import { UMB_DUPLICATE_DOCUMENT_MODAL_ALIAS } from './manifests.js';
import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity';
import { UmbModalToken } from '@umbraco-cms/backoffice/modal';
import type { UmbDocumentTreeItemModel } from '../../../types.js';
import type { UmbEntityExpansionModel } from '@umbraco-cms/backoffice/utils';
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface UmbDuplicateDocumentModalData extends UmbEntityModel {
selectableFilter?: (item: UmbDocumentTreeItemModel) => boolean;
treeExpansion?: UmbEntityExpansionModel;
}
export interface UmbDuplicateDocumentModalValue {