Feature: Tree expansion state (#18227)

* implement tree expansion logic

* wip test example

* support complex expansion

* extend entity

* extend with model

* Update tree-item-context.interface.ts

* use expansion model to observe open state

* clean up

* fall back to tree context

* Update default-tree.context.ts

* Update default-tree.context.ts

* Update default-tree.context.ts

* clean up

* simplify model and state

* refactor to manager

* remove test data

* Update default-tree.context.ts

* rename

* add get method

* rename to collapse

* all collapse all method

* fix collapse logic

* add js docs

* add tests for expansion manager

* do not load children if the item is already open

* Update tree-item-element-base.ts

* config to expand tree root in pickers

* expand tree root for duplicate to

* Update tree-expansion-manager.test.ts

* make methods async

* use array state

* add isExpanded helper

* refactor to use isExpanded helper

* fix type issues

---------

Co-authored-by: Niels Lyngsø <niels.lyngso@gmail.com>
Co-authored-by: Niels Lyngsø <nsl@umbraco.dk>
This commit is contained in:
Mads Rasmussen
2025-03-24 14:49:09 +01:00
committed by GitHub
parent ad443c7b13
commit 36c66177fc
14 changed files with 364 additions and 5 deletions

View File

@@ -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<UmbTreeStartNode | undefined>(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([]);

View File

@@ -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()}`;
}

View File

@@ -32,6 +32,7 @@ export class UmbDuplicateToModalElement extends UmbModalBaseElement<UmbDuplicate
alias=${this.data.treeAlias}
.props=${{
foldersOnly: this.data?.foldersOnly,
expandTreeRoot: true,
}}
@selection-change=${this.#onTreeSelectionChange}></umb-tree>
</uui-box>

View File

@@ -16,6 +16,7 @@ export class UmbMoveToEntityAction extends UmbEntityActionBase<MetaEntityActionM
data: {
treeAlias: this.args.meta.treeAlias,
foldersOnly: this.args.meta.foldersOnly,
expandTreeRoot: true,
pickableFilter: (treeItem) => treeItem.unique !== this.args.unique,
},
});

View File

@@ -0,0 +1 @@
export * from './tree-expansion-manager.js';

View File

@@ -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([]);
});
});
});

View File

@@ -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<UmbEntityModel>([], (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<boolean>} True if the entity is expanded
* @memberof UmbTreeExpansionManager
*/
isExpanded(entity: UmbEntityModel): Observable<boolean> {
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<void>}
*/
public async expandItem(entity: UmbEntityModel): Promise<void> {
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<void>}
*/
public async collapseItem(entity: UmbEntityModel): Promise<void> {
this.#expansion.filter((x) => x.entityType !== entity.entityType || x.unique !== entity.unique);
}
/**
* Closes all child tree items
* @memberof UmbTreeExpansionManager
* @returns {Promise<void>}
*/
public async collapseAll(): Promise<void> {
this.#expansion.setValue([]);
}
}

View File

@@ -0,0 +1,3 @@
import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity';
export type UmbTreeExpansionModel = Array<UmbEntityModel>;

View File

@@ -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<UmbTreeItemContext<TreeItemType>>
implements UmbTreeItemContext<TreeItemType>
{
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;

View File

@@ -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`
<uui-menu-item
@show-children=${this._onShowChildren}
@hide-children=${this._onHideChildren}
@selected=${this._handleSelectedItem}
@deselected=${this._handleDeselectedItem}
?active=${this._isActive}
@@ -121,6 +132,7 @@ export abstract class UmbTreeItemElementBase<
?selected=${this._isSelected}
.loading=${this._isLoading}
.hasChildren=${this._hasChildren}
.showChildren=${this._isOpen}
.caretLabel=${this.localize.term('visuallyHiddenTexts_expandChildItems') + ' ' + label}
label=${label}
href="${ifDefined(this._isSelectableContext ? undefined : this._href)}">

View File

@@ -14,6 +14,7 @@ export interface UmbTreeItemContext<TreeItemType extends UmbTreeItemModel> exten
isSelectable: Observable<boolean>;
isSelected: Observable<boolean>;
isActive: Observable<boolean>;
isOpen: Observable<boolean>;
hasActions: Observable<boolean>;
path: Observable<string>;
pagination: UmbPaginationManager;
@@ -24,4 +25,7 @@ export interface UmbTreeItemContext<TreeItemType extends UmbTreeItemModel> exten
select(): void;
deselect(): void;
constructPath(pathname: string, entityType: string, unique: string): string;
loadChildren(): void;
showChildren(): void;
hideChildren(): void;
}

View File

@@ -182,6 +182,7 @@ export class UmbTreePickerModalElement<TreeItemType extends UmbTreeItemModelBase
.props=${{
hideTreeItemActions: true,
hideTreeRoot: this.data?.hideTreeRoot,
expandTreeRoot: this.data?.expandTreeRoot,
selectionConfiguration: this._selectionConfiguration,
filter: this.data?.filter,
selectableFilter: this.data?.pickableFilter,

View File

@@ -18,6 +18,7 @@ export interface UmbTreePickerModalData<
PathPatternParamsType extends UmbPathPatternParamsType = UmbPathPatternParamsType,
> extends UmbPickerModalData<TreeItemType> {
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<PathPatternParamsType>;

View File

@@ -46,7 +46,10 @@ export class UmbDocumentDuplicateToModalElement extends UmbModalBaseElement<
return html`
<umb-body-layout headline="Duplicate">
<uui-box id="tree-box" headline="Duplicate to">
<umb-tree alias=${UMB_DOCUMENT_TREE_ALIAS} @selection-change=${this.#onTreeSelectionChange}></umb-tree>
<umb-tree
alias=${UMB_DOCUMENT_TREE_ALIAS}
.props=${{ expandTreeRoot: true }}
@selection-change=${this.#onTreeSelectionChange}></umb-tree>
</uui-box>
<uui-box headline="Options">
<umb-property-layout label="Relate to original" orientation="vertical"