diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/tree/default/default-tree.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/tree/default/default-tree.context.ts index 71a82a8ba8..79a5eba6c3 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/tree/default/default-tree.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/tree/default/default-tree.context.ts @@ -3,6 +3,8 @@ import type { UmbTreeRepository } from '../data/tree-repository.interface.js'; import type { UmbTreeContext } from '../tree-context.interface.js'; import type { UmbTreeRootItemsRequestArgs } from '../data/types.js'; import type { ManifestTree } from '../extensions/types.js'; +import { UmbTreeExpansionManager } from '../expansion-manager/index.js'; +import type { UmbTreeExpansionModel } from '../expansion-manager/types.js'; import { UMB_TREE_CONTEXT } from './default-tree.context-token.js'; import { type UmbActionEventContext, UMB_ACTION_EVENT_CONTEXT } from '@umbraco-cms/backoffice/action'; import { type ManifestRepository, umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; @@ -38,10 +40,14 @@ export class UmbDefaultTreeContext< public filter?: (item: TreeItemType) => boolean = () => true; public readonly selection = new UmbSelectionManager(this._host); public readonly pagination = new UmbPaginationManager(); + public readonly expansion = new UmbTreeExpansionManager(this._host); #hideTreeRoot = new UmbBooleanState(false); hideTreeRoot = this.#hideTreeRoot.asObservable(); + #expandTreeRoot = new UmbBooleanState(undefined); + expandTreeRoot = this.#expandTreeRoot.asObservable(); + #startNode = new UmbObjectState(undefined); startNode = this.#startNode.asObservable(); @@ -156,6 +162,10 @@ export class UmbDefaultTreeContext< if (data) { this.#treeRoot.setValue(data); this.pagination.setTotalItems(1); + + if (this.getExpandTreeRoot()) { + this.#toggleTreeRootExpansion(true); + } } } @@ -277,6 +287,56 @@ export class UmbDefaultTreeContext< return this.#additionalRequestArgs.getValue(); } + /** + * Sets the expansion state + * @param {UmbTreeExpansionModel} data - The expansion state + * @returns {void} + * @memberof UmbDefaultTreeContext + */ + setExpansion(data: UmbTreeExpansionModel): void { + this.expansion.setExpansion(data); + } + + /** + * Gets the expansion state + * @returns {UmbTreeExpansionModel} - The expansion state + * @memberof UmbDefaultTreeContext + */ + getExpansion(): UmbTreeExpansionModel { + return this.expansion.getExpansion(); + } + + /** + * Sets the expandTreeRoot config + * @param {boolean} expandTreeRoot - Whether to expand the tree root + * @memberof UmbDefaultTreeContext + */ + setExpandTreeRoot(expandTreeRoot: boolean) { + this.#expandTreeRoot.setValue(expandTreeRoot); + this.#toggleTreeRootExpansion(expandTreeRoot); + } + + /** + * Gets the expandTreeRoot config + * @returns {boolean | undefined} - Whether to expand the tree root + * @memberof UmbDefaultTreeContext + */ + getExpandTreeRoot(): boolean | undefined { + return this.#expandTreeRoot.getValue(); + } + + #toggleTreeRootExpansion(expand: boolean) { + const treeRoot = this.#treeRoot.getValue(); + if (!treeRoot) return; + const treeRootEntity = { entityType: treeRoot.entityType, unique: treeRoot.unique }; + + if (expand) { + this.expansion.expandItem(treeRootEntity); + } else { + this.expansion.collapseItem(treeRootEntity); + } + } + #resetTree() { this.#treeRoot.setValue(undefined); this.#rootItems.setValue([]); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/tree/default/default-tree.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/tree/default/default-tree.element.ts index 329e9dfb3d..3ad8bf25c8 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/tree/default/default-tree.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/tree/default/default-tree.element.ts @@ -5,6 +5,7 @@ import type { UmbTreeSelectionConfiguration, UmbTreeStartNode, } from '../types.js'; +import type { UmbTreeExpansionModel } from '../expansion-manager/types.js'; import type { UmbDefaultTreeContext } from './default-tree.context.js'; import { UMB_TREE_CONTEXT } from './default-tree.context-token.js'; import type { PropertyValueMap } from '@umbraco-cms/backoffice/external/lit'; @@ -28,6 +29,9 @@ export class UmbDefaultTreeElement extends UmbLitElement { @property({ type: Boolean, attribute: false }) hideTreeRoot: boolean = false; + @property({ type: Boolean, attribute: false }) + expandTreeRoot: boolean = false; + @property({ type: Object, attribute: false }) startNode?: UmbTreeStartNode; @@ -40,6 +44,9 @@ export class UmbDefaultTreeElement extends UmbLitElement { @property({ attribute: false }) filter: (item: UmbTreeItemModelBase) => boolean = () => true; + @property({ attribute: false }) + expansion: UmbTreeExpansionModel = []; + @state() private _rootItems: UmbTreeItemModel[] = []; @@ -92,6 +99,10 @@ export class UmbDefaultTreeElement extends UmbLitElement { this.#treeContext!.setHideTreeRoot(this.hideTreeRoot); } + if (_changedProperties.has('expandTreeRoot')) { + this.#treeContext!.setExpandTreeRoot(this.expandTreeRoot); + } + if (_changedProperties.has('foldersOnly')) { this.#treeContext!.setFoldersOnly(this.foldersOnly ?? false); } @@ -103,12 +114,20 @@ export class UmbDefaultTreeElement extends UmbLitElement { if (_changedProperties.has('filter')) { this.#treeContext!.filter = this.filter; } + + if (_changedProperties.has('expansion')) { + this.#treeContext!.setExpansion(this.expansion); + } } getSelection() { return this.#treeContext?.selection.getSelection(); } + getExpansion() { + return this.#treeContext?.expansion.getExpansion(); + } + override render() { return html` ${this.#renderTreeRoot()} ${this.#renderRootItems()}`; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/tree/entity-actions/duplicate-to/modal/duplicate-to-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/tree/entity-actions/duplicate-to/modal/duplicate-to-modal.element.ts index cc37ba5d74..ef129d8762 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/tree/entity-actions/duplicate-to/modal/duplicate-to-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/tree/entity-actions/duplicate-to/modal/duplicate-to-modal.element.ts @@ -32,6 +32,7 @@ export class UmbDuplicateToModalElement extends UmbModalBaseElement diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/tree/entity-actions/move/move-to.action.ts b/src/Umbraco.Web.UI.Client/src/packages/core/tree/entity-actions/move/move-to.action.ts index 239ddb10b3..370dd982bc 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/tree/entity-actions/move/move-to.action.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/tree/entity-actions/move/move-to.action.ts @@ -16,6 +16,7 @@ export class UmbMoveToEntityAction extends UmbEntityActionBase treeItem.unique !== this.args.unique, }, }); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/tree/expansion-manager/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/tree/expansion-manager/index.ts new file mode 100644 index 0000000000..11111f15fb --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/tree/expansion-manager/index.ts @@ -0,0 +1 @@ +export * from './tree-expansion-manager.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/tree/expansion-manager/tree-expansion-manager.test.ts b/src/Umbraco.Web.UI.Client/src/packages/core/tree/expansion-manager/tree-expansion-manager.test.ts new file mode 100644 index 0000000000..9f9adb5d7a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/tree/expansion-manager/tree-expansion-manager.test.ts @@ -0,0 +1,107 @@ +import { UmbTreeExpansionManager } from './tree-expansion-manager.js'; +import { expect } from '@open-wc/testing'; +import { Observable } from '@umbraco-cms/backoffice/external/rxjs'; +import { customElement } from '@umbraco-cms/backoffice/external/lit'; +import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api'; + +@customElement('test-my-controller-host') +class UmbTestControllerHostElement extends UmbControllerHostElementMixin(HTMLElement) {} + +describe('UmbTreeExpansionManager', () => { + let manager: UmbTreeExpansionManager; + const item = { entityType: 'test', unique: '123' }; + const item2 = { entityType: 'test', unique: '456' }; + + beforeEach(() => { + const hostElement = new UmbTestControllerHostElement(); + manager = new UmbTreeExpansionManager(hostElement); + }); + + describe('Public API', () => { + describe('properties', () => { + it('has an expansion property', () => { + expect(manager).to.have.property('expansion').to.be.an.instanceOf(Observable); + }); + }); + + describe('methods', () => { + it('has an isExpanded method', () => { + expect(manager).to.have.property('isExpanded').that.is.a('function'); + }); + + it('has a setExpansion method', () => { + expect(manager).to.have.property('setExpansion').that.is.a('function'); + }); + + it('has a getExpansion method', () => { + expect(manager).to.have.property('getExpansion').that.is.a('function'); + }); + + it('has a expandItem method', () => { + expect(manager).to.have.property('expandItem').that.is.a('function'); + }); + + it('has a collapseItem method', () => { + expect(manager).to.have.property('collapseItem').that.is.a('function'); + }); + + it('has a collapseAll method', () => { + expect(manager).to.have.property('collapseAll').that.is.a('function'); + }); + }); + }); + + describe('isExpanded', () => { + it('checks if an item is expanded', (done) => { + manager.setExpansion([item]); + const isExpanded = manager.isExpanded(item); + expect(isExpanded).to.be.an.instanceOf(Observable); + manager.isExpanded(item).subscribe((value) => { + console.log('VALUE', value); + expect(value).to.be.true; + done(); + }); + }); + }); + + describe('setExpansion', () => { + it('sets the expansion state', () => { + const expansion = [item]; + manager.setExpansion(expansion); + expect(manager.getExpansion()).to.deep.equal(expansion); + }); + }); + + describe('getExpansion', () => { + it('gets the expansion state', () => { + const expansion = [item]; + manager.setExpansion(expansion); + expect(manager.getExpansion()).to.deep.equal(expansion); + }); + }); + + describe('expandItem', () => { + it('expands an item', async () => { + await manager.expandItem(item); + expect(manager.getExpansion()).to.deep.equal([item]); + }); + }); + + describe('collapseItem', () => { + it('collapses an item', async () => { + await manager.expandItem(item); + expect(manager.getExpansion()).to.deep.equal([item]); + manager.collapseItem(item); + expect(manager.getExpansion()).to.deep.equal([]); + }); + }); + + describe('collapseAll', () => { + it('collapses all items', () => { + manager.setExpansion([item, item2]); + expect(manager.getExpansion()).to.deep.equal([item, item2]); + manager.collapseAll(); + expect(manager.getExpansion()).to.deep.equal([]); + }); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/tree/expansion-manager/tree-expansion-manager.ts b/src/Umbraco.Web.UI.Client/src/packages/core/tree/expansion-manager/tree-expansion-manager.ts new file mode 100644 index 0000000000..a2144f8c1c --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/tree/expansion-manager/tree-expansion-manager.ts @@ -0,0 +1,81 @@ +import type { UmbTreeExpansionModel } from './types.js'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity'; +import { UmbArrayState, type Observable } from '@umbraco-cms/backoffice/observable-api'; + +/** + * Manages the expansion state of a tree + * @exports + * @class UmbTreeExpansionManager + * @augments {UmbControllerBase} + */ +export class UmbTreeExpansionManager extends UmbControllerBase { + #expansion = new UmbArrayState([], (x) => x.unique); + expansion = this.#expansion.asObservable(); + + /** + * Checks if an entity is expanded + * @param {UmbEntityModel} entity The entity to check + * @param {string} entity.entityType The entity type + * @param {string} entity.unique The unique key + * @returns {Observable} True if the entity is expanded + * @memberof UmbTreeExpansionManager + */ + isExpanded(entity: UmbEntityModel): Observable { + return this.#expansion.asObservablePart((entries) => + entries?.some((entry) => entry.entityType === entity.entityType && entry.unique === entity.unique), + ); + } + + /** + * Sets the expansion state + * @param {UmbTreeExpansionModel | undefined} expansion The expansion state + * @memberof UmbTreeExpansionManager + * @returns {void} + */ + setExpansion(expansion: UmbTreeExpansionModel): void { + this.#expansion.setValue(expansion); + } + + /** + * Gets the expansion state + * @memberof UmbTreeExpansionManager + * @returns {UmbTreeExpansionModel} The expansion state + */ + getExpansion(): UmbTreeExpansionModel { + return this.#expansion.getValue(); + } + + /** + * Opens a child tree item + * @param {UmbEntityModel} entity The entity to open + * @param {string} entity.entityType The entity type + * @param {string} entity.unique The unique key + * @memberof UmbTreeExpansionManager + * @returns {Promise} + */ + public async expandItem(entity: UmbEntityModel): Promise { + this.#expansion.appendOne(entity); + } + + /** + * Closes a child tree item + * @param {UmbEntityModel} entity The entity to close + * @param {string} entity.entityType The entity type + * @param {string} entity.unique The unique key + * @memberof UmbTreeExpansionManager + * @returns {Promise} + */ + public async collapseItem(entity: UmbEntityModel): Promise { + this.#expansion.filter((x) => x.entityType !== entity.entityType || x.unique !== entity.unique); + } + + /** + * Closes all child tree items + * @memberof UmbTreeExpansionManager + * @returns {Promise} + */ + public async collapseAll(): Promise { + this.#expansion.setValue([]); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/tree/expansion-manager/types.ts b/src/Umbraco.Web.UI.Client/src/packages/core/tree/expansion-manager/types.ts new file mode 100644 index 0000000000..7d5c050029 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/tree/expansion-manager/types.ts @@ -0,0 +1,3 @@ +import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity'; + +export type UmbTreeExpansionModel = Array; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item/tree-item-base/tree-item-context-base.ts b/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item/tree-item-base/tree-item-context-base.ts index 93844ec277..00b8782314 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item/tree-item-base/tree-item-context-base.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item/tree-item-base/tree-item-context-base.ts @@ -19,6 +19,7 @@ import { import type { UmbEntityActionEvent } from '@umbraco-cms/backoffice/entity-action'; import { UmbPaginationManager, debounce } from '@umbraco-cms/backoffice/utils'; import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; +import type { UmbEntityUnique } from '@umbraco-cms/backoffice/entity'; export abstract class UmbTreeItemContextBase< TreeItemType extends UmbTreeItemModel, @@ -28,7 +29,7 @@ export abstract class UmbTreeItemContextBase< extends UmbContextBase> implements UmbTreeItemContext { - public unique?: string | null; + public unique?: UmbEntityUnique; public entityType?: string; public readonly pagination = new UmbPaginationManager(); @@ -66,6 +67,9 @@ export abstract class UmbTreeItemContextBase< #path = new UmbStringState(''); readonly path = this.#path.asObservable(); + #isOpen = new UmbBooleanState(false); + isOpen = this.#isOpen.asObservable(); + #foldersOnly = new UmbBooleanState(false); readonly foldersOnly = this.#foldersOnly.asObservable(); @@ -212,16 +216,57 @@ export abstract class UmbTreeItemContextBase< }); } + /** + * Selects the tree item + * @memberof UmbTreeItemContextBase + * @returns {void} + */ public select() { if (this.unique === undefined) throw new Error('Could not select. Unique is missing'); this.treeContext?.selection.select(this.unique); } + /** + * Deselects the tree item + * @memberof UmbTreeItemContextBase + * @returns {void} + */ public deselect() { if (this.unique === undefined) throw new Error('Could not deselect. Unique is missing'); this.treeContext?.selection.deselect(this.unique); } + public showChildren() { + const entityType = this.entityType; + const unique = this.unique; + + if (!entityType) { + throw new Error('Could not show children, entity type is missing'); + } + + if (unique === undefined) { + throw new Error('Could not show children, unique is missing'); + } + + // It is the tree that keeps track of the open children. We tell the tree to open this child + this.treeContext?.expansion.expandItem({ entityType, unique }); + } + + public hideChildren() { + const entityType = this.entityType; + const unique = this.unique; + + if (!entityType) { + throw new Error('Could not show children, entity type is missing'); + } + + if (unique === undefined) { + throw new Error('Could not show children, unique is missing'); + } + + this.treeContext?.expansion.collapseItem({ entityType, unique }); + } + async #consumeContexts() { this.consumeContext(UMB_SECTION_CONTEXT, (instance) => { this.#sectionContext = instance; @@ -239,6 +284,7 @@ export abstract class UmbTreeItemContextBase< this.#observeIsSelectable(); this.#observeIsSelected(); this.#observeFoldersOnly(); + this.#observeExpansion(); }); this.consumeContext(UMB_TREE_ITEM_CONTEXT, (instance) => { @@ -339,6 +385,25 @@ export abstract class UmbTreeItemContextBase< ); } + #observeExpansion() { + if (this.unique === undefined) return; + if (!this.entityType) return; + if (!this.treeContext) return; + + this.observe( + this.treeContext.expansion.isExpanded({ entityType: this.entityType, unique: this.unique }), + (isExpanded) => { + // If this item has children, load them + if (isExpanded && this.#hasChildren.getValue() && this.#isOpen.getValue() === false) { + this.loadChildren(); + } + + this.#isOpen.setValue(isExpanded); + }, + 'observeExpansion', + ); + } + #onReloadRequest = (event: UmbEntityActionEvent) => { if (event.getUnique() !== this.unique) return; if (event.getEntityType() !== this.entityType) return; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item/tree-item-base/tree-item-element-base.ts b/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item/tree-item-base/tree-item-element-base.ts index 8cee44081d..aa5814edfc 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item/tree-item-base/tree-item-element-base.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item/tree-item-base/tree-item-element-base.ts @@ -2,6 +2,7 @@ import type { UmbTreeItemContext } from '../index.js'; import type { UmbTreeItemModel } from '../../types.js'; import { html, nothing, state, ifDefined, repeat, property } from '@umbraco-cms/backoffice/external/lit'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import type { UUIMenuItemEvent } from '@umbraco-cms/backoffice/external/uui'; export abstract class UmbTreeItemElementBase< TreeItemModelType extends UmbTreeItemModel, @@ -32,6 +33,7 @@ export abstract class UmbTreeItemElementBase< this.observe(this.#api.childItems, (value) => (this._childItems = value)); this.observe(this.#api.hasChildren, (value) => (this._hasChildren = value)); this.observe(this.#api.isActive, (value) => (this._isActive = value)); + this.observe(this.#api.isOpen, (value) => (this._isOpen = value)); this.observe(this.#api.isLoading, (value) => (this._isLoading = value)); this.observe(this.#api.isSelectableContext, (value) => (this._isSelectableContext = value)); this.observe(this.#api.isSelectable, (value) => (this._isSelectable = value)); @@ -70,6 +72,9 @@ export abstract class UmbTreeItemElementBase< @state() private _hasChildren = false; + @state() + private _isOpen = false; + @state() private _iconSlotHasChildren = false; @@ -95,9 +100,14 @@ export abstract class UmbTreeItemElementBase< this.#api?.deselect(); } - // TODO: do we want to catch and emit a backoffice event here? - private _onShowChildren() { - this.#api?.loadChildren(); + private _onShowChildren(event: UUIMenuItemEvent) { + event.stopPropagation(); + this.#api?.showChildren(); + } + + private _onHideChildren(event: UUIMenuItemEvent) { + event.stopPropagation(); + this.#api?.hideChildren(); } #onLoadMoreClick = (event: any) => { @@ -113,6 +123,7 @@ export abstract class UmbTreeItemElementBase< return html` diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item/tree-item-context.interface.ts b/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item/tree-item-context.interface.ts index d1829dc149..a9255fa94a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item/tree-item-context.interface.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item/tree-item-context.interface.ts @@ -14,6 +14,7 @@ export interface UmbTreeItemContext exten isSelectable: Observable; isSelected: Observable; isActive: Observable; + isOpen: Observable; hasActions: Observable; path: Observable; pagination: UmbPaginationManager; @@ -24,4 +25,7 @@ export interface UmbTreeItemContext exten select(): void; deselect(): void; constructPath(pathname: string, entityType: string, unique: string): string; + loadChildren(): void; + showChildren(): void; + hideChildren(): void; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-picker-modal/tree-picker-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-picker-modal/tree-picker-modal.element.ts index a7b47cd0b6..4bceebff8f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-picker-modal/tree-picker-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-picker-modal/tree-picker-modal.element.ts @@ -182,6 +182,7 @@ export class UmbTreePickerModalElement extends UmbPickerModalData { hideTreeRoot?: boolean; + expandTreeRoot?: boolean; treeAlias?: string; // Consider if it makes sense to move this into the UmbPickerModalData interface, but for now this is a TreePicker feature. [NL] createAction?: UmbTreePickerModalCreateActionData; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/entity-actions/duplicate/modal/duplicate-document-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/entity-actions/duplicate/modal/duplicate-document-modal.element.ts index 55b95dd45f..27fe5f948c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/entity-actions/duplicate/modal/duplicate-document-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/entity-actions/duplicate/modal/duplicate-document-modal.element.ts @@ -46,7 +46,10 @@ export class UmbDocumentDuplicateToModalElement extends UmbModalBaseElement< return html` - +