Compare commits

...
Author SHA1 Message Date
Mads Rasmussen 1103a2f821 Add document variant state value summary 2026-06-26 14:34:25 +02:00
Mads Rasmussen 4738c53def Extract reusable variant resolver controller 2026-06-26 12:10:04 +02:00
Mads Rasmussen 783b60354e Update document-item-data-resolver.test.ts 2026-06-25 09:49:41 +02:00
Mads Rasmussen e2f1410fa9 Extract document variant state tag config util 2026-06-24 14:46:03 +02:00
Mads Rasmussen 93a90d1ee1 document collection item open in picker instead of expand 2026-06-24 11:52:00 +02:00
Mads Rasmussen 6d31f803da initial setup of card and table view for document tree 2026-06-24 10:36:20 +02:00
Mads Rasmussen d7fde5d74c Reload tree/children on structure change events 2026-06-24 09:19:15 +02:00
Mads Rasmussen ab4ba41462 Match reload events against start node 2026-06-23 14:49:23 +02:00
Mads Rasmussen a77e18eb4e Add empty-state for tree children 2026-06-23 14:28:10 +02:00
Mads Rasmussen 8f34149b19 Update extensions controller generic types 2026-06-23 12:29:32 +02:00
Mads Rasmussen a2ed0fd6fc Add jsdocs 2026-06-23 12:14:40 +02:00
Mads Rasmussen 4c6736b18d Improve typings in tree-item-card extension 2026-06-23 11:38:22 +02:00
Mads Rasmussen 227c228aab Use resource key '#tree_children' for label 2026-06-23 11:32:14 +02:00
Mads Rasmussen e3b08349c8 Update table.element.ts 2026-06-23 10:36:51 +02:00
Mads Rasmussen ac74d102fb Ensure breadcrumb load sequencing and root handling 2026-06-23 10:06:08 +02:00
Mads Rasmussen ca12e6d79c No-op opening tree root with null unique 2026-06-23 09:42:23 +02:00
Mads Rasmussen 267882f8d8 Fix typo: Treekind -> TreeKind in workspace 2026-06-23 09:26:24 +02:00
83bcc8c3ab Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-23 08:57:02 +02:00
266bd6295f Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-23 08:56:35 +02:00
Mads Rasmussen 8eff03664b simplify name + inline 2026-06-22 11:19:01 +02:00
Mads Rasmussen 17d7a3b0c6 Use clickTreeItemWithName for tree items
Add a clickTreeItemWithName helper to UiBaseLocators and replace direct getByLabel/getByText clicks in multiple UI helper files to use it.
2026-06-19 15:30:26 +02:00
Mads Rasmussen 0705aacf38 Bump tree view deprecation to 20.0.0 2026-06-19 11:28:03 +02:00
Mads Rasmussen cb19024005 Implement tree ancestors lookup in picker-data-source example 2026-06-19 08:42:42 +02:00
Mads Rasmussen 129a9731a7 Always load tree root entity on init 2026-06-19 08:18:00 +02:00
Mads Rasmussen f7af28bb77 Remove currentPage state; use loadNextItems 2026-06-18 14:55:39 +02:00
Mads Rasmussen 6592c9d641 make new interface changes optional 2026-06-18 14:51:20 +02:00
Mads Rasmussen f2dd4e2dfd revert 2026-06-18 14:50:16 +02:00
Mads Rasmussen 0715cdc913 Add tree view labels and localize children 2026-06-18 14:03:30 +02:00
Mads Rasmussen 6c0acb662f Delete tree-views-plan.md 2026-06-18 13:43:37 +02:00
Mads Rasmussen 67ee65e8ac Merge branch 'main' into v17/feature/tree-views 2026-06-18 13:31:51 +02:00
Mads Rasmussen 2780527d3a Add override modifier to hasChildren 2026-06-18 13:28:18 +02:00
Mads Rasmussen f73ec5ffa2 Use explicit undefined type for item property 2026-06-18 13:19:37 +02:00
Mads Rasmussen 70a3ec8281 Centralize noAccess handling for tree items 2026-06-18 13:17:05 +02:00
Mads Rasmussen 389a59d05b Update index.ts 2026-06-18 10:07:38 +02:00
Mads Rasmussen aac5db9198 Update tree-create-action.api.ts 2026-06-18 10:07:08 +02:00
Mads Rasmussen 52752e2a92 format 2026-06-18 08:52:22 +02:00
Mads Rasmussen 9c31815adb Load tree when API is set; remove root preload 2026-06-17 15:47:47 +02:00
Mads Rasmussen 964a427bce Use UmbEntityModel and defer tree render 2026-06-17 15:42:42 +02:00
Mads Rasmussen 05b4137493 Fix tree root/children load and start node 2026-06-17 14:10:26 +02:00
Mads Rasmussen 2e512add88 Persist and restore tree picker location in memory 2026-06-17 13:14:25 +02:00
Mads Rasmussen c1af34b03c Add tree interaction memory support to picker 2026-06-17 10:59:43 +02:00
Mads Rasmussen dad7092574 Persist tree workspace view via interaction memory
Integrate interaction-memory into the tree system so tree view selection and per-tree interaction memories are persisted and restored.
2026-06-17 10:38:02 +02:00
Mads Rasmussen 9beb0d7bc6 Disable tree item if not selectable in context 2026-06-16 14:41:07 +02:00
Mads Rasmussen 541b262548 Skip active check when path is empty 2026-06-16 14:39:21 +02:00
Mads Rasmussen d5631691bb Add hideTreeActions to tree toolbar 2026-06-16 13:09:01 +02:00
Mads Rasmussen 278c78293e Initialize entity context and refactor create action 2026-06-16 12:22:38 +02:00
Mads Rasmussen 6b1a79fd73 Add bottom margin to breadcrumb in tree picker 2026-06-16 08:25:17 +02:00
Mads Rasmussen c1a5386c0d Remove uui-box wrapper around tree 2026-06-16 08:19:39 +02:00
Mads Rasmussen 0ba1565db3 remove redundant view + deprecate dead code 2026-06-15 20:40:55 +02:00
Mads Rasmussen 4c52b2d4f0 remove padding 2026-06-15 20:23:09 +02:00
Mads Rasmussen c109310b8b fix spacing 2026-06-15 20:21:32 +02:00
Mads Rasmussen 2c5d3f8dbf Add tree 'create' actions and tree-alias condition 2026-06-15 20:20:25 +02:00
Mads Rasmussen f1fa764b6d hide toolbar in menu 2026-06-15 14:08:14 +02:00
Mads Rasmussen e082cbee6b Change classic view label to 'Tree' 2026-06-15 13:48:10 +02:00
Mads Rasmussen 58ca45e2dd Add host styles to tree-workspace-view 2026-06-15 13:44:19 +02:00
Mads Rasmussen 1dd37f6190 Wrap classic tree view in uui-box when not menu 2026-06-15 13:43:57 +02:00
Mads Rasmussen 5abfd7b35b Respect selectable context when setting select-only 2026-06-15 12:10:55 +02:00
Mads Rasmussen 6b23d5922d Add tree workspace view manifest and element 2026-06-15 12:05:36 +02:00
Mads Rasmussen d11a681856 Merge branch 'main' into v17/feature/tree-views 2026-06-15 10:21:12 +02:00
Mads Rasmussen d62dbc27d4 Add tree pagination component and paging support 2026-06-15 10:18:46 +02:00
Mads Rasmussen 054410e29d Localize tree item names and refactor table view 2026-06-02 20:53:09 +02:00
Mads Rasmussen a9f133c3ac Adjust tree view weights for card and table 2026-06-02 14:49:31 +02:00
Mads Rasmussen 365215a072 Update default-tree.element.ts 2026-06-02 14:18:10 +02:00
Mads Rasmussen 3a1c6940cb Add Document Type table tree view manifest 2026-06-02 13:16:55 +02:00
Mads Rasmussen 9241bfdaa9 Add table tree view UI and manifests 2026-06-02 13:16:38 +02:00
Mads Rasmussen 3f848081ec Add children indicator column and row callbacks 2026-06-02 13:15:28 +02:00
Mads Rasmussen 7a2e54430d Add 'Table' view label to English tree 2026-06-02 13:14:21 +02:00
Mads Rasmussen 7254bce9ae Replace dblclick handler with open event 2026-06-02 10:18:46 +02:00
Mads Rasmussen f0375288e4 Mark leaf nodes as select-only in tree item card 2026-06-02 10:06:44 +02:00
Mads Rasmussen 88e5852a89 Expose hasChildren to tree item API and card 2026-06-02 09:51:08 +02:00
Mads Rasmussen a365771fd9 Add noAccess observable to tree items 2026-06-02 09:37:09 +02:00
Mads Rasmussen 3632564bbc Support selectOnly mode for tree items 2026-06-02 09:29:56 +02:00
Mads Rasmussen 5f56d4d38b Add selectOnly observable to tree API 2026-06-01 20:18:16 +02:00
Mads Rasmussen 2a661530b1 Respect hideTreeItemActions in TreeItem API
Introduce a #hideTreeItemActions boolean state and combine it with the existing entity action availability to compute hasActions. Import combineLatest and update hasActions to emit false when actions are hidden. Hook into _onTreeContextChanged to observe context.hideTreeItemActions (defaulting to false) so the tree context can dynamically hide item actions.
2026-06-01 20:15:35 +02:00
Mads Rasmussen dc02cc1e22 inline kind implementations 2026-06-01 20:06:35 +02:00
Mads Rasmussen c581d03174 add interfaces for treeItemCard api and element 2026-06-01 15:43:37 +02:00
Mads Rasmussen e655232756 remove fallback and require registration 2026-06-01 14:26:30 +02:00
Mads Rasmussen 35be50f57d simplify folders + register tree item card default kind 2026-06-01 14:25:37 +02:00
Mads Rasmussen aadfd9bd5f format 2026-06-01 11:21:54 +02:00
Mads Rasmussen 1df05f399e Merge branch 'main' into v17/feature/tree-views 2026-06-01 11:21:01 +02:00
Mads Rasmussen 2182d91504 Merge branch 'v17/dev' into v17/feature/tree-views 2026-05-13 10:14:43 +02:00
Mads Rasmussen cbd0e3562e Merge branch 'main' into v17/feature/tree-views 2026-05-05 16:29:54 +02:00
Mads Rasmussen d64f79d2d8 Introduce TreeItemApiBase and card API wiring 2026-04-24 12:30:27 +02:00
Mads Rasmussen 3100c85904 add phase 9 2026-04-23 10:16:06 +02:00
Mads Rasmussen 4e9924d7d6 add double click support for classic tree items 2026-04-23 09:18:37 +02:00
Mads Rasmussen a2a65d91a7 update plan 2026-04-22 21:01:56 +02:00
Mads Rasmussen 111f494a44 add tests for the view manager 2026-04-22 21:01:48 +02:00
Mads Rasmussen ba2eb4de6d localize tree view labels 2026-04-22 21:00:45 +02:00
Mads Rasmussen a1de5b3902 introduce tree item picker breadcrumbs 2026-04-22 13:55:19 +02:00
Mads Rasmussen 8a847ffd00 remove breadcrumb from card view 2026-04-22 12:24:04 +02:00
Mads Rasmussen 09d01a240d remove unused 2026-04-22 12:23:32 +02:00
Mads Rasmussen 760fb7164b Update tree-views-plan.md 2026-04-22 11:08:19 +02:00
Mads Rasmussen 846001393b introduce base class for tree views 2026-04-21 15:17:29 +02:00
Mads Rasmussen 57514815d1 breadcrumbs first iteration 2026-04-21 14:50:21 +02:00
Mads Rasmussen 26941c5413 render folder icon 2026-04-21 13:51:55 +02:00
Mads Rasmussen 4c4054e4fe Add card tree view and tree-item-card components 2026-04-21 13:18:21 +02:00
Mads Rasmussen b39dfc9526 import toolbar 2026-04-20 21:46:01 +02:00
Mads Rasmussen 392c7f8dec Add tree toolbar and view bundle components 2026-04-20 21:44:05 +02:00
Mads Rasmussen d9358463c1 Add pluggable tree view system & classic view 2026-04-20 21:36:34 +02:00
Mads Rasmussen 6ba81b0302 add temp method 2026-04-20 20:35:02 +02:00
Mads Rasmussen 3ad2ea3327 Add tree view & tree-item-card types and exports 2026-04-20 20:24:06 +02:00
Mads Rasmussen 026acdf64c add progress tracker 2026-04-20 20:10:51 +02:00
Mads Rasmussen 5790763eb3 Create tree-views-plan.md 2026-04-20 15:06:41 +02:00
114 changed files with 4544 additions and 732 deletions
@@ -4,7 +4,11 @@ import type {
UmbPickerTreeDataSource,
} from '@umbraco-cms/backoffice/picker-data-source';
import type { UmbSearchRequestArgs, UmbSearchResultItemModel } from '@umbraco-cms/backoffice/search';
import type { UmbTreeChildrenOfRequestArgs, UmbTreeItemModel } from '@umbraco-cms/backoffice/tree';
import type {
UmbTreeAncestorsOfRequestArgs,
UmbTreeChildrenOfRequestArgs,
UmbTreeItemModel,
} from '@umbraco-cms/backoffice/tree';
export class ExampleCustomPickerTreePropertyEditorDataSource
extends UmbControllerBase
@@ -58,9 +62,19 @@ export class ExampleCustomPickerTreePropertyEditorDataSource
return { data };
}
async requestTreeItemAncestors() {
// TODO: implement when needed
return { data: [] };
async requestTreeItemAncestors(args: UmbTreeAncestorsOfRequestArgs) {
const ancestors: Array<UmbTreeItemModel> = [];
let current = customItems.find((item) => item.unique === args.treeItem.unique);
// Walk up the parent chain, building the list root-first and including the item itself.
while (current) {
ancestors.unshift(current);
const parentUnique = current.parent.unique;
current = parentUnique ? customItems.find((item) => item.unique === parentUnique) : undefined;
}
return { data: ancestors };
}
async requestItems(uniques: Array<string>) {
@@ -2054,6 +2054,12 @@ export default {
advancedGroup: 'Avanceret',
webhooks: 'Webhooks',
},
tree: {
classicViewLabel: 'Træ',
cardViewLabel: 'Kort',
tableViewLabel: 'Tabel',
children: 'Underelementer',
},
update: {
updateAvailable: 'Ny opdatering er klar',
updateDownloadText: '%0% er klar, klik her for at downloade',
@@ -2138,6 +2138,13 @@ export default {
advancedGroup: 'Advanced',
webhooks: 'Webhooks',
},
tree: {
classicViewLabel: 'Tree',
cardViewLabel: 'Cards',
tableViewLabel: 'Table',
children: 'Children',
noItems: 'No items',
},
update: {
updateAvailable: 'New update ready',
updateDownloadText: '%0% is ready, click here for download',
@@ -5,6 +5,7 @@ import {
html,
ifDefined,
keyed,
nothing,
property,
ref,
repeat,
@@ -21,6 +22,8 @@ export interface UmbTableItem {
entityType?: string;
data: Array<UmbTableItemData>;
selectable?: boolean;
active?: boolean;
hasChildren?: boolean;
}
export interface UmbTableItemData {
@@ -183,14 +186,38 @@ export class UmbTableElement extends UmbLitElement {
private _selectionMode = false;
#lastColumnKey = '';
#hasChildrenColumn = false;
#cellElementCache = new WeakMap<UmbTableItem, Map<string, UmbTableColumnLayoutElement>>();
#rowRenderedCallbacks = new Map<string, { fn: (el: Element | undefined) => void; current: UmbTableItem }>();
#getRowRenderedCallback(item: UmbTableItem): (el: Element | undefined) => void {
const existing = this.#rowRenderedCallbacks.get(item.id);
if (existing) {
existing.current = item;
return existing.fn;
}
const entry = {
current: item,
fn: (el: Element | undefined) => this.onRowRendered?.(el as HTMLElement | undefined, entry.current),
};
this.#rowRenderedCallbacks.set(item.id, entry);
return entry.fn;
}
override willUpdate(changedProperties: Map<string | number | symbol, unknown>) {
super.willUpdate(changedProperties);
if (changedProperties.has('selection')) {
this._selectionMode = this.selection.length > 0;
}
if (changedProperties.has('_items')) {
const currentIds = new Set(this._items.map((i) => i.id));
for (const id of this.#rowRenderedCallbacks.keys()) {
if (!currentIds.has(id)) this.#rowRenderedCallbacks.delete(id);
}
this.#hasChildrenColumn = this._items.some((i) => i.hasChildren);
}
}
override updated(changedProperties: Map<string | number | symbol, unknown>) {
@@ -198,8 +225,9 @@ export class UmbTableElement extends UmbLitElement {
// The `keyed` directive in `render()` rebuilds the `<uui-table>` element when the column
// signature changes. The sorter caches its container element on first initialization, so
// when the table is replaced we need to reattach it to the fresh node.
if (changedProperties.has('columns') && this._sortable) {
// when the table is replaced we need to reattach it to the fresh node. Gate on the key
// because the key also depends on `#hasChildrenColumn`, which can toggle from an items update alone.
if (this._sortable) {
const columnKey = this.#getColumnKey();
if (columnKey !== this.#lastColumnKey) {
this.#lastColumnKey = columnKey;
@@ -210,7 +238,7 @@ export class UmbTableElement extends UmbLitElement {
}
#getColumnKey() {
return JSON.stringify(this.columns.map((column) => column.alias));
return JSON.stringify([this.#hasChildrenColumn, ...this.columns.map((column) => column.alias)]);
}
#sorter = new UmbSorterController<UmbTableItem>(this, {
@@ -316,8 +344,12 @@ export class UmbTableElement extends UmbLitElement {
this.#getColumnKey(),
html`
<uui-table class="uui-text">
${this.#hasChildrenColumn ? html`<uui-table-column style="width: 24px;"></uui-table-column>` : nothing}
<uui-table-column style=${ifDefined(style)}></uui-table-column>
<uui-table-head>
${this.#hasChildrenColumn
? html`<uui-table-head-cell class="children-indicator-cell"></uui-table-head-cell>`
: nothing}
${this._renderHeaderCheckboxCell()}
${repeat(
this.columns,
@@ -375,15 +407,19 @@ export class UmbTableElement extends UmbLitElement {
const isItemSelectable = this.#isSelectableItem(item);
return html`
<uui-table-row
${ref((el) => {
this.onRowRendered?.(el as HTMLElement | undefined, item);
})}
${ref(this.#getRowRenderedCallback(item))}
data-sortable-id=${item.id}
?selectable=${this.config.allowSelection && !this._sortable && isItemSelectable}
?select-only=${this._selectionMode || this.config.selectOnly}
?selected=${this._isSelected(item.id)}
?active=${item.active ?? false}
@selected=${() => this._selectRow(item)}
@deselected=${() => this._deselectRow(item)}>
${this.#hasChildrenColumn
? html`<uui-table-cell class="children-indicator-cell">
${item.hasChildren ? html`<uui-symbol-expand></uui-symbol-expand>` : nothing}
</uui-table-cell>`
: nothing}
${this._renderRowCheckboxCell(item)}
${repeat(
this.columns,
@@ -504,6 +540,12 @@ export class UmbTableElement extends UmbLitElement {
display: inline-block;
}
.children-indicator-cell {
padding-right: 0;
display: flex;
align-items: center;
}
uui-table-head-cell:focus,
uui-table-head-cell:focus-within,
uui-table-head-cell:hover {
@@ -0,0 +1,22 @@
import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension-registry';
import { UmbTreeCreateActionButtonElement } from './tree-create-action.element.js';
import { UmbTreeCreateActionApi } from './tree-create-action.api.js';
export const manifests: Array<UmbExtensionManifest | UmbExtensionManifestKind> = [
{
type: 'kind',
alias: 'Umb.Kind.TreeAction.Create',
matchKind: 'create',
matchType: 'treeAction',
manifest: {
type: 'treeAction',
kind: 'create',
api: UmbTreeCreateActionApi,
element: UmbTreeCreateActionButtonElement,
weight: 1200,
meta: {
label: '#actions_createFor',
},
},
},
];
@@ -0,0 +1,83 @@
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import { UmbTreeActionBase } from '../tree-action-base.js';
import { UMB_ENTITY_CONTEXT } from '@umbraco-cms/backoffice/entity';
import { UmbExtensionsApiInitializer } from '@umbraco-cms/backoffice/extension-api';
import type { UmbExtensionApiInitializer } from '@umbraco-cms/backoffice/extension-api';
import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';
import type { ManifestEntityCreateOptionAction } from '@umbraco-cms/backoffice/entity-create-option-action';
import { UmbArrayState, UmbBooleanState } from '@umbraco-cms/backoffice/observable-api';
import { combineLatest } from '@umbraco-cms/backoffice/external/rxjs';
import type { UmbTreeCreateOption } from './types.js';
type ManifestType = ManifestEntityCreateOptionAction;
export class UmbTreeCreateActionApi extends UmbTreeActionBase {
#options = new UmbArrayState<UmbTreeCreateOption>([], (x) => x.alias);
public readonly options = this.#options.asObservable();
#multipleOptions = new UmbBooleanState(false);
public readonly multipleOptions = this.#multipleOptions.asObservable();
#controllers = new Map<string, UmbExtensionApiInitializer<ManifestType>>();
#extensionsInitializer?: UmbExtensionsApiInitializer<ManifestType>;
constructor(host: UmbControllerHost) {
super(host);
this.consumeContext(UMB_ENTITY_CONTEXT, (context) => {
if (!context) return;
this.observe(
combineLatest([context.entityType, context.unique]),
([entityType, unique]) => {
if (!entityType || unique === undefined) {
this.#options.setValue([]);
this.#multipleOptions.setValue(false);
return;
}
this.#extensionsInitializer?.destroy();
this.#extensionsInitializer = new UmbExtensionsApiInitializer(
this,
umbExtensionsRegistry,
'entityCreateOptionAction',
(manifest: ManifestType) => [{ entityType, unique, meta: manifest.meta }],
(manifest: ManifestType) => manifest.forEntityTypes.includes(entityType),
async (controllers) => {
const apiControllers = controllers as unknown as Array<UmbExtensionApiInitializer<ManifestType>>;
this.#controllers.clear();
const options: Array<UmbTreeCreateOption> = [];
for (const controller of apiControllers) {
const manifest = controller.manifest;
if (!manifest) continue;
this.#controllers.set(manifest.alias, controller);
options.push({
alias: manifest.alias,
label: manifest.meta.label ?? manifest.name,
icon: manifest.meta.icon,
href: await controller.api?.getHref(),
additionalOptions: manifest.meta.additionalOptions,
});
}
this.#options.setValue(options);
this.#multipleOptions.setValue(options.length > 1);
},
) as unknown as UmbExtensionsApiInitializer<ManifestType>;
},
'umbEntityContextObserver',
);
});
}
async executeByAlias(alias: string) {
const controller = this.#controllers.get(alias);
if (!controller?.api) throw new Error('No API found');
await controller.api.execute();
}
async execute() {}
}
export { UmbTreeCreateActionApi as api };
@@ -0,0 +1,120 @@
import { customElement, html, ifDefined, nothing, state } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import type { UmbTreeCreateActionApi } from './tree-create-action.api.js';
import type { UmbTreeCreateOption } from './types.js';
@customElement('umb-tree-create-action-button')
export class UmbTreeCreateActionButtonElement extends UmbLitElement {
@state()
private _popoverOpen = false;
@state()
private _multipleOptions = false;
@state()
private _options: Array<UmbTreeCreateOption> = [];
#createLabel = this.localize.term('general_create');
#api: UmbTreeCreateActionApi | undefined;
public get api(): UmbTreeCreateActionApi | undefined {
return this.#api;
}
public set api(value: UmbTreeCreateActionApi | undefined) {
this.#api = value;
this.observe(this.#api?.options, (options) => {
this._options = options ?? [];
});
this.observe(this.#api?.multipleOptions, (multipleOptions) => {
this._multipleOptions = multipleOptions ?? false;
});
}
#onPopoverToggle(event: Event) {
// TODO: This ignorer is just needed for JSON SCHEMA TO WORK, As its not updated with latest TS yet.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
this._popoverOpen = event.newState === 'open';
}
async #onClick(event: Event, alias: string, href?: string) {
if (href) return;
event.stopPropagation();
await this.#api?.executeByAlias(alias).catch(() => {});
}
#getTarget(href?: string) {
if (href?.startsWith('http')) return '_blank';
return '_self';
}
override render() {
if (this._options.length === 0) return nothing;
return this._multipleOptions ? this.#renderMultiOptionAction() : this.#renderSingleOptionAction();
}
#renderSingleOptionAction() {
const option = this._options[0];
return html`
<uui-button
label=${this.#createLabel}
color="default"
look="outline"
href=${ifDefined(option?.href)}
target=${this.#getTarget(option?.href)}
@click=${(event: Event) => this.#onClick(event, option?.alias, option?.href)}></uui-button>
`;
}
#renderMultiOptionAction() {
return html`
<uui-button
popovertarget="tree-create-action-menu-popover"
label=${this.#createLabel}
color="default"
look="outline">
${this.#createLabel}
<uui-symbol-expand .open=${this._popoverOpen}></uui-symbol-expand>
</uui-button>
${this.#renderDropdown()}
`;
}
#renderDropdown() {
return html`
<uui-popover-container
id="tree-create-action-menu-popover"
placement="bottom-start"
@toggle=${this.#onPopoverToggle}>
<umb-popover-layout>
<uui-scroll-container>
${this._options.map((option) => this.#renderMenuItem(option))}
</uui-scroll-container>
</umb-popover-layout>
</uui-popover-container>
`;
}
#renderMenuItem(option: UmbTreeCreateOption) {
const label = option.label ? this.localize.string(option.label) : option.alias;
return html`
<uui-menu-item
label=${option.additionalOptions ? label + '...' : label}
href=${ifDefined(option.href)}
target=${this.#getTarget(option.href)}
@click=${(event: Event) => this.#onClick(event, option.alias, option.href)}>
<umb-icon slot="icon" .name=${option.icon}></umb-icon>
</uui-menu-item>
`;
}
}
export { UmbTreeCreateActionButtonElement as element };
declare global {
interface HTMLElementTagNameMap {
'umb-tree-create-action-button': UmbTreeCreateActionButtonElement;
}
}
@@ -0,0 +1,20 @@
import type { ManifestTreeAction } from '../tree-action.extension.js';
export interface ManifestTreeActionCreateKind extends ManifestTreeAction {
type: 'treeAction';
kind: 'create';
}
export interface UmbTreeCreateOption {
alias: string;
label: string;
icon?: string;
href?: string;
additionalOptions?: boolean;
}
declare global {
interface UmbExtensionManifestMap {
umbTreeActionCreateKind: ManifestTreeActionCreateKind;
}
}
@@ -0,0 +1,6 @@
export { UmbTreeActionBase } from './tree-action-base.js';
export type { UmbTreeAction } from './tree-action-base.js';
export type { ManifestTreeAction, MetaTreeAction } from './tree-action.extension.js';
export { UmbTreeCreateActionApi } from './create/tree-create-action.api.js';
export { UmbTreeCreateActionButtonElement } from './create/tree-create-action.element.js';
export type { UmbTreeCreateOption, ManifestTreeActionCreateKind } from './create/types.js';
@@ -0,0 +1,4 @@
import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension-registry';
import { manifests as createManifests } from './create/manifests.js';
export const manifests: Array<UmbExtensionManifest | UmbExtensionManifestKind> = [...createManifests];
@@ -0,0 +1,32 @@
import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
import type { UmbApi } from '@umbraco-cms/backoffice/extension-api';
/**
* A tree-level action, rendered in the tree's header to operate on the tree as a whole.
*/
export interface UmbTreeAction extends UmbApi {
/**
* The href location, the action will act as a link.
* @returns {undefined | Promise<string | undefined>}
*/
getHref?: () => Promise<string | undefined>;
/**
* Determine if the UI should indicate that more options will appear when interacting with this.
* @returns {undefined | Promise<boolean | undefined>}
*/
hasAdditionalOptions?: () => Promise<boolean | undefined>;
/**
* The `execute` method, the action will act as a button.
* @returns {Promise<void>}
*/
execute(): Promise<void>;
}
/**
* Base class for {@link UmbTreeAction} implementations.
*/
export abstract class UmbTreeActionBase extends UmbControllerBase implements UmbTreeAction {
abstract execute(): Promise<void>;
}
@@ -0,0 +1,23 @@
import type { UmbTreeAction } from './tree-action-base.js';
import type { UmbControllerHostElement } from '@umbraco-cms/backoffice/controller-api';
import type { ManifestElementAndApi, ManifestWithDynamicConditions } from '@umbraco-cms/backoffice/extension-api';
export interface ManifestTreeAction
extends
ManifestElementAndApi<UmbControllerHostElement, UmbTreeAction>,
ManifestWithDynamicConditions<UmbExtensionConditionConfig> {
type: 'treeAction';
meta: MetaTreeAction;
}
export interface MetaTreeAction {
label: string;
href?: string;
additionalOptions?: boolean;
}
declare global {
interface UmbExtensionManifestMap {
umbTreeAction: ManifestTreeAction;
}
}
@@ -1,2 +1,6 @@
export * from './tree-action-bundle.element.js';
export * from './tree-load-more-button.element.js';
export * from './tree-load-prev-button.element.js';
export * from './tree-pagination.element.js';
export * from './tree-toolbar.element.js';
export * from './tree-view-bundle.element.js';
@@ -0,0 +1,23 @@
import { css, customElement, html } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
@customElement('umb-tree-action-bundle')
export class UmbTreeActionBundleElement extends UmbLitElement {
override render() {
return html`<umb-extension-with-api-slot type="treeAction"></umb-extension-with-api-slot>`;
}
static override readonly styles = [
css`
:host {
display: contents;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
'umb-tree-action-bundle': UmbTreeActionBundleElement;
}
}
@@ -0,0 +1,71 @@
import { UMB_TREE_CONTEXT } from '../tree.context.token.js';
import type { UUIPaginationEvent } from '@umbraco-cms/backoffice/external/uui';
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
import { css, customElement, html, nothing, state } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
@customElement('umb-tree-pagination')
export class UmbTreePaginationElement extends UmbLitElement {
@state()
private _totalPages = 1;
@state()
private _currentPage = 1;
#treeContext?: typeof UMB_TREE_CONTEXT.TYPE;
constructor() {
super();
this.consumeContext(UMB_TREE_CONTEXT, (context) => {
this.#treeContext = context;
this.observe(
context?.pagination.currentPage,
(currentPage) => (this._currentPage = currentPage ?? 1),
'_observeCurrentPage',
);
this.observe(
context?.pagination.totalPages,
(totalPages) => (this._totalPages = totalPages ?? 1),
'_observeTotalPages',
);
});
}
#onChange(event: UUIPaginationEvent) {
this.#treeContext?.loadPage?.(event.target.current);
}
override render() {
if (this._totalPages <= 1) return nothing;
return html`<uui-pagination
.current=${this._currentPage}
.total=${this._totalPages}
firstlabel=${this.localize.term('general_first')}
previouslabel=${this.localize.term('general_previous')}
nextlabel=${this.localize.term('general_next')}
lastlabel=${this.localize.term('general_last')}
@change=${this.#onChange}></uui-pagination>`;
}
static override styles = [
UmbTextStyles,
css`
:host {
display: block;
text-align: center;
}
uui-pagination {
display: block;
margin-top: var(--uui-size-layout-1);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
'umb-tree-pagination': UmbTreePaginationElement;
}
}
@@ -0,0 +1,53 @@
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
import { css, html, customElement, nothing, property } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import './tree-action-bundle.element.js';
import './tree-view-bundle.element.js';
@customElement('umb-tree-toolbar')
export class UmbTreeToolbarElement extends UmbLitElement {
/**
* When true the tree actions are hidden.
* Defaults to true — tree actions are not shown unless explicitly opted in with hide-tree-actions="false".
*/
@property({ type: Boolean, attribute: 'hide-tree-actions' })
hideTreeActions: boolean = true;
override render() {
return html`
<div id="toolbar">
${!this.hideTreeActions ? html`<umb-tree-action-bundle></umb-tree-action-bundle>` : nothing}
<umb-tree-view-bundle></umb-tree-view-bundle>
</div>
`;
}
static override styles = [
UmbTextStyles,
css`
:host {
display: contents;
}
#toolbar {
display: flex;
align-items: center;
width: 100%;
padding: 0 0 var(--uui-size-layout-1) 0;
box-sizing: border-box;
}
umb-tree-view-bundle {
display: inline-block;
margin-left: auto;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
'umb-tree-toolbar': UmbTreeToolbarElement;
}
}
@@ -0,0 +1,114 @@
import { UMB_TREE_CONTEXT } from '../tree.context.token.js';
import type { ManifestTreeView } from '../view/types.js';
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
import { css, customElement, html, nothing, query, repeat, state } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import type { UUIPopoverContainerElement } from '@umbraco-cms/backoffice/external/uui';
@customElement('umb-tree-view-bundle')
export class UmbTreeViewBundleElement extends UmbLitElement {
@state()
private _views: Array<ManifestTreeView> = [];
@state()
private _currentView?: ManifestTreeView;
#treeContext?: typeof UMB_TREE_CONTEXT.TYPE;
constructor() {
super();
this.consumeContext(UMB_TREE_CONTEXT, (context) => {
this.#treeContext = context;
this.#observeViews();
});
}
#observeViews() {
if (!this.#treeContext?.view) return;
this.observe(
this.#treeContext.view.views,
(views) => {
this._views = views;
},
'umbTreeViewsObserver',
);
this.observe(
this.#treeContext.view.currentView,
(currentView) => {
this._currentView = currentView;
},
'umbTreeCurrentViewObserver',
);
}
@query('#tree-view-bundle-popover')
private _popover?: UUIPopoverContainerElement;
#onClick(view: ManifestTreeView) {
this.#treeContext?.view?.setCurrentView(view);
setTimeout(() => {
// TODO: This ignorer is just needed for JSON SCHEMA TO WORK, As its not updated with latest TS yet.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
this._popover?.hidePopover();
}, 100);
}
override render() {
if (!this._currentView) return nothing;
if (this._views.length <= 1) return nothing;
return html`
<uui-button compact popovertarget="tree-view-bundle-popover" label=${this.localize.term('general_switchView')}>
<umb-icon name=${this._currentView.meta.icon}></umb-icon>
</uui-button>
<uui-popover-container id="tree-view-bundle-popover" placement="bottom-end">
<umb-popover-layout>
<div class="view-dropdown">
${repeat(
this._views,
(view) => view.alias,
(view) => this.#renderItem(view),
)}
</div>
</umb-popover-layout>
</uui-popover-container>
`;
}
#renderItem(view: ManifestTreeView) {
return html`
<uui-menu-item
label=${this.localize.string(view.meta.label)}
@click-label=${() => this.#onClick(view)}
?active=${view.alias === this._currentView?.alias}>
<umb-icon slot="icon" name=${view.meta.icon}></umb-icon>
</uui-menu-item>
`;
}
static override styles = [
UmbTextStyles,
css`
:host {
--uui-button-content-align: left;
--uui-menu-item-flat-structure: 1;
display: contents;
}
.view-dropdown {
padding: var(--uui-size-space-3);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
'umb-tree-view-bundle': UmbTreeViewBundleElement;
}
}
@@ -0,0 +1 @@
export const UMB_TREE_ALIAS_CONDITION = 'Umb.Condition.TreeAlias';
@@ -0,0 +1,3 @@
export { UmbTreeAliasCondition } from './tree-alias.condition.js';
export { UMB_TREE_ALIAS_CONDITION } from './constants.js';
export type { TreeAliasConditionConfig } from './types.js';
@@ -0,0 +1,12 @@
import { UmbTreeAliasCondition } from './tree-alias.condition.js';
import { UMB_TREE_ALIAS_CONDITION } from './constants.js';
import type { ManifestCondition } from '@umbraco-cms/backoffice/extension-api';
export const manifests: Array<ManifestCondition> = [
{
type: 'condition',
name: 'Tree Alias Condition',
alias: UMB_TREE_ALIAS_CONDITION,
api: UmbTreeAliasCondition,
},
];
@@ -0,0 +1,16 @@
import { UMB_TREE_CONTEXT } from '../tree.context.token.js';
import type { TreeAliasConditionConfig } from './types.js';
import type { UmbConditionControllerArguments, UmbExtensionCondition } from '@umbraco-cms/backoffice/extension-api';
import { UmbConditionBase } from '@umbraco-cms/backoffice/extension-registry';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
export class UmbTreeAliasCondition extends UmbConditionBase<TreeAliasConditionConfig> implements UmbExtensionCondition {
constructor(host: UmbControllerHost, args: UmbConditionControllerArguments<TreeAliasConditionConfig>) {
super(host, args);
this.consumeContext(UMB_TREE_CONTEXT, (context) => {
this.permitted = context?.manifest?.alias === this.config.match;
});
}
}
export default UmbTreeAliasCondition;
@@ -0,0 +1,18 @@
import type { UMB_TREE_ALIAS_CONDITION } from './constants.js';
import type { UmbConditionConfigBase } from '@umbraco-cms/backoffice/extension-api';
// eslint-disable-next-line @typescript-eslint/naming-convention
export type TreeAliasConditionConfig = UmbConditionConfigBase<typeof UMB_TREE_ALIAS_CONDITION> & {
/**
* The tree that this extension should be available in.
* @example
* "Umb.Tree.DocumentType"
*/
match: string;
};
declare global {
interface UmbExtensionConditionConfigMap {
TreeAliasConditionConfig: TreeAliasConditionConfig;
}
}
@@ -1,5 +1,7 @@
import { UmbTreeItemOpenEvent } from '../tree-item/events/tree-item-open.event.js';
import { UmbTreeItemActiveManager } from '../active-manager/tree-active-manager.js';
import { UmbTreeExpansionManager } from '../expansion-manager/index.js';
import { UmbTreeViewManager } from '../view/tree-view.manager.js';
import { UmbTreeItemChildrenManager } from '../tree-item/tree-item-children.manager.js';
import { UmbTreeItemTargetExpansionManager } from '../tree-item/tree-item-expansion.manager.js';
import { UMB_TREE_CONTEXT } from '../tree.context.token.js';
@@ -12,40 +14,60 @@ import type { UmbTreeRootItemsRequestArgs } from '../data/types.js';
import { UmbBooleanState, UmbObjectState } from '@umbraco-cms/backoffice/observable-api';
import { UmbContextBase } from '@umbraco-cms/backoffice/class-api';
import { UmbDeprecation, UmbSelectionManager, debounce } from '@umbraco-cms/backoffice/utils';
import { UmbInteractionMemoryManager } from '@umbraco-cms/backoffice/interaction-memory';
import { UmbExtensionApiInitializer } from '@umbraco-cms/backoffice/extension-api';
import { umbExtensionsRegistry, type ManifestRepository } from '@umbraco-cms/backoffice/extension-registry';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import { UmbEntityContext } from '@umbraco-cms/backoffice/entity';
import type { UmbEntityUnique } from '@umbraco-cms/backoffice/entity';
export class UmbDefaultTreeContext<
TreeItemType extends UmbTreeItemModel,
TreeRootType extends UmbTreeRootModel,
RequestArgsType extends UmbTreeRootItemsRequestArgs = UmbTreeRootItemsRequestArgs,
>
TreeItemType extends UmbTreeItemModel,
TreeRootType extends UmbTreeRootModel,
RequestArgsType extends UmbTreeRootItemsRequestArgs = UmbTreeRootItemsRequestArgs,
>
extends UmbContextBase
implements UmbTreeContext<TreeItemType, TreeRootType, RequestArgsType>
{
#treeRoot = new UmbObjectState<TreeRootType | undefined>(undefined);
public readonly treeRoot = this.#treeRoot.asObservable();
#entityContext = new UmbEntityContext(this);
public selectableFilter?: (item: TreeItemType) => boolean = () => true;
public filter?: (item: TreeItemType) => boolean = () => true;
public readonly selection = new UmbSelectionManager(this);
public readonly expansion = new UmbTreeExpansionManager(this);
public readonly interactionMemory = new UmbInteractionMemoryManager(this);
public readonly view = new UmbTreeViewManager(this, { interactionMemoryManager: this.interactionMemory });
#hideTreeRoot = new UmbBooleanState(false);
public readonly hideTreeRoot = this.#hideTreeRoot.asObservable();
#hideTreeItemActions = new UmbBooleanState(false);
public readonly hideTreeItemActions = this.#hideTreeItemActions.asObservable();
#selectOnly = new UmbBooleanState(undefined);
public readonly selectOnly = this.#selectOnly.asObservable();
#selectOnlyConfig?: boolean;
#isMenu = new UmbBooleanState(false);
public readonly isMenu = this.#isMenu.asObservable();
#expandTreeRoot = new UmbBooleanState(undefined);
public readonly expandTreeRoot = this.#expandTreeRoot.asObservable();
#treeItemChildrenManager = new UmbTreeItemChildrenManager<TreeItemType, TreeRootType, RequestArgsType>(this);
public readonly rootItems = this.#treeItemChildrenManager.children;
public readonly currentPageItems = this.#treeItemChildrenManager.currentPageChildren;
public readonly hasChildren = this.#treeItemChildrenManager.hasChildren;
public readonly pagination = this.#treeItemChildrenManager.offsetPagination;
public readonly targetPagination = this.#treeItemChildrenManager.targetPagination;
public readonly startNode = this.#treeItemChildrenManager.startNode;
public readonly foldersOnly = this.#treeItemChildrenManager.foldersOnly;
public readonly additionalRequestArgs = this.#treeItemChildrenManager.additionalRequestArgs;
public readonly isLoadingChildren = this.#treeItemChildrenManager.isLoading;
public readonly isLoadingPrevChildren = this.#treeItemChildrenManager.isLoadingPrevChildren;
public readonly isLoadingNextChildren = this.#treeItemChildrenManager.isLoadingNextChildren;
@@ -73,8 +95,13 @@ export class UmbDefaultTreeContext<
constructor(host: UmbControllerHost) {
super(host, UMB_TREE_CONTEXT);
this.#treeItemChildrenManager.setTakeSize(50);
// always load the tree root because we need the root entity to reload the entire tree
this.#loadTreeRoot();
// Auto-enable selectOnly when a selection exists and it was not explicitly set.
this.observe(this.selection.selection, (selection) => {
if (this.#selectOnlyConfig === undefined) {
this.#selectOnly.setValue((selection?.length ?? 0) > 0);
}
});
}
// TODO: find a generic way to do this
@@ -93,6 +120,9 @@ export class UmbDefaultTreeContext<
public set manifest(manifest: ManifestTree | undefined) {
if (this.#manifest === manifest) return;
this.#manifest = manifest;
if (manifest?.alias) {
this.view.setTreeAlias(manifest.alias);
}
this.#observeRepository(this.#manifest?.meta.repositoryAlias);
}
public get manifest() {
@@ -118,6 +148,13 @@ export class UmbDefaultTreeContext<
return this.#repository;
}
public open(item: TreeItemType): void {
// The tree root has a null unique and is not a navigable target, so opening it is a no-op.
const unique = item.unique as UmbEntityUnique;
if (unique === null || unique === undefined) return;
this.getHostElement().dispatchEvent(new UmbTreeItemOpenEvent({ unique, entityType: item.entityType }));
}
/**
* Loads the tree
* @memberof UmbDefaultTreeContext
@@ -136,9 +173,12 @@ export class UmbDefaultTreeContext<
/**
* Reloads the tree
* @param pageNumber
* @memberof UmbDefaultTreeContext
* @returns {Promise<void>}
*/
public loadPage = (pageNumber: number): Promise<void> => this.#treeItemChildrenManager.loadPage(pageNumber);
public loadMore = (): Promise<void> => this.#treeItemChildrenManager.loadNextChildren();
/**
@@ -160,14 +200,21 @@ export class UmbDefaultTreeContext<
const hasStartNode = this.getStartNode();
const hideTreeRoot = this.getHideTreeRoot();
if (hasStartNode || hideTreeRoot) {
// Always load the tree root entity, even when the root node is hidden or we are drilled
// into a start node. The root entity is required to wire up the target/expansion handling
// at root level: the tree's expansion manager only starts observing once it has been given
// the root tree item, which is what lets a deep-link / breadcrumb target paginate the root.
this.#loadTreeRoot(reload);
// Load children when drilled into a node, when the root is hidden, or when
// the root is pre-expanded — all cases where children must be immediately visible.
if (hasStartNode || hideTreeRoot || this.getExpandTreeRoot()) {
if (reload) {
this.#treeItemChildrenManager.reloadChildren();
} else {
this.#treeItemChildrenManager.loadChildren();
}
} else {
this.#loadTreeRoot(reload);
}
}
@@ -180,6 +227,10 @@ export class UmbDefaultTreeContext<
this.#treeRoot.setValue(data);
this.#treeItemChildrenManager.setTreeItem(data);
this.#treeItemExpansionManager.setTreeItem(data);
if (!this.getStartNode()) {
this.#entityContext.setEntityType(data.entityType);
this.#entityContext.setUnique(data.unique);
}
this.pagination.setTotalItems(1);
if (!reload) {
@@ -190,6 +241,32 @@ export class UmbDefaultTreeContext<
}
}
setHideTreeItemActions(value: boolean) {
this.#hideTreeItemActions.setValue(value);
}
getHideTreeItemActions(): boolean {
return this.#hideTreeItemActions.getValue();
}
setSelectOnly(value: boolean | undefined) {
this.#selectOnlyConfig = value;
// If undefined (auto-detect), compute from current selection so re-renders don't reset the auto-detected state.
this.#selectOnly.setValue(value ?? (this.selection.getSelection()?.length ?? 0) > 0);
}
getSelectOnly(): boolean {
return this.#selectOnly.getValue() ?? false;
}
setIsMenu(value: boolean) {
this.#isMenu.setValue(value);
}
getIsMenu(): boolean {
return this.#isMenu.getValue();
}
/**
* Sets the hideTreeRoot config
* @param {boolean} hideTreeRoot - Whether to hide the tree root
@@ -218,6 +295,10 @@ export class UmbDefaultTreeContext<
*/
setStartNode(startNode: UmbTreeStartNode | undefined) {
this.#treeItemChildrenManager.setStartNode(startNode);
if (startNode) {
this.#entityContext.setEntityType(startNode.entityType);
this.#entityContext.setUnique(startNode.unique);
}
// we need to reset the tree if this config changes
this.#clearTree();
this.loadTree();
@@ -7,9 +7,17 @@ import type {
} from '../types.js';
import type { UmbTreeExpansionModel } from '../expansion-manager/types.js';
import type { UmbDefaultTreeContext } from './default-tree.context.js';
import { css, customElement, html, nothing, property, repeat, state } from '@umbraco-cms/backoffice/external/lit';
import { css, customElement, html, nothing, property, state } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
import { createExtensionElement } from '@umbraco-cms/backoffice/extension-api';
import type { PropertyValueMap } from '@umbraco-cms/backoffice/external/lit';
import { UmbInteractionMemoriesChangeEvent } from '@umbraco-cms/backoffice/interaction-memory';
import type { UmbInteractionMemoryModel } from '@umbraco-cms/backoffice/interaction-memory';
import { jsonStringComparison } from '@umbraco-cms/backoffice/observable-api';
import '../components/tree-toolbar.element.js';
@customElement('umb-default-tree')
export class UmbDefaultTreeElement extends UmbLitElement {
@@ -26,7 +34,53 @@ export class UmbDefaultTreeElement extends UmbLitElement {
}
public set api(value: UmbDefaultTreeContext<UmbTreeItemModel, UmbTreeRootModel> | undefined) {
this._api = value;
this.#observeData();
if (value) {
// Derive emptiness from the loaded children, not `hasChildren`: the latter is also written by the
// concurrent tree-root load (with the root's own child count), which can clobber the start node's value
// and intermittently hide the empty state.
this.observe(
value.rootItems,
(items) => (this._hasItems = (items?.length ?? 0) > 0),
'umbTreeRootItemsObserver',
);
// Track loading so the empty state isn't shown before the children have loaded, or while reloading.
this.observe(
value.isLoadingChildren,
(isLoadingChildren) => {
this._isLoadingChildren = isLoadingChildren ?? false;
if (isLoadingChildren) {
this.#hasBeenLoading = true;
} else if (this.#hasBeenLoading) {
this._initialLoadDone = true;
}
},
'umbTreeIsLoadingChildrenObserver',
);
}
if (value?.view) {
this.observe(value.view.currentView, async (manifest) => {
const element = manifest ? await createExtensionElement(manifest) : null;
if (element && 'manifest' in element) {
(element as HTMLElement & { manifest: unknown }).manifest = manifest;
}
this._viewElement = element;
});
}
if (value?.interactionMemory) {
// Snapshot before forwarding so the first observer emission is not treated as a change.
this.#lastDispatchedMemories = this.interactionMemories ?? [];
this.interactionMemories?.forEach((m) => value.interactionMemory!.setMemory(m));
this.observe(
value.interactionMemory.memories,
(memories) => {
if (!jsonStringComparison(memories, this.#lastDispatchedMemories)) {
this.#lastDispatchedMemories = memories;
this.dispatchEvent(new UmbInteractionMemoriesChangeEvent());
}
},
'umbTreeInteractionMemoryObserver',
);
}
}
@property({ type: Object, attribute: false })
@@ -59,45 +113,40 @@ export class UmbDefaultTreeElement extends UmbLitElement {
@property({ attribute: false })
expansion: UmbTreeExpansionModel = [];
@state()
private _rootItems: UmbTreeItemModel[] = [];
/**
* When true the view-switcher toolbar is hidden.
* Defaults to true for backwards compatibility — existing trees stay toolbar-less
* until a consumer explicitly opts in with hide-toolbar="false".
* Note: hideTreeRoot and hideTreeItemActions default to false; this is the intentional exception.
*/
@property({ type: Boolean, attribute: 'hide-toolbar' })
hideToolbar: boolean = true;
/**
* When true the tree actions are hidden.
* Defaults to true — tree actions are not shown unless explicitly opted in with hide-tree-actions="false".
*/
@property({ type: Boolean, attribute: 'hide-tree-actions' })
hideTreeActions: boolean = true;
@property({ attribute: false })
interactionMemories?: Array<UmbInteractionMemoryModel>;
#lastDispatchedMemories: Array<UmbInteractionMemoryModel> = [];
@state()
private _treeRoot?: UmbTreeRootModel;
private _viewElement?: HTMLElement | null;
@state()
private _currentPage = 1;
private _hasItems = false;
@state()
private _hasPreviousItems = false;
private _isLoadingChildren = false;
@state()
private _hasNextItems = false;
private _initialLoadDone = false;
@state()
private _isLoadingPrevChildren = false;
@state()
private _isLoadingNextChildren = false;
#observeData() {
this.observe(this._api?.treeRoot, (treeRoot) => (this._treeRoot = treeRoot), '_observeTreeRoot');
this.observe(this._api?.rootItems, (rootItems) => (this._rootItems = rootItems ?? []), '_observeRootItems');
this.observe(this._api?.pagination.currentPage, (value) => (this._currentPage = value ?? 1), '_observeCurrentPage');
this.observe(this._api?.isLoadingPrevChildren, (value) => (this._isLoadingPrevChildren = value ?? false), '_observeIsLoadingPrevChildren');
this.observe(this._api?.isLoadingNextChildren, (value) => (this._isLoadingNextChildren = value ?? false), '_observeIsLoadingNextChildren');
this.observe(
this._api?.targetPagination?.totalPrevItems,
(value) => (this._hasPreviousItems = value ? value > 0 : false),
'_observeTotalPrevItems',
);
this.observe(
this._api?.targetPagination?.totalNextItems,
(value) => (this._hasNextItems = value ? value > 0 : false),
'_observeTotalNextItems',
);
}
#hasBeenLoading = false;
protected override async updated(
_changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>,
@@ -105,12 +154,16 @@ export class UmbDefaultTreeElement extends UmbLitElement {
super.updated(_changedProperties);
if (this._api === undefined) return;
if (_changedProperties.has('api')) {
this._api.loadTree();
}
if (_changedProperties.has('selectionConfiguration')) {
this._selectionConfiguration = this.selectionConfiguration;
this._api!.selection.setMultiple(this._selectionConfiguration.multiple ?? false);
this._api!.selection.setSelectable(this._selectionConfiguration.selectable ?? true);
this._api!.selection.setSelection(this._selectionConfiguration.selection ?? []);
this._api!.setSelectOnly(this._selectionConfiguration.selectOnly);
}
if (_changedProperties.has('startNode')) {
@@ -140,6 +193,19 @@ export class UmbDefaultTreeElement extends UmbLitElement {
if (_changedProperties.has('expansion')) {
this._api!.setExpansion(this.expansion);
}
if (_changedProperties.has('hideTreeItemActions')) {
this._api!.setHideTreeItemActions?.(this.hideTreeItemActions);
}
if (_changedProperties.has('isMenu')) {
this._api!.setIsMenu?.(this.isMenu ?? false);
}
if (_changedProperties.has('interactionMemories') && this._api?.interactionMemory) {
this.#lastDispatchedMemories = this.interactionMemories ?? [];
this.interactionMemories?.forEach((m) => this._api!.interactionMemory!.setMemory(m));
}
}
getSelection() {
@@ -150,74 +216,45 @@ export class UmbDefaultTreeElement extends UmbLitElement {
return this._api?.expansion.getExpansion();
}
#onLoadPrev(event: any) {
event.stopPropagation();
this._api?.loadPrevItems?.();
}
#onLoadNext(event: any) {
event.stopPropagation();
const next = (this._currentPage = this._currentPage + 1);
this._api?.pagination.setCurrentPageNumber(next);
}
override render() {
return html` ${this.#renderTreeRoot()} ${this.#renderRootItems()}`;
}
#renderTreeRoot() {
if (this.hideTreeRoot || this._treeRoot === undefined) return nothing;
return html`
<umb-tree-item
.entityType=${this._treeRoot.entityType}
.props=${{
hideActions: this.hideTreeItemActions,
item: this._treeRoot,
isMenu: this.isMenu,
}}></umb-tree-item>
${!this.hideToolbar
? html`<umb-tree-toolbar .hideTreeActions=${this.hideTreeActions}></umb-tree-toolbar>`
: nothing}
${this._viewElement ?? nothing} ${this.#renderEmptyState()}
`;
}
#renderRootItems() {
// only show the root items directly if the tree root is hidden
if (this.hideTreeRoot === true) {
return html`
${this.#renderLoadPrevButton()}
${repeat(
this._rootItems,
(item, index) => item.name + '___' + index,
(item) => html`
<umb-tree-item
.entityType=${item.entityType}
.props=${{ hideActions: this.hideTreeItemActions, item, isMenu: this.isMenu }}></umb-tree-item>
`,
)}
${this.#renderLoadNextButton()}
`;
} else {
return nothing;
}
#renderEmptyState() {
// The empty state belongs to the children list, not a single view, so it is presented once here — mirroring
// the collection pattern. It is only relevant when the children are shown on their own (root hidden or drilled
// into a start node) and never in the sidebar menu.
if (this.isMenu || !(this.hideTreeRoot || this.startNode)) return nothing;
if (!this._initialLoadDone || this._isLoadingChildren || this._hasItems) return nothing;
return html`<div id="empty-state" class="uui-text"><h4>${this.localize.term('tree_noItems')}</h4></div>`;
}
#renderLoadPrevButton() {
if (!this._hasPreviousItems) return nothing;
return html`<umb-tree-load-prev-button
@click=${this.#onLoadPrev}
.loading=${this._isLoadingPrevChildren}></umb-tree-load-prev-button>`;
}
static override styles = [
UmbTextStyles,
css`
:host {
display: contents;
}
#renderLoadNextButton() {
if (!this._hasNextItems) return nothing;
return html`<umb-tree-load-more-button
@click=${this.#onLoadNext}
.loading=${this._isLoadingNextChildren}></umb-tree-load-more-button> `;
}
#empty-state {
text-align: center;
padding: var(--uui-size-layout-1);
opacity: 0;
animation: fadeIn 100ms 100ms forwards;
}
static override styles = css`
#load-more {
width: 100%;
}
`;
@keyframes fadeIn {
100% {
opacity: 1;
}
}
`,
];
}
export default UmbDefaultTreeElement;
@@ -30,17 +30,14 @@ export class UmbDuplicateToModalElement extends UmbModalBaseElement<UmbDuplicate
return html`
<umb-body-layout headline=${this.localize.term('actions_copyTo')}>
<uui-box>
<umb-tree
alias=${this.data.treeAlias}
.props=${{
foldersOnly: this.data?.foldersOnly,
expandTreeRoot: true,
expansion: this._treeExpansion,
}}
@selection-change=${this.#onTreeSelectionChange}></umb-tree>
</uui-box>
<umb-tree
alias=${this.data.treeAlias}
.props=${{
foldersOnly: this.data?.foldersOnly,
expandTreeRoot: true,
expansion: this._treeExpansion,
}}
@selection-change=${this.#onTreeSelectionChange}></umb-tree>
${this.#renderActions()}
</umb-body-layout>
`;
@@ -103,4 +103,5 @@ describe('UmbTreeExpansionManager', () => {
expect(manager.getExpansion()).to.deep.equal([]);
});
});
});
@@ -1,4 +1,6 @@
export * from './action/index.js';
export * from './active-manager/index.js';
export * from './conditions/index.js';
export * from './components/index.js';
export * from './constants.js';
export * from './data/index.js';
@@ -12,6 +14,5 @@ export * from './tree-item-children/index.js';
export * from './tree-menu-item/index.js';
export * from './tree.element.js';
export * from './entity-actions/move/index.js';
export * from './view/index.js';
export type * from './types.js';
export type { UmbTreePickerModalData, UmbTreePickerModalValue } from './tree-picker-modal/index.js';
@@ -1,16 +1,26 @@
import { manifests as actionManifests } from './action/manifests.js';
import { manifests as conditionManifests } from './conditions/manifests.js';
import { manifests as defaultTreeItemManifests } from './tree-item/tree-item-default/manifests.js';
import { manifests as defaultTreeManifests } from './default/manifests.js';
import { manifests as entityActionManifests } from './entity-actions/manifests.js';
import { manifests as folderManifests } from './folder/manifests.js';
import { manifests as treeMenuItemManifests } from './tree-menu-item/manifests.js';
import { manifests as treePickerManifests } from './tree-picker-modal/manifests.js';
import { manifests as treeItemCardManifests } from './tree-item-card/manifests.js';
import { manifests as treeViewManifests } from './view/manifests.js';
import { manifests as workspaceViewManifests } from './workspace-view/manifests.js';
import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension-registry';
export const manifests: Array<UmbExtensionManifest | UmbExtensionManifestKind> = [
...actionManifests,
...conditionManifests,
...defaultTreeItemManifests,
...defaultTreeManifests,
...entityActionManifests,
...folderManifests,
...treeMenuItemManifests,
...treePickerManifests,
...treeItemCardManifests,
...treeViewManifests,
...workspaceViewManifests,
];
@@ -0,0 +1,8 @@
import type { UmbTreeItemModel } from '../../types.js';
import type { ManifestTreeItemCard } from '../tree-item-card.extension.js';
import { UmbTreeItemApiBase } from '../../tree-item/tree-item-base/tree-item-api-base.js';
import type { UmbTreeItemCardApi } from '../types.js';
export class UmbDefaultTreeItemCardApi<TreeItemType extends UmbTreeItemModel = UmbTreeItemModel>
extends UmbTreeItemApiBase<TreeItemType, ManifestTreeItemCard>
implements UmbTreeItemCardApi {}
@@ -0,0 +1,121 @@
import type { UmbTreeItemModel } from '../../types.js';
import { getItemFallbackIcon } from '@umbraco-cms/backoffice/entity-item';
import { customElement, html, ifDefined, nothing, property, state } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import type { UmbTreeItemCardApi } from '../types.js';
@customElement('umb-default-tree-item-card')
export class UmbDefaultTreeItemCardElement extends UmbLitElement {
#api?: UmbTreeItemCardApi;
@property({ type: Object, attribute: false })
public set api(value: UmbTreeItemCardApi | undefined) {
this.#api = value;
if (value) {
this.observe(value.isSelectable, (v) => (this._isSelectable = v), '_observeIsSelectable');
this.observe(value.isSelectableContext, (v) => (this._isSelectableContext = v), '_observeIsSelectableContext');
this.observe(value.selectOnly, (v) => (this._selectOnly = v), '_observeSelectOnly');
this.observe(value.isSelected, (v) => (this._isSelected = v), '_observeIsSelected');
this.observe(value.isActive, (v) => (this._isActive = v), '_observeIsActive');
this.observe(value.hasChildren, (v) => (this._hasChildren = v), '_observeHasChildren');
this.observe(value.noAccess, (v) => (this._noAccess = v), '_observeNoAccess');
this.observe(value.path, (v) => (this._path = v), '_observePath');
this.observe(value.hasActions, (v) => (this._hasActions = v), '_observeHasActions');
}
}
public get api(): UmbTreeItemCardApi | undefined {
return this.#api;
}
@property({ type: Object, attribute: false })
item: UmbTreeItemModel | undefined;
@state()
private _isSelectable = false;
@state()
private _isSelectableContext = false;
@state()
private _selectOnly = false;
@state()
private _isSelected = false;
@state()
private _isActive = false;
@state()
private _hasChildren = false;
@state()
private _noAccess = false;
@state()
private _path = '';
@state()
private _hasActions = false;
#onSelected(e: CustomEvent) {
e.stopPropagation();
this.#api?.select();
}
#onDeselected(e: CustomEvent) {
e.stopPropagation();
this.#api?.deselect();
}
#onOpen(e: Event) {
if (!this._hasChildren) return;
e.stopPropagation();
this.#api?.open();
}
#onKeyDown(e: KeyboardEvent) {
if (e.key === 'ArrowRight' && this._hasChildren) {
e.stopPropagation();
this.#api?.open();
}
}
override render() {
if (!this.item) return nothing;
const href = this._isSelectableContext ? undefined : this._path || undefined;
return html`
<umb-figure-card
name=${this.localize.string(this.item?.name ?? '')}
href=${ifDefined(href)}
?selectable=${this._isSelectable}
?select-only=${this._selectOnly || (!this._hasChildren && this._isSelectableContext)}
?selected=${this._isSelected}
?active=${this._isActive}
?has-children=${this._hasChildren}
?disabled=${this._noAccess}
background-color="var(--uui-color-surface)"
@selected=${this.#onSelected}
@deselected=${this.#onDeselected}
@open=${this.#onOpen}
@keydown=${this.#onKeyDown}>
${this.#renderIcon(this.item)} ${this.#renderActions()}
</umb-figure-card>
`;
}
#renderIcon(item: UmbTreeItemModel) {
const icon = item.isFolder ? 'icon-folder' : item.icon || getItemFallbackIcon();
return html`<umb-icon name=${icon}></umb-icon>`;
}
#renderActions() {
if (!this._hasActions) return nothing;
return html`<umb-entity-actions-bundle slot="actions" .label=${this.localize.string(this.item?.name ?? '')}></umb-entity-actions-bundle>`;
}
}
declare global {
interface HTMLElementTagNameMap {
'umb-default-tree-item-card': UmbDefaultTreeItemCardElement;
}
}
@@ -0,0 +1,4 @@
import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension-registry';
import { manifest as kindManifest } from './tree-item-card-default.kind.js';
export const manifests: Array<UmbExtensionManifest | UmbExtensionManifestKind> = [kindManifest];
@@ -0,0 +1,15 @@
import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension-registry';
import { UmbDefaultTreeItemCardElement } from './default-tree-item-card.element.js';
import { UmbDefaultTreeItemCardApi } from './default-tree-item-card.api.js';
export const manifest: UmbExtensionManifestKind = {
type: 'kind',
alias: 'Umb.Kind.TreeItemCard.Default',
matchKind: 'default',
matchType: 'treeItemCard',
manifest: {
type: 'treeItemCard',
element: UmbDefaultTreeItemCardElement,
api: UmbDefaultTreeItemCardApi,
},
};
@@ -0,0 +1,4 @@
import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension-registry';
import { manifests as defaultManifests } from './default/manifests.js';
export const manifests: Array<UmbExtensionManifest | UmbExtensionManifestKind> = [...defaultManifests];
@@ -0,0 +1,92 @@
import type { UmbTreeItemModel } from '../types.js';
import type { ManifestTreeItemCard } from './tree-item-card.extension.js';
import type { UmbTreeItemCardElement } from './types.js';
import { css, customElement, html, property, state } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { UmbExtensionsElementAndApiInitializer } from '@umbraco-cms/backoffice/extension-api';
import type { ManifestBase } from '@umbraco-cms/backoffice/extension-api';
import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';
@customElement('umb-tree-item-card-extension')
export class UmbTreeItemCardExtensionElement extends UmbLitElement {
#extensionsController?: UmbExtensionsElementAndApiInitializer<ManifestBase, string, ManifestTreeItemCard>;
#item?: UmbTreeItemModel;
@state()
protected _component?: UmbTreeItemCardElement;
@property({ type: Object, attribute: false })
public set item(value: UmbTreeItemModel | undefined) {
const oldValue = this.#item;
this.#item = value;
if (value === oldValue) return;
if (!value) return;
if (this._component && value.entityType === oldValue?.entityType) {
this._component.item = value;
return;
}
this.#createController(value.entityType);
}
public get item(): UmbTreeItemModel | undefined {
return this.#item;
}
#createController(entityType: string) {
this.#extensionsController?.destroy();
this.#extensionsController = new UmbExtensionsElementAndApiInitializer(
this,
umbExtensionsRegistry,
'treeItemCard',
undefined,
(manifest: ManifestTreeItemCard) => manifest.forEntityTypes.includes(entityType),
(extensionControllers) => {
if (this._component) {
this._component.remove();
}
const ctrl = extensionControllers[0];
if (!ctrl?.component || !ctrl?.api) return;
const component = ctrl.component;
const api = ctrl.api;
component.item = this.#item;
component.api = api;
api.setTreeItem(this.#item);
this._component = component;
this.requestUpdate('_component');
},
undefined,
undefined,
undefined,
{ single: true },
);
}
override render() {
return html`${this._component}`;
}
override destroy(): void {
this.#extensionsController?.destroy();
super.destroy();
}
static override styles = css`
:host {
display: block;
position: relative;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
'umb-tree-item-card-extension': UmbTreeItemCardExtensionElement;
}
}
@@ -0,0 +1,13 @@
import type { ManifestElementAndApi } from '@umbraco-cms/backoffice/extension-api';
import type { UmbTreeItemCardApi, UmbTreeItemCardElement } from './types.js';
export interface ManifestTreeItemCard extends ManifestElementAndApi<UmbTreeItemCardElement, UmbTreeItemCardApi> {
type: 'treeItemCard';
forEntityTypes: Array<string>;
}
declare global {
interface UmbExtensionManifestMap {
umbTreeItemCard: ManifestTreeItemCard;
}
}
@@ -0,0 +1,13 @@
import type { UmbControllerHostElement } from '@umbraco-cms/backoffice/controller-api';
import type { UmbTreeItemModel } from '../types.js';
import type { UmbTreeItemApi } from '../tree-item/tree-item-base/tree-item-api-base.js';
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface UmbTreeItemCardApi extends UmbTreeItemApi {}
export interface UmbTreeItemCardElement extends UmbControllerHostElement {
item: UmbTreeItemModel | undefined;
api: UmbTreeItemCardApi | undefined;
}
export type * from './tree-item-card.extension.js';
@@ -0,0 +1 @@
export * from './tree-item-open.event.js';
@@ -0,0 +1,18 @@
export class UmbTreeItemOpenEvent extends Event {
public static readonly TYPE = 'umb-tree-item-open';
public readonly unique: string;
public readonly entityType: string;
constructor(args: { unique: string; entityType: string }, eventInit?: EventInit) {
super(UmbTreeItemOpenEvent.TYPE, { bubbles: true, composed: true, ...eventInit });
this.unique = args.unique;
this.entityType = args.entityType;
}
}
declare global {
interface GlobalEventHandlersEventMap {
[UmbTreeItemOpenEvent.TYPE]: UmbTreeItemOpenEvent;
}
}
@@ -1,3 +1,4 @@
export * from './events/index.js';
export * from './tree-item-base/index.js';
export * from './tree-item-default/index.js';
export * from './tree-item.context.token.js';
@@ -1,2 +1,3 @@
export * from './tree-item-api-base.js';
export * from './tree-item-context-base.js';
export * from './tree-item-element-base.js';
@@ -0,0 +1,345 @@
import type { UmbTreeItemModel } from '../../types.js';
import { UMB_TREE_CONTEXT } from '../../tree.context.token.js';
import { UMB_TREE_ITEM_API_CONTEXT } from '../tree-item.context.token.js';
import { UmbTreeItemEntityActionManager } from '../tree-item-entity-action.manager.js';
import { combineLatest, map } from '@umbraco-cms/backoffice/external/rxjs';
import { UmbBooleanState, UmbObjectState, UmbStringState } from '@umbraco-cms/backoffice/observable-api';
import { UmbContextBase } from '@umbraco-cms/backoffice/class-api';
import { UMB_WORKSPACE_EDIT_PATH_PATTERN } from '@umbraco-cms/backoffice/workspace';
import { ensureSlash } from '@umbraco-cms/backoffice/router';
import { debounce } from '@umbraco-cms/backoffice/utils';
import { UmbEntityContext, UmbParentEntityContext } from '@umbraco-cms/backoffice/entity';
import { UMB_SECTION_CONTEXT } from '@umbraco-cms/backoffice/section';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import type { UmbEntityModel, UmbEntityUnique } from '@umbraco-cms/backoffice/entity';
import type { Observable } from '@umbraco-cms/backoffice/observable-api';
import type { UmbContextMinimal } from '@umbraco-cms/backoffice/context-api';
import type { ManifestBase, UmbApi } from '@umbraco-cms/backoffice/extension-api';
/**
* Shared contract for tree item contexts and card apis covering item data,
* selection, active state, path, and entity actions. Children, expansion, and
* pagination are not included here and remain exclusively on `UmbTreeItemContextBase`.
*/
export interface UmbTreeItemApi<
TreeItemType extends UmbTreeItemModel = UmbTreeItemModel,
ManifestType extends ManifestBase = ManifestBase,
>
extends UmbApi, UmbContextMinimal {
unique?: UmbEntityUnique;
entityType?: string;
manifest: ManifestType | undefined;
readonly treeItem: Observable<TreeItemType | undefined>;
readonly isSelectable: Observable<boolean>;
readonly isSelectableContext: Observable<boolean>;
readonly selectOnly: Observable<boolean>;
readonly isSelected: Observable<boolean>;
readonly isActive: Observable<boolean>;
readonly hasChildren: Observable<boolean>;
readonly hasActions: Observable<boolean>;
readonly noAccess: Observable<boolean>;
readonly path: Observable<string>;
setTreeItem(item: TreeItemType | undefined): void;
getTreeItem(): TreeItemType | undefined;
open(): void;
select(): void;
deselect(): void;
constructPath(pathname: string, entityType: string, unique: string | null): string;
}
/**
* Abstract base for tree item apis. Handles item data, selection, active state,
* path, and entity actions — without children, expansion, or pagination.
*
* Provides itself as `UMB_TREE_ITEM_API_CONTEXT` so entity action conditions
* can discover a tree item regardless of which tree view is active.
*/
export abstract class UmbTreeItemApiBase<
TreeItemType extends UmbTreeItemModel = UmbTreeItemModel,
ManifestType extends ManifestBase = ManifestBase,
>
extends UmbContextBase
implements UmbTreeItemApi<TreeItemType, ManifestType>
{
public unique: UmbEntityUnique | undefined;
public entityType: string | undefined;
#manifest?: ManifestType;
public get manifest(): ManifestType | undefined {
return this.#manifest;
}
public set manifest(value: ManifestType | undefined) {
if (this.#manifest === value) return;
this.#manifest = value;
}
protected _treeContext?: typeof UMB_TREE_CONTEXT.TYPE;
/** Exposes the tree context consumer so subclasses can call `.asPromise()` on it. */
protected readonly _treeContextConsumer;
readonly #gotTreeContext: Promise<unknown>;
protected readonly _treeItem = new UmbObjectState<TreeItemType | undefined>(undefined);
readonly treeItem = this._treeItem.asObservable();
protected readonly _isSelectable = new UmbBooleanState(false);
readonly isSelectable = this._isSelectable.asObservable();
#isSelectableContext = new UmbBooleanState(false);
readonly isSelectableContext = this.#isSelectableContext.asObservable();
protected readonly _isSelected = new UmbBooleanState(false);
readonly isSelected = this._isSelected.asObservable();
protected readonly _isActive = new UmbBooleanState(false);
readonly isActive = this._isActive.asObservable();
readonly hasChildren = this._treeItem.asObservablePart((item) => item?.hasChildren ?? false);
#hasActiveDescendant = new UmbBooleanState(undefined);
readonly hasActiveDescendant = this.#hasActiveDescendant.asObservable();
#treeItemEntityActionManager = new UmbTreeItemEntityActionManager(this);
#hideTreeItemActions = new UmbBooleanState(false);
readonly noAccess = this._treeItem.asObservablePart((item) => item?.noAccess ?? false);
readonly hasActions = combineLatest([
this.#treeItemEntityActionManager.hasActions,
this.#hideTreeItemActions.asObservable(),
]).pipe(map(([has, hide]) => !hide && has));
protected readonly _selectOnly = new UmbBooleanState(false);
readonly selectOnly = this._selectOnly.asObservable();
#path = new UmbStringState('');
readonly path = this.#path.asObservable();
#sectionContext?: typeof UMB_SECTION_CONTEXT.TYPE;
#entityContext = new UmbEntityContext(this);
#parentContext = new UmbParentEntityContext(this);
/** Public accessor for the tree context. Kept public for backward compatibility. */
public get treeContext(): typeof UMB_TREE_CONTEXT.TYPE | undefined {
return this._treeContext;
}
constructor(host: UmbControllerHost) {
super(host, UMB_TREE_ITEM_API_CONTEXT);
this._treeContextConsumer = this.consumeContext(UMB_TREE_CONTEXT, (context) => {
this._treeContext = context;
this._observeIsSelectable();
this._observeIsSelected();
this._observeSelectOnly();
if (context) this._onTreeContextChanged(context);
});
this.#gotTreeContext = this._treeContextConsumer.asPromise();
this.consumeContext(UMB_SECTION_CONTEXT, (instance) => {
this.#sectionContext = instance;
this.#observeSectionPath();
});
window.addEventListener('navigationend', this.#debouncedCheckIsActive);
}
setTreeItem(item: TreeItemType | undefined): void {
if (!item) {
this._treeItem.setValue(undefined);
this.#entityContext.setEntityType(undefined);
this.#entityContext.setUnique(null);
this.#treeItemEntityActionManager.setTreeItem(undefined);
return;
}
// Only check for undefined. The tree root has null as unique.
if (item.unique === undefined) throw new Error('Could not set tree item, unique is missing');
if (!item.entityType) throw new Error('Could not set tree item, entity type is missing');
this._treeItem.setValue(item);
this.unique = item.unique;
this.entityType = item.entityType;
this.#entityContext.setEntityType(item.entityType);
this.#entityContext.setUnique(item.unique);
const parentEntity: UmbEntityModel | undefined = item.parent
? { entityType: item.parent.entityType, unique: item.parent.unique }
: undefined;
this.#parentContext.setParent(parentEntity);
this.#treeItemEntityActionManager.setTreeItem(item);
this._observeIsSelected();
this._observeIsSelectable();
this.#observeSectionPath();
}
getTreeItem(): TreeItemType | undefined {
return this._treeItem.getValue();
}
public getPath(): string {
return this.#path.getValue();
}
public getAscending(): Array<UmbEntityModel> | undefined {
return (this._treeItem.getValue() as any)?.ancestors;
}
protected _observeIsSelectable() {
const ctx = this._treeContext;
if (!ctx) return;
this.observe(
ctx.selection.selectable,
(value) => {
this.#isSelectableContext.setValue(value ?? false);
const isSelectable = value ? (ctx.selectableFilter?.(this.getTreeItem()!) ?? true) : false;
this._isSelectable.setValue(isSelectable);
if (value === true) {
this.#checkIsActive();
}
},
'_observeIsSelectable',
);
}
protected _observeIsSelected() {
const ctx = this._treeContext;
if (!ctx || this.unique === undefined) return;
this.observe(
ctx.selection.selection.pipe(map((selection) => selection.includes(this.unique!))),
(isSelected) => {
this._isSelected.setValue(isSelected);
},
'_observeIsSelected',
);
}
protected _observeSelectOnly() {
const ctx = this._treeContext;
if (!ctx) return;
this.observe(ctx.selectOnly, (value) => this._selectOnly.setValue(value ?? false), '_observeSelectOnly');
}
/**
* Hook called when the tree context is received or changes. Subclasses can override to add additional observations.
* @param _context
*/
protected _onTreeContextChanged(_context: typeof UMB_TREE_CONTEXT.TYPE): void {
this.#observeActive();
if (_context.hideTreeItemActions) {
this.observe(
_context.hideTreeItemActions,
(value) => this.#hideTreeItemActions.setValue(value ?? false),
'_observeHideTreeItemActions',
);
}
}
#observeActive() {
if (this.unique === undefined || this.entityType === undefined) return;
const entity = { entityType: this.entityType, unique: this.unique };
this.observe(
this._treeContext?.activeManager.hasActiveDescendants(entity),
(hasActiveDescendant) => {
if (this.#hasActiveDescendant.getValue() === undefined && hasActiveDescendant === false) {
return;
}
this.#hasActiveDescendant.setValue(hasActiveDescendant);
},
'observeActiveDescendant',
);
}
#observeSectionPath() {
this.observe(
this.#sectionContext?.pathname,
(pathname) => {
if (!pathname || !this.entityType || this.unique === undefined) return;
const path = this.constructPath(pathname, this.entityType, this.unique);
this.#path.setValue(path);
this.#checkIsActive();
},
'observeSectionPath',
);
}
#checkIsActive = async () => {
const isSelectable = this._isSelectable.getValue();
if (isSelectable) {
this._isActive.setValue(false);
return;
}
/* Check if the current location includes the path of this tree item.
We ensure that the paths ends with a slash to avoid collisions with paths like /path-1 and /path-1-2 where /path-1 is in both.
Instead we compare /path-1/ with /path-1-2/ which wont collide.*/
const path = this.#path.getValue();
// If the path hasn't been resolved yet (e.g. no section context in a modal), skip the check.
// ensureSlash('') produces '/' which matches every URL and would mark all items as active.
if (!path) return;
const location = ensureSlash(window.location.pathname);
const comparePath = ensureSlash(path);
const isActive = location.includes(comparePath);
if (this._isActive.getValue() === isActive) return;
if (!this.entityType || this.unique === undefined) {
throw new Error('Could not check active state, entity type or unique is missing');
}
const ascending = this.getAscending();
// Only if this type of item has ancestors...
if (ascending) {
const path = [...ascending, { entityType: this.entityType, unique: this.unique }];
await this.#gotTreeContext;
if (isActive) {
this._treeContext?.activeManager.setActive(path);
} else {
// If this is the current, then remove it:
// This is a hack, where we are assuming that another active item would have made its entrance and replaced the 'active' within 2 second. [NL]
// The problem is that it may take some time before an item appears in the tree and communicates that its active.
// And in the meantime the removal of this would have resulted in the parent closing. And since we don't use Active state to open the tree, then we have a problem.
debounce(() => this._treeContext?.activeManager.removeActiveIfMatch(path), 1000);
}
}
this._isActive.setValue(isActive);
};
#debouncedCheckIsActive = debounce(this.#checkIsActive, 100);
open(): void {
const item = this.getTreeItem();
if (!item) return;
this._treeContext?.open?.(item);
}
select(): void {
if (this.unique === undefined) throw new Error('Could not select. Unique is missing');
this._treeContext?.selection.select(this.unique);
}
deselect(): void {
if (this.unique === undefined) throw new Error('Could not deselect. Unique is missing');
this._treeContext?.selection.deselect(this.unique);
}
constructPath(pathname: string, entityType: string, unique: string | null): string {
return UMB_WORKSPACE_EDIT_PATH_PATTERN.generateAbsolute({
sectionName: pathname,
entityType,
unique: unique ?? 'null',
});
}
override destroy(): void {
window.removeEventListener('navigationend', this.#debouncedCheckIsActive);
super.destroy();
}
}
@@ -2,56 +2,23 @@ import type { ManifestTreeItem } from '../../extensions/types.js';
import type { UmbTreeItemContext } from '../tree-item-context.interface.js';
import type { UmbTreeItemModel, UmbTreeRootModel } from '../../types.js';
import { UmbTreeItemChildrenManager } from '../tree-item-children.manager.js';
import { UmbTreeItemEntityActionManager } from '../tree-item-entity-action.manager.js';
import { UmbTreeItemTargetExpansionManager } from '../tree-item-expansion.manager.js';
import { UMB_TREE_CONTEXT } from '../../tree.context.token.js';
import { UMB_TREE_ITEM_CONTEXT } from '../tree-item.context.token.js';
import { ensureSlash } from '@umbraco-cms/backoffice/router';
import { map } from '@umbraco-cms/backoffice/external/rxjs';
import { UmbBooleanState, UmbObjectState, UmbStringState } from '@umbraco-cms/backoffice/observable-api';
import { UmbContextBase } from '@umbraco-cms/backoffice/class-api';
import { debounce } from '@umbraco-cms/backoffice/utils';
import { UmbEntityContext, UmbParentEntityContext } from '@umbraco-cms/backoffice/entity';
import { UMB_SECTION_CONTEXT } from '@umbraco-cms/backoffice/section';
import { UMB_WORKSPACE_EDIT_PATH_PATTERN } from '@umbraco-cms/backoffice/workspace';
import type { UMB_TREE_CONTEXT } from '../../tree.context.token.js';
import { UmbTreeItemApiBase } from './tree-item-api-base.js';
import { UmbDeprecation } from '@umbraco-cms/backoffice/utils';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import type { UmbEntityModel, UmbEntityUnique } from '@umbraco-cms/backoffice/entity';
export abstract class UmbTreeItemContextBase<
TreeItemType extends UmbTreeItemModel,
TreeRootType extends UmbTreeRootModel,
ManifestType extends ManifestTreeItem = ManifestTreeItem,
>
extends UmbContextBase
TreeItemType extends UmbTreeItemModel,
TreeRootType extends UmbTreeRootModel,
ManifestType extends ManifestTreeItem = ManifestTreeItem,
>
extends UmbTreeItemApiBase<TreeItemType, ManifestType>
implements UmbTreeItemContext<TreeItemType>
{
#gotTreeContext!: Promise<unknown>;
public unique?: UmbEntityUnique;
public entityType?: string;
#manifest?: ManifestType;
protected readonly _treeItem = new UmbObjectState<TreeItemType | undefined>(undefined);
readonly treeItem = this._treeItem.asObservable();
#isSelectable = new UmbBooleanState(false);
readonly isSelectable = this.#isSelectable.asObservable();
#isSelectableContext = new UmbBooleanState(false);
readonly isSelectableContext = this.#isSelectableContext.asObservable();
#isSelected = new UmbBooleanState(false);
readonly isSelected = this.#isSelected.asObservable();
#isActive = new UmbBooleanState(false);
readonly isActive = this.#isActive.asObservable();
#path = new UmbStringState('');
readonly path = this.#path.asObservable();
protected readonly _treeItemChildrenManager = new UmbTreeItemChildrenManager<TreeItemType, TreeRootType>(this);
public readonly childItems = this._treeItemChildrenManager.children;
public readonly hasChildren = this._treeItemChildrenManager.hasChildren;
public override readonly hasChildren = this._treeItemChildrenManager.hasChildren;
public readonly foldersOnly = this._treeItemChildrenManager.foldersOnly;
public readonly pagination = this._treeItemChildrenManager.offsetPagination;
public readonly targetPagination = this._treeItemChildrenManager.targetPagination;
@@ -65,19 +32,6 @@ export abstract class UmbTreeItemContextBase<
});
isOpen = this.#treeItemExpansionManager.isExpanded;
#treeItemEntityActionManager = new UmbTreeItemEntityActionManager(this);
public readonly hasActions = this.#treeItemEntityActionManager.hasActions;
public treeContext?: typeof UMB_TREE_CONTEXT.TYPE;
#sectionContext?: typeof UMB_SECTION_CONTEXT.TYPE;
#entityContext = new UmbEntityContext(this);
#parentContext = new UmbParentEntityContext(this);
#hasActiveDescendant = new UmbBooleanState(undefined);
public readonly hasActiveDescendant = this.#hasActiveDescendant.asObservable();
#isMenu = false;
setIsMenu(isMenu: boolean) {
this.#isMenu = isMenu;
@@ -87,80 +41,31 @@ export abstract class UmbTreeItemContextBase<
}
constructor(host: UmbControllerHost) {
super(host, UMB_TREE_ITEM_CONTEXT);
super(host);
// TODO: Get take size from Tree context
this._treeItemChildrenManager.setTakeSize(50);
this.#consumeContexts();
window.addEventListener('navigationend', this.#debouncedCheckIsActive);
}
/**
* Sets the manifest
* @param {ManifestCollection} manifest
* @memberof UmbCollectionContext
*/
public set manifest(manifest: ManifestType | undefined) {
if (this.#manifest === manifest) return;
this.#manifest = manifest;
}
public get manifest() {
return this.#manifest;
}
/**
* Returns the current path value
* @returns {string}
* Returns the manifest.
* @returns {ManifestCollection}
* @memberof UmbTreeItemContextBase
* @deprecated Use the `.manifest` property instead.
*/
public getPath() {
return this.#path.getValue();
public getManifest() {
new UmbDeprecation({
removeInVersion: '18.0.0',
deprecated: 'getManifest',
solution: 'Use .manifest property instead',
}).warn();
return this.manifest;
}
/**
* Returns the ascending items of this tree item
* @returns {Array<UmbEntityModel>}
* @memberof UmbTreeItemContextBase
*/
public getAscending(): Array<UmbEntityModel> | undefined {
// This should be supported for all trees.
return (this._treeItem.getValue() as any)?.ancestors;
}
public setTreeItem(treeItem: TreeItemType | undefined) {
if (!treeItem) {
this._treeItem.setValue(undefined);
this.#entityContext.setEntityType(undefined);
this.#entityContext.setUnique(null);
return;
}
// Only check for undefined. The tree root has null as unique
if (treeItem.unique === undefined) throw new Error('Could not create tree item context, unique is missing');
this.unique = treeItem.unique;
if (!treeItem.entityType) throw new Error('Could not create tree item context, tree item type is missing');
this.entityType = treeItem.entityType;
this.#entityContext.setEntityType(treeItem.entityType);
this.#entityContext.setUnique(treeItem.unique);
public override setTreeItem(treeItem: TreeItemType | undefined) {
super.setTreeItem(treeItem);
if (!treeItem) return;
this._treeItemChildrenManager.setTreeItem(treeItem);
this.#treeItemExpansionManager.setTreeItem(treeItem);
this.#treeItemEntityActionManager.setTreeItem(treeItem);
const parentEntity: UmbEntityModel | undefined = treeItem.parent
? {
entityType: treeItem.parent.entityType,
unique: treeItem.parent.unique,
}
: undefined;
this.#parentContext.setParent(parentEntity);
this._treeItem.setValue(treeItem);
// Update observers:
this.#observeIsSelectable();
this.#observeIsSelected();
this.#observeSectionPath();
}
/**
@@ -186,26 +91,6 @@ export abstract class UmbTreeItemContextBase<
*/
public loadNextItems = (): Promise<void> => this._treeItemChildrenManager.loadNextChildren();
/**
* 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;
@@ -219,7 +104,7 @@ export abstract class UmbTreeItemContextBase<
}
// 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 });
this._treeContext?.expansion.expandItem({ entityType, unique });
}
public hideChildren() {
@@ -234,65 +119,20 @@ export abstract class UmbTreeItemContextBase<
throw new Error('Could not show children, unique is missing');
}
this.treeContext?.expansion.collapseItem({ entityType, unique });
this._treeContext?.expansion.collapseItem({ entityType, unique });
}
async #consumeContexts() {
// TODO: Stop consuming the section context, instead lets get the needed data from the tree context. [NL]
this.consumeContext(UMB_SECTION_CONTEXT, (instance) => {
this.#sectionContext = instance;
this.#observeSectionPath();
});
this.#gotTreeContext = this.consumeContext(UMB_TREE_CONTEXT, (treeContext) => {
this.treeContext = treeContext;
this.#observeIsSelectable();
this.#observeIsSelected();
this.#observeFoldersOnly();
this.#observeAdditionalRequestArgs();
this.#observeActive();
}).asPromise();
}
getTreeItem() {
return this._treeItem.getValue();
}
#observeIsSelectable() {
if (!this.treeContext) return;
this.observe(
this.treeContext.selection.selectable,
(value) => {
this.#isSelectableContext.setValue(value);
// If the tree is selectable, check if this item is selectable
if (value === true) {
const isSelectable = this.treeContext?.selectableFilter?.(this.getTreeItem()!) ?? true;
this.#isSelectable.setValue(isSelectable);
this.#checkIsActive();
}
},
'observeIsSelectable',
);
}
#observeIsSelected() {
if (!this.treeContext || this.unique === undefined) return;
this.observe(
this.treeContext.selection.selection.pipe(map((selection) => selection.includes(this.unique!))),
(isSelected) => {
this.#isSelected.setValue(isSelected);
},
'observeIsSelected',
);
protected override _onTreeContextChanged(context: typeof UMB_TREE_CONTEXT.TYPE): void {
super._onTreeContextChanged(context);
this.#observeFoldersOnly();
this.#observeAdditionalRequestArgs();
}
#observeFoldersOnly() {
if (this.unique === undefined) return;
this.observe(
this.treeContext?.foldersOnly,
this._treeContext?.foldersOnly,
(foldersOnly) => {
this._treeItemChildrenManager.setFoldersOnly(foldersOnly ?? false);
},
@@ -304,7 +144,7 @@ export abstract class UmbTreeItemContextBase<
if (this.unique === undefined) return;
this.observe(
this.treeContext?.additionalRequestArgs,
this._treeContext?.additionalRequestArgs,
(additionalRequestArgs) => {
if (!additionalRequestArgs) return;
this._treeItemChildrenManager.setAdditionalRequestArgs(additionalRequestArgs);
@@ -312,91 +152,4 @@ export abstract class UmbTreeItemContextBase<
'observeAdditionalRequestArgs',
);
}
#observeActive() {
if (this.unique === undefined || this.entityType === undefined) return;
const entity = { entityType: this.entityType, unique: this.unique };
this.observe(
this.treeContext?.activeManager.hasActiveDescendants(entity),
(hasActiveDescendant) => {
if (this.#hasActiveDescendant.getValue() === undefined && hasActiveDescendant === false) {
return;
}
this.#hasActiveDescendant.setValue(hasActiveDescendant);
},
'observeActiveDescendant',
);
}
#observeSectionPath() {
this.observe(
this.#sectionContext?.pathname,
(pathname) => {
if (!pathname || !this.entityType || this.unique === undefined) return;
const path = this.constructPath(pathname, this.entityType, this.unique);
this.#path.setValue(path);
this.#checkIsActive();
},
'observeSectionPath',
);
}
#checkIsActive = async () => {
// don't set the active state if the item is selectable
const isSelectable = this.#isSelectable.getValue();
if (isSelectable) {
this.#isActive.setValue(false);
return;
}
/* Check if the current location includes the path of this tree item.
We ensure that the paths ends with a slash to avoid collisions with paths like /path-1 and /path-1-2 where /path-1 is in both.
Instead we compare /path-1/ with /path-1-2/ which wont collide.*/
const location = ensureSlash(window.location.pathname);
const comparePath = ensureSlash(this.#path.getValue());
const isActive = location.includes(comparePath);
if (this.#isActive.getValue() === isActive) return;
if (!this.entityType || this.unique === undefined) {
throw new Error('Could not check active state, entity type or unique is missing');
}
const ascending = this.getAscending();
// Only if this type of item has ancestors...
if (ascending) {
const path = [...ascending, { entityType: this.entityType, unique: this.unique }];
await this.#gotTreeContext;
if (isActive) {
this.treeContext?.activeManager.setActive(path);
} else {
// If this is the current, then remove it:
// This is a hack, where we are assuming that another active item would have made its entrance and replaced the 'active' within 2 second. [NL]
// The problem is that it may take some time before an item appears in the tree and communicates that its active.
// And in the meantime the removal of this would have resulted in the parent closing. And since we don't use Active state to open the tree, then we have a problem.
debounce(() => this.treeContext?.activeManager.removeActiveIfMatch(path), 1000);
}
}
this.#isActive.setValue(isActive);
};
#debouncedCheckIsActive = debounce(this.#checkIsActive, 100);
// TODO: use router context
constructPath(pathname: string, entityType: string, unique: string | null) {
return UMB_WORKSPACE_EDIT_PATH_PATTERN.generateAbsolute({
sectionName: pathname,
entityType,
unique: unique ?? 'null',
});
}
override destroy(): void {
window.removeEventListener('navigationend', this.#debouncedCheckIsActive);
super.destroy();
}
}
@@ -34,28 +34,8 @@ export abstract class UmbTreeItemElementBase<
}
protected _item?: TreeItemModelType;
/**
* @internal
* Indicates whether the user has no access to this tree item.
* This property is reflected as an attribute for styling purposes.
*
* **Usage Pattern (opt-in):**
* Child classes that support access restrictions should observe their context's `noAccess` observable
* and update this property. The base class provides the property, styling, and interaction prevention,
* but does not subscribe to the observable to avoid forcing all tree item types to implement it.
*
* **Example (in child class api setter):**
* ```typescript
* this.observe(this.#api.noAccess, (noAccess) => (this._noAccess = noAccess));
* ```
*
* **Why not in the base interface?**
* Adding `noAccess` to `UmbTreeItemContext` would be a breaking change, forcing all tree item
* implementations (users, members, data types, etc.) to provide this property even when access
* restrictions don't apply to them.
*/
@property({ type: Boolean, reflect: true, attribute: 'no-access' })
protected _noAccess = false;
protected _noAccess: boolean = false;
/**
* @param item - The item from which to extract flags.
@@ -89,14 +69,27 @@ export abstract class UmbTreeItemElementBase<
this.observe(this.#api.isActive, (value) => (this._isActive = value), '_observeIsActive');
this.observe(this.#api.isOpen, (value) => (this._isOpen = value), '_observeIsOpen');
this.observe(this.#api.isLoading, (value) => (this._isLoading = value), '_observeIsLoading');
this.observe(this.#api.isSelectableContext, (value) => (this._isSelectableContext = value), '_observeIsSelectableContext');
this.observe(
this.#api.isSelectableContext,
(value) => (this._isSelectableContext = value),
'_observeIsSelectableContext',
);
this.observe(this.#api.isSelectable, (value) => (this._isSelectable = value), '_observeIsSelectable');
this.observe(this.#api.selectOnly, (value) => (this._selectOnly = value), '_observeSelectOnly');
this.observe(this.#api.isSelected, (value) => (this._isSelected = value), '_observeIsSelected');
this.observe(this.#api.noAccess, (value) => (this._noAccess = value), '_observeNoAccess');
this.observe(this.#api.path, (value) => (this._href = value), '_observePath');
this.observe(this.#api.pagination.currentPage, (value) => (this._currentPage = value), '_observeCurrentPage');
this.observe(this.#api.pagination.totalPages, (value) => (this._totalPages = value), '_observeTotalPages');
this.observe(this.#api.isLoadingPrevChildren, (value) => (this._isLoadingPrevChildren = value ?? false), '_observeIsLoadingPrevChildren');
this.observe(this.#api.isLoadingNextChildren, (value) => (this._isLoadingNextChildren = value ?? false), '_observeIsLoadingNextChildren');
this.observe(
this.#api.isLoadingPrevChildren,
(value) => (this._isLoadingPrevChildren = value ?? false),
'_observeIsLoadingPrevChildren',
);
this.observe(
this.#api.isLoadingNextChildren,
(value) => (this._isLoadingNextChildren = value ?? false),
'_observeIsLoadingNextChildren',
);
this.observe(
this.#api.targetPagination?.totalPrevItems,
@@ -138,6 +131,9 @@ export abstract class UmbTreeItemElementBase<
@state()
private _isSelectable = false;
@state()
private _selectOnly = false;
@state()
protected _isSelected = false;
@@ -156,9 +152,6 @@ export abstract class UmbTreeItemElementBase<
@state()
private _totalPages = 1;
@state()
private _currentPage = 1;
@state()
private _hasPreviousItems = false;
@@ -192,6 +185,12 @@ export abstract class UmbTreeItemElementBase<
this.#api?.deselect();
}
#handleDblClick(event: MouseEvent) {
if (!this._item?.hasChildren) return;
event.stopPropagation();
this.#api?.open();
}
private _onShowChildren(event: UUIMenuItemEvent) {
event.stopPropagation();
// Prevent default cause we will now control the show-children state ourself.
@@ -213,8 +212,7 @@ export abstract class UmbTreeItemElementBase<
#onLoadNext(event: any) {
event.stopPropagation();
const next = (this._currentPage = this._currentPage + 1);
this.#api?.pagination.setCurrentPageNumber(next);
this.#api?.loadNextItems?.();
}
// Note: Currently we want to prevent opening when the item is in a selectable context, but this might change in the future.
@@ -230,7 +228,7 @@ export abstract class UmbTreeItemElementBase<
@selected=${this._handleSelectedItem}
@deselected=${this._handleDeselectedItem}
?active=${this._isActive}
?disabled=${(this._isSelectableContext && !this._isSelectable) || this._noAccess}
?disabled=${this._noAccess || (this._isSelectableContext && !this._isSelectable)}
?selectable=${this._isSelectable}
?selected=${this._isSelected}
.loading=${this._isLoading}
@@ -301,7 +299,7 @@ export abstract class UmbTreeItemElementBase<
}
renderLabel() {
return html`<slot name="label" slot="label"></slot>`;
return html`<span slot="label" @dblclick=${this.#handleDblClick}>${this._label}<slot name="label"></slot></span>`;
}
#renderActions() {
@@ -0,0 +1,197 @@
import { UmbTreeItemChildrenManager } from './tree-item-children.manager.js';
import { UMB_TREE_CONTEXT } from '../tree.context.token.js';
import type { UmbTreeItemModel, UmbTreeRootModel } from '../types.js';
import { UmbActionEventContext } from '@umbraco-cms/backoffice/action';
import {
UmbRequestReloadChildrenOfEntityEvent,
UmbRequestReloadStructureForEntityEvent,
} from '@umbraco-cms/backoffice/entity-action';
import { UmbContextBase } from '@umbraco-cms/backoffice/class-api';
import { UmbContextToken } from '@umbraco-cms/backoffice/context-api';
import { UmbElementMixin } from '@umbraco-cms/backoffice/element-api';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import { aTimeout, expect } from '@open-wc/testing';
import { customElement } from '@umbraco-cms/backoffice/external/lit';
type RequestCall = { parentUnique: string | null };
class UmbTestTreeRepository {
public itemsOfCalls: Array<RequestCall> = [];
public rootCalls = 0;
public items: Array<UmbTreeItemModel> = [];
async requestTreeItemsOf(args: any) {
this.itemsOfCalls.push({ parentUnique: args.parent.unique });
return { data: { items: this.items, total: this.items.length, totalBefore: 0, totalAfter: 0 } };
}
async requestTreeRootItems() {
this.rootCalls++;
return { data: { items: this.items, total: this.items.length, totalBefore: 0, totalAfter: 0 } };
}
}
class UmbTestTreeContext extends UmbContextBase {
#repository: UmbTestTreeRepository;
constructor(host: UmbControllerHost, repository: UmbTestTreeRepository) {
super(host, UMB_TREE_CONTEXT as unknown as UmbContextToken<UmbTestTreeContext>);
this.#repository = repository;
}
getRepository() {
return this.#repository;
}
}
@customElement('umb-test-tree-children-manager-host')
class UmbTestTreeChildrenManagerHostElement extends UmbElementMixin(HTMLElement) {}
const treeRoot: UmbTreeRootModel = {
unique: null,
entityType: 'test-root-entity-type',
name: 'Root',
hasChildren: true,
isFolder: false,
};
const startNode = { unique: 'start-node-id', entityType: 'test-entity-type' };
describe('UmbTreeItemChildrenManager', () => {
let host: UmbTestTreeChildrenManagerHostElement;
let repository: UmbTestTreeRepository;
let actionEventContext: UmbActionEventContext;
let manager: UmbTreeItemChildrenManager<UmbTreeItemModel, UmbTreeRootModel>;
beforeEach(async () => {
host = new UmbTestTreeChildrenManagerHostElement();
document.body.appendChild(host);
repository = new UmbTestTreeRepository();
new UmbTestTreeContext(host, repository);
actionEventContext = new UmbActionEventContext(host);
manager = new UmbTreeItemChildrenManager<UmbTreeItemModel, UmbTreeRootModel>(host);
// Allow consumeContext (tree + action event context) to resolve and the
// reload event listeners to be wired up before dispatching.
await aTimeout(0);
});
afterEach(() => {
document.body.removeChild(host);
});
describe('reload children events', () => {
it('reloads the start node children when drilled into a start node', async () => {
// The tree root is the tracked tree item, but children are loaded for the start node.
manager.setTreeItem(treeRoot);
manager.setStartNode(startNode);
actionEventContext.dispatchEvent(
new UmbRequestReloadChildrenOfEntityEvent({
entityType: startNode.entityType,
unique: startNode.unique,
}),
);
await aTimeout(0);
expect(repository.itemsOfCalls.length).to.equal(1);
expect(repository.itemsOfCalls[0].parentUnique).to.equal(startNode.unique);
});
it('ignores reload events targeting an unrelated entity', async () => {
manager.setTreeItem(treeRoot);
manager.setStartNode(startNode);
actionEventContext.dispatchEvent(
new UmbRequestReloadChildrenOfEntityEvent({
entityType: 'some-other-type',
unique: 'some-other-unique',
}),
);
await aTimeout(0);
expect(repository.itemsOfCalls.length).to.equal(0);
expect(repository.rootCalls).to.equal(0);
});
it('reloads the tree item children when no start node is set', async () => {
const treeItem: UmbTreeItemModel = {
unique: 'parent-folder-id',
entityType: 'test-entity-type',
name: 'Parent Folder',
hasChildren: true,
isFolder: true,
parent: { unique: null, entityType: 'test-root-entity-type' },
};
manager.setTreeItem(treeItem);
actionEventContext.dispatchEvent(
new UmbRequestReloadChildrenOfEntityEvent({
entityType: treeItem.entityType,
unique: treeItem.unique,
}),
);
await aTimeout(0);
expect(repository.itemsOfCalls.length).to.equal(1);
expect(repository.itemsOfCalls[0].parentUnique).to.equal(treeItem.unique);
});
});
describe('reload structure events', () => {
const childItem: UmbTreeItemModel = {
unique: 'child-id',
entityType: 'test-entity-type',
name: 'Child',
hasChildren: false,
isFolder: false,
parent: { unique: startNode.unique, entityType: startNode.entityType },
};
it('reloads children when a displayed child changes (e.g. is deleted) in a drilled start node', async () => {
repository.items = [childItem];
manager.setTreeItem(treeRoot);
manager.setStartNode(startNode);
await manager.loadChildren();
expect(repository.itemsOfCalls.length).to.equal(1);
actionEventContext.dispatchEvent(
new UmbRequestReloadStructureForEntityEvent({
entityType: childItem.entityType,
unique: childItem.unique,
}),
);
await aTimeout(0);
expect(repository.itemsOfCalls.length).to.equal(2);
expect(repository.itemsOfCalls[1].parentUnique).to.equal(startNode.unique);
});
it('ignores structure changes for an entity that is not a displayed child', async () => {
repository.items = [childItem];
manager.setTreeItem(treeRoot);
manager.setStartNode(startNode);
await manager.loadChildren();
expect(repository.itemsOfCalls.length).to.equal(1);
actionEventContext.dispatchEvent(
new UmbRequestReloadStructureForEntityEvent({
entityType: 'some-other-type',
unique: 'some-other-unique',
}),
);
await aTimeout(0);
expect(repository.itemsOfCalls.length).to.equal(1);
});
});
});
@@ -5,7 +5,6 @@ import { UmbRequestReloadTreeItemChildrenEvent } from '../entity-actions/reload-
import { UMB_TREE_ITEM_CONTEXT } from './tree-item.context.token.js';
import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import { UmbChangeEvent } from '@umbraco-cms/backoffice/event';
import { UmbArrayState, UmbBooleanState, UmbObjectState } from '@umbraco-cms/backoffice/observable-api';
import {
UmbPaginationManager,
@@ -36,6 +35,9 @@ export class UmbTreeItemChildrenManager<
#children = new UmbArrayState<TreeItemType>([], (x) => x.unique);
public readonly children = this.#children.asObservable();
#currentPageChildren = new UmbArrayState<TreeItemType>([], (x) => x.unique);
public readonly currentPageChildren = this.#currentPageChildren.asObservable();
#hasChildren = new UmbBooleanState(false);
public readonly hasChildren = this.#hasChildren.asObservable();
#hasChildrenContext = new UmbHasChildrenEntityContext(this);
@@ -73,9 +75,6 @@ export class UmbTreeItemChildrenManager<
constructor(host: UmbControllerHost) {
super(host);
// listen for page changes on the pagination manager
this.offsetPagination.addEventListener(UmbChangeEvent.TYPE, this.#onPageChange);
this.#listenForActionEvents();
this.consumeContext(UMB_TREE_CONTEXT, (treeContext) => {
@@ -307,6 +306,7 @@ export class UmbTreeItemChildrenManager<
if (data) {
const items = data.items as Array<TreeItemType>;
this.#children.setValue(items);
this.#currentPageChildren.setValue(items);
this.setHasChildren(data.total > 0);
this.offsetPagination.setTotalItems(data.total);
@@ -459,9 +459,11 @@ export class UmbTreeItemChildrenManager<
if (data) {
const items = data.items as Array<TreeItemType>;
this.#children.append(items);
this.#currentPageChildren.setValue(items);
this.setHasChildren(data.total > 0);
this.offsetPagination.setTotalItems(data.total);
this.offsetPagination.setCurrentPageNumber(this.offsetPagination.getCurrentPageNumber() + 1);
this.targetPagination.appendCurrentItems(data.items);
this.targetPagination.setTotalItems(data.total);
@@ -501,6 +503,7 @@ export class UmbTreeItemChildrenManager<
*/
public clear(): void {
this.#children.setValue([]);
this.#currentPageChildren.setValue([]);
this.offsetPagination.clear();
this.targetPagination.clear();
}
@@ -578,7 +581,11 @@ export class UmbTreeItemChildrenManager<
}
}
#onPageChange = () => this.loadNextChildren();
public async loadPage(pageNumber: number): Promise<void> {
this.offsetPagination.setCurrentPageNumber(pageNumber);
this.#children.setValue([]);
await this.#loadChildren();
}
#listenForActionEvents() {
this.consumeContext(UMB_ACTION_EVENT_CONTEXT, (instance) => {
@@ -603,26 +610,37 @@ export class UmbTreeItemChildrenManager<
}
#onReloadChildrenRequest = (event: UmbEntityActionEvent) => {
const entityType = this.getTreeItem()?.entityType;
const unique = this.getTreeItem()?.unique;
// Match against the parent we actually load children for. When drilled into a start node
// the children belong to the start node, not the tree root held by getTreeItem().
const parent = this.getStartNode() || this.getTreeItem();
if (event.getEntityType() !== entityType) return;
if (event.getUnique() !== unique) return;
if (event.getEntityType() !== parent?.entityType) return;
if (event.getUnique() !== parent?.unique) return;
this.reloadChildren();
};
#onReloadStructureForEntityRequest = async (event: UmbRequestReloadStructureForEntityEvent) => {
const entityType = this.getTreeItem()?.entityType;
const unique = this.getTreeItem()?.unique;
const entity = { entityType: event.getEntityType(), unique: event.getUnique() };
if (event.getEntityType() !== entityType) return;
if (event.getUnique() !== unique) return;
// A child we are currently displaying changed its structure (e.g. was deleted, moved or
// renamed). Reload our own children so the change is reflected. This is the manager that
// owns the displayed list, so it also covers flat card/table views where the children have
// no individual tree-item context to react on their own behalf.
if (this.isChildLoaded(entity)) {
this.reloadChildren();
return;
}
if (this.#parentTreeItemContext) {
this.#parentTreeItemContext.reloadChildren();
} else if (this.#treeContext) {
this.#treeContext.reloadTree();
// Our own item changed and no parent manager displays us as a child (we are the
// root/context-level manager). Reload the whole tree so the change is picked up.
const treeItem = this.getTreeItem();
if (
!this.#parentTreeItemContext &&
event.getEntityType() === treeItem?.entityType &&
event.getUnique() === treeItem?.unique
) {
this.#treeContext?.reloadTree();
}
};
@@ -1,41 +1,25 @@
import type { UmbTreeItemApi } from './tree-item-base/tree-item-api-base.js';
import type { UmbTreeItemModel } from '../types.js';
import type { UmbPaginationManager } from '@umbraco-cms/backoffice/utils';
import type { Observable } from '@umbraco-cms/backoffice/external/rxjs';
import type { UmbApi } from '@umbraco-cms/backoffice/extension-api';
import type { UmbContextMinimal } from '@umbraco-cms/backoffice/context-api';
import type { UmbTargetPaginationManager } from '@umbraco-cms/backoffice/utils';
export interface UmbTreeItemContext<TreeItemType extends UmbTreeItemModel = UmbTreeItemModel>
extends UmbApi,
UmbContextMinimal {
unique?: string | null;
entityType?: string;
treeItem: Observable<TreeItemType | undefined>;
extends UmbTreeItemApi<TreeItemType> {
childItems: Observable<TreeItemType[]>;
hasChildren: Observable<boolean>;
isLoading: Observable<boolean>;
isSelectableContext: Observable<boolean>;
isSelectable: Observable<boolean>;
isSelected: Observable<boolean>;
isActive: Observable<boolean>;
isOpen: Observable<boolean>;
hasActions: Observable<boolean>;
path: Observable<string>;
pagination: UmbPaginationManager;
targetPagination: UmbTargetPaginationManager;
getTreeItem(): TreeItemType | undefined;
setTreeItem(treeItem: TreeItemType | undefined): void;
select(): void;
deselect(): void;
constructPath(pathname: string, entityType: string, unique: string): string;
isLoadingPrevChildren: Observable<boolean>;
isLoadingNextChildren: Observable<boolean>;
loadChildren(): void;
reloadChildren(): void;
showChildren(): void;
hideChildren(): void;
loadPrevItems(): void;
loadNextItems(): void;
isLoadingPrevChildren: Observable<boolean>;
isLoadingNextChildren: Observable<boolean>;
setIsMenu(isMenu: boolean): void;
getIsMenu(): boolean;
}
@@ -1,4 +1,24 @@
import type { UmbTreeItemApi } from './tree-item-base/tree-item-api-base.js';
import type { UmbTreeItemContext } from './tree-item-context.interface.js';
import { UmbContextToken } from '@umbraco-cms/backoffice/context-api';
export const UMB_TREE_ITEM_CONTEXT = new UmbContextToken<UmbTreeItemContext>('UmbTreeItemContext');
/**
* Base token for any tree item provider — matches both classic tree item contexts
* and card api providers. Use this in entity action conditions that should work
* regardless of which tree view is active.
* Ideally this would be named `UMB_TREE_ITEM_CONTEXT`, but that name was already
* taken by the full tree item context token (which includes children, expansion, and pagination).
*/
export const UMB_TREE_ITEM_API_CONTEXT = new UmbContextToken<UmbTreeItemApi>('UmbTreeItemContext');
/**
* Full tree item context token. Only matches providers that implement children,
* expansion, and pagination. Use this when you specifically need child loading
* or expansion state.
*/
export const UMB_TREE_ITEM_CONTEXT = new UmbContextToken<UmbTreeItemApi, UmbTreeItemContext>(
'UmbTreeItemContext',
undefined,
(context): context is UmbTreeItemContext => 'loadChildren' in context,
);
@@ -65,6 +65,7 @@ export class UmbMenuItemTreeDefaultElement extends UmbLitElement implements UmbM
alias=${this.manifest?.meta.treeAlias}
.props=${{
hideTreeRoot: this.manifest?.meta.hideTreeRoot === true,
hideToolbar: true,
selectionConfiguration: {
selectable: false,
multiple: false,
@@ -1,14 +1,29 @@
import { UmbTreeItemPickerContext } from '../tree-item-picker/index.js';
import type { UmbTreeElement } from '../tree.element.js';
import type { UmbTreeItemModelBase, UmbTreeSelectionConfiguration } from '../types.js';
import type { UmbTreeItemModelBase, UmbTreeSelectionConfiguration, UmbTreeStartNode } from '../types.js';
import type { UmbTreeRepository } from '../data/tree-repository.interface.js';
import type { ManifestTree } from '../extensions/types.js';
import { UmbTreeItemOpenEvent } from '../tree-item/events/tree-item-open.event.js';
import type { UmbTreePickerModalData, UmbTreePickerModalValue } from './types.js';
import { customElement, html, ifDefined, nothing, state } from '@umbraco-cms/backoffice/external/lit';
import { css, customElement, html, ifDefined, nothing, repeat, state } from '@umbraco-cms/backoffice/external/lit';
import { UmbDeselectedEvent, UmbSelectedEvent } from '@umbraco-cms/backoffice/event';
import { UmbModalRouteRegistrationController } from '@umbraco-cms/backoffice/router';
import { UmbPickerModalBaseElement } from '@umbraco-cms/backoffice/picker';
import { UMB_WORKSPACE_MODAL } from '@umbraco-cms/backoffice/workspace';
import type { PropertyValueMap } from '@umbraco-cms/backoffice/external/lit';
import type { UmbEntityExpansionModel, UmbExpansionChangeEvent } from '@umbraco-cms/backoffice/utils';
import type { UmbInteractionMemoryModel } from '@umbraco-cms/backoffice/interaction-memory';
import { UmbExtensionApiInitializer } from '@umbraco-cms/backoffice/extension-api';
import { umbExtensionsRegistry, type ManifestRepository } from '@umbraco-cms/backoffice/extension-registry';
const TREE_MEMORY_UNIQUE = 'UmbTreeItemPickerTree';
const LOCATION_MEMORY_UNIQUE = 'UmbTreeItemPickerLocation';
interface UmbTreeBreadcrumbItem {
unique: string | null;
entityType: string;
name: string;
}
@customElement('umb-tree-picker-modal')
export class UmbTreePickerModalElement<TreeItemType extends UmbTreeItemModelBase> extends UmbPickerModalBaseElement<
@@ -38,6 +53,20 @@ export class UmbTreePickerModalElement<TreeItemType extends UmbTreeItemModelBase
@state()
private _treeExpansion: UmbEntityExpansionModel = [];
@state()
private _treeInteractionMemories: Array<UmbInteractionMemoryModel> = [];
@state()
private _currentLocation?: UmbTreeStartNode;
@state()
private _breadcrumb: Array<UmbTreeBreadcrumbItem> = [];
private _initialStartNode?: UmbTreeStartNode;
private _repository?: UmbTreeRepository;
private _breadcrumbLoaded = false;
private _breadcrumbLoadPromise?: Promise<void>;
protected _pickerContext = new UmbTreeItemPickerContext(this);
constructor() {
@@ -49,11 +78,18 @@ export class UmbTreePickerModalElement<TreeItemType extends UmbTreeItemModelBase
this.#observePickerSelection();
this.#observeSearch();
this.#observeExpansion();
this.#observeTreeInteractionMemories();
}
override connectedCallback(): void {
super.connectedCallback();
this.#initCreateAction();
this.addEventListener(UmbTreeItemOpenEvent.TYPE, this.#onTreeItemOpen);
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.removeEventListener(UmbTreeItemOpenEvent.TYPE, this.#onTreeItemOpen);
}
protected override async updated(_changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>) {
@@ -76,6 +112,15 @@ export class UmbTreePickerModalElement<TreeItemType extends UmbTreeItemModelBase
multiple,
};
if (this.data?.treeAlias) {
this._initialStartNode = this.data.startNode;
this._currentLocation = this.data.startNode;
this._breadcrumb = [];
this._breadcrumbLoaded = false;
this._breadcrumbLoadPromise = undefined;
this.#initRepository(this.data.treeAlias);
}
if (this.data?.treeExpansion !== undefined) {
this._pickerContext.expansion.setExpansion(this.data.treeExpansion);
}
@@ -91,6 +136,136 @@ export class UmbTreePickerModalElement<TreeItemType extends UmbTreeItemModelBase
}
}
#initRepository(treeAlias: string) {
const treeManifest = umbExtensionsRegistry.getByAlias<ManifestTree>(treeAlias);
const repositoryAlias = treeManifest?.meta?.repositoryAlias;
if (!repositoryAlias) return;
new UmbExtensionApiInitializer<ManifestRepository<UmbTreeRepository>>(
this,
umbExtensionsRegistry,
repositoryAlias,
[this],
async (permitted, ctrl) => {
this._repository = permitted ? ctrl.api : undefined;
if (this._repository && !this._breadcrumbLoaded) {
this._breadcrumbLoaded = true;
this._breadcrumbLoadPromise = this.#loadInitialBreadcrumb();
await this._breadcrumbLoadPromise;
await this.#restoreLocationFromMemory();
}
},
);
}
async #loadInitialBreadcrumb() {
if (!this._repository) return;
if (this._initialStartNode) {
const { data } = await this._repository.requestTreeItemAncestors({
treeItem: this._initialStartNode,
});
const items = data ?? [];
const ceilingIndex = items.findIndex((item) => item.unique === this._initialStartNode!.unique);
const sliced = ceilingIndex >= 0 ? items.slice(ceilingIndex) : items;
this._breadcrumb = sliced.map((item) => ({
unique: item.unique,
entityType: item.entityType,
name: item.name,
}));
} else {
const { data: root } = await this._repository.requestTreeRoot();
if (root) {
this._breadcrumb = [{ unique: null, entityType: root.entityType, name: root.name }];
}
}
}
#onTreeItemOpen = async (event: UmbTreeItemOpenEvent) => {
event.stopPropagation();
const { unique, entityType } = event;
await this.#navigateToLocation({ unique, entityType });
this.#setLocationInInteractionMemory();
};
async #navigateToLocation(entity: UmbTreeStartNode) {
this._currentLocation = entity;
if (!this._repository) return;
await this._breadcrumbLoadPromise;
const { data } = await this._repository.requestTreeItemAncestors({ treeItem: entity });
const items = data ?? [];
if (this._initialStartNode) {
const ceilingIndex = items.findIndex((item) => item.unique === this._initialStartNode!.unique);
const sliced = ceilingIndex >= 0 ? items.slice(ceilingIndex) : items;
this._breadcrumb = sliced.map((item) => ({
unique: item.unique,
entityType: item.entityType,
name: item.name,
}));
} else {
const root = this._breadcrumb[0];
this._breadcrumb = [
...(root ? [root] : []),
...items.map((item) => ({
unique: item.unique,
entityType: item.entityType,
name: item.name,
})),
];
}
}
#setLocationInInteractionMemory() {
if (!this._currentLocation) {
this._pickerContext.interactionMemory.deleteMemory(LOCATION_MEMORY_UNIQUE);
return;
}
const memory: UmbInteractionMemoryModel = {
unique: LOCATION_MEMORY_UNIQUE,
value: {
entity: {
unique: this._currentLocation.unique,
entityType: this._currentLocation.entityType,
},
},
};
this._pickerContext.interactionMemory.setMemory(memory);
}
#getLocationFromInteractionMemory(): UmbTreeStartNode | undefined {
const memory = this._pickerContext.interactionMemory.getMemory(LOCATION_MEMORY_UNIQUE);
return memory?.value?.entity;
}
async #restoreLocationFromMemory() {
const entity = this.#getLocationFromInteractionMemory();
if (!entity || !this._repository) return;
if (this._initialStartNode) {
const { data } = await this._repository.requestTreeItemAncestors({ treeItem: entity });
const isWithinStartNode = (data ?? []).some((a) => a.unique === this._initialStartNode!.unique);
if (!isWithinStartNode) return;
}
await this.#navigateToLocation(entity);
}
#onBreadcrumbItemClick(index: number) {
if (index === this._breadcrumb.length - 1) return;
const item = this._breadcrumb[index];
if (index === 0 && !this._initialStartNode) {
this._currentLocation = undefined;
} else {
this._currentLocation = { unique: item.unique!, entityType: item.entityType };
}
this._breadcrumb = this._breadcrumb.slice(0, index + 1);
this.#setLocationInInteractionMemory();
}
#observePickerSelection() {
this.observe(
this._pickerContext.selection.selection,
@@ -122,6 +297,16 @@ export class UmbTreePickerModalElement<TreeItemType extends UmbTreeItemModelBase
);
}
#observeTreeInteractionMemories() {
this.observe(
this._pickerContext.interactionMemory.memory(TREE_MEMORY_UNIQUE),
(memory) => {
this._treeInteractionMemories = memory?.memories ?? [];
},
'umbTreePickerInteractionMemoriesObserver',
);
}
// Tree Selection
#onTreeItemSelected(event: UmbSelectedEvent) {
event.stopPropagation();
@@ -176,6 +361,17 @@ export class UmbTreePickerModalElement<TreeItemType extends UmbTreeItemModelBase
this._pickerContext.expansion.setExpansion(expansion);
}
#onTreeInteractionMemoriesChange(event: Event) {
event.stopPropagation();
const tree = event.currentTarget as UmbTreeElement;
const memories = tree.interactionMemories;
if (memories.length > 0) {
this._pickerContext.interactionMemory.setMemory({ unique: TREE_MEMORY_UNIQUE, memories });
} else {
this._pickerContext.interactionMemory.deleteMemory(TREE_MEMORY_UNIQUE);
}
}
#searchSelectableFilter = () => true;
override render() {
@@ -185,6 +381,7 @@ export class UmbTreePickerModalElement<TreeItemType extends UmbTreeItemModelBase
</umb-body-layout>
`;
}
#renderSearch() {
const selectableFilter =
this.data?.search?.pickableFilter ?? this.data?.pickableFilter ?? this.#searchSelectableFilter;
@@ -201,24 +398,48 @@ export class UmbTreePickerModalElement<TreeItemType extends UmbTreeItemModelBase
}
return html`
<uui-box>
<umb-tree
alias=${ifDefined(this.data?.treeAlias)}
.props=${{
hideTreeItemActions: true,
hideTreeRoot: this.data?.hideTreeRoot,
expandTreeRoot: this.data?.expandTreeRoot,
selectionConfiguration: this._selectionConfiguration,
filter: this.data?.filter,
selectableFilter: this.data?.pickableFilter,
startNode: this.data?.startNode,
foldersOnly: this.data?.foldersOnly,
expansion: this._treeExpansion,
}}
@selected=${this.#onTreeItemSelected}
@deselected=${this.#onTreeItemDeselected}
@expansion-change=${this.#onTreeItemExpansionChange}></umb-tree
></uui-box>
${this.#renderBreadcrumb()}
<umb-tree
alias=${ifDefined(this.data?.treeAlias)}
.props=${{
hideToolbar: false,
hideTreeItemActions: true,
hideTreeRoot: this.data?.hideTreeRoot,
expandTreeRoot: this.data?.expandTreeRoot,
selectionConfiguration: this._selectionConfiguration,
filter: this.data?.filter,
selectableFilter: this.data?.pickableFilter,
startNode: this._currentLocation,
foldersOnly: this.data?.foldersOnly,
expansion: this._treeExpansion,
interactionMemories: this._treeInteractionMemories,
}}
@selected=${this.#onTreeItemSelected}
@deselected=${this.#onTreeItemDeselected}
@expansion-change=${this.#onTreeItemExpansionChange}
@interaction-memories-change=${this.#onTreeInteractionMemoriesChange}></umb-tree>
`;
}
#renderBreadcrumb() {
if (!this._breadcrumb.length) return nothing;
return html`
<div id="breadcrumb">
<uui-breadcrumbs>
${repeat(
this._breadcrumb,
(item) => item.unique ?? 'root',
(item, index) => html`
<uui-breadcrumb-item
?last-item=${index === this._breadcrumb.length - 1}
@click=${() => this.#onBreadcrumbItemClick(index)}>
${this.localize.string(item.name)}
</uui-breadcrumb-item>
`,
)}
</uui-breadcrumbs>
</div>
`;
}
@@ -241,6 +462,21 @@ export class UmbTreePickerModalElement<TreeItemType extends UmbTreeItemModelBase
</div>
`;
}
static override styles = css`
#breadcrumb {
margin-bottom: var(--uui-size-space-4);
}
uui-breadcrumbs {
overflow: hidden;
min-width: 0;
}
uui-breadcrumb-item:not([last-item]) {
cursor: pointer;
}
`;
}
export default UmbTreePickerModalElement;
@@ -4,7 +4,9 @@ import type { UmbTreeExpansionModel } from './expansion-manager/types.js';
import type { UmbTreeItemActiveManager } from './active-manager/tree-active-manager.js';
import type { UmbTreeRepository } from './data/tree-repository.interface.js';
import type { UmbTreeRootItemsRequestArgs } from './data/types.js';
import type { UmbTreeViewManager } from './view/tree-view.manager.js';
import type { UmbContextBase } from '@umbraco-cms/backoffice/class-api';
import type { UmbInteractionMemoryManager } from '@umbraco-cms/backoffice/interaction-memory';
import type { Observable } from '@umbraco-cms/backoffice/observable-api';
import type {
UmbPaginationManager,
@@ -20,10 +22,15 @@ export interface UmbTreeContext<
manifest: ManifestTree | undefined;
readonly activeManager: UmbTreeItemActiveManager;
readonly interactionMemory?: UmbInteractionMemoryManager;
readonly view?: UmbTreeViewManager;
readonly treeRoot: Observable<TreeRootType | undefined>;
readonly hideTreeRoot: Observable<boolean | undefined>;
readonly expandTreeRoot: Observable<boolean | undefined>;
readonly hideTreeItemActions?: Observable<boolean>;
readonly isMenu?: Observable<boolean>;
readonly selectOnly?: Observable<boolean | undefined>;
selectableFilter?(item: TreeItemType): boolean;
filter?(item: TreeItemType): boolean;
@@ -32,19 +39,24 @@ export interface UmbTreeContext<
readonly expansion: UmbTreeExpansionManager;
readonly rootItems: Observable<TreeItemType[]>;
readonly currentPageItems?: Observable<TreeItemType[]>;
readonly hasChildren: Observable<boolean>;
readonly pagination: UmbPaginationManager;
readonly targetPagination: UmbTargetPaginationManager;
readonly startNode: Observable<UmbTreeStartNode | undefined>;
readonly foldersOnly: Observable<boolean>;
readonly additionalRequestArgs: Observable<Partial<RequestArgsType> | object>;
readonly isLoadingChildren?: Observable<boolean>;
readonly isLoadingPrevChildren: Observable<boolean>;
readonly isLoadingNextChildren: Observable<boolean>;
getRepository(): UmbTreeRepository | undefined;
open?(item: TreeItemType): void;
loadTree(): void;
reloadTree(): void;
loadPage?(pageNumber: number): void;
loadMore(): void;
loadPrevItems(): void;
loadNextItems(): void;
@@ -52,6 +64,15 @@ export interface UmbTreeContext<
setHideTreeRoot(hideTreeRoot: boolean): void;
getHideTreeRoot(): boolean;
setHideTreeItemActions?(value: boolean): void;
getHideTreeItemActions?(): boolean;
setSelectOnly?(value: boolean | undefined): void;
getSelectOnly?(): boolean;
setIsMenu?(value: boolean): void;
getIsMenu?(): boolean;
setStartNode(startNode: UmbTreeStartNode | undefined): void;
getStartNode(): UmbTreeStartNode | undefined;
@@ -1,9 +1,15 @@
import type { ManifestTree } from './extensions/types.js';
import type { UmbTreeContext } from './tree.context.interface.js';
import type { UmbInteractionMemoryModel } from '@umbraco-cms/backoffice/interaction-memory';
import { customElement } from '@umbraco-cms/backoffice/external/lit';
import { UmbExtensionElementAndApiSlotElementBase } from '@umbraco-cms/backoffice/extension-registry';
@customElement('umb-tree')
export class UmbTreeElement extends UmbExtensionElementAndApiSlotElementBase<ManifestTree> {
get interactionMemories(): Array<UmbInteractionMemoryModel> {
return (this._api as UmbTreeContext | undefined)?.interactionMemory?.getAllMemories() ?? [];
}
getExtensionType() {
return 'tree';
}
@@ -5,12 +5,17 @@ export type * from './entity-actions/types.js';
export type * from './extensions/types.js';
export type * from './folder/types.js';
export type * from './tree-menu-item/types.js';
export type * from './tree-item-card/types.js';
export type * from './workspace-view/types.js';
export type { UmbTreePickerModalData, UmbTreePickerModalValue } from './tree-picker-modal/index.js';
export interface UmbTreeItemModelBase extends UmbEntityWithOptionalFlags {
name: string;
hasChildren: boolean;
isFolder: boolean;
icon?: string | null;
noAccess?: boolean;
}
export interface UmbTreeItemModel extends UmbTreeItemModelBase {
@@ -25,6 +30,7 @@ export interface UmbTreeRootModel extends UmbTreeItemModelBase {
export type UmbTreeSelectionConfiguration = {
multiple?: boolean;
selectable?: boolean;
selectOnly?: boolean;
selection?: Array<string | null>;
};
@@ -0,0 +1,58 @@
import type { UmbTreeItemModel } from '../../types.js';
import { UmbTreeViewElementBase } from '../tree-view-element-base.js';
import { css, customElement, html, nothing, repeat, state } from '@umbraco-cms/backoffice/external/lit';
import '../../tree-item-card/tree-item-card-extension.element.js';
@customElement('umb-card-tree-view')
export class UmbCardTreeViewElement extends UmbTreeViewElementBase<UmbTreeItemModel> {
@state()
private _items: UmbTreeItemModel[] = [];
protected override _observeContext() {
super._observeContext();
this.observe(
this._treeContext?.currentPageItems,
(items) => (this._items = items ?? []),
'_observeCurrentPageItems',
);
}
override render() {
if (!this._items.length) return nothing;
return html`
<div id="grid">
${repeat(
this._items,
(item) => item.unique,
(item) => html`
<umb-tree-item-card-extension .entityType=${item.entityType} .item=${item}></umb-tree-item-card-extension>
`,
)}
</div>
<umb-tree-pagination></umb-tree-pagination>
`;
}
static override styles = css`
:host {
display: block;
}
#grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: var(--uui-size-space-4);
}
`;
}
export default UmbCardTreeViewElement;
declare global {
interface HTMLElementTagNameMap {
'umb-card-tree-view': UmbCardTreeViewElement;
}
}
@@ -0,0 +1,20 @@
import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension-registry';
import UmbCardTreeViewElement from './card-tree-view.element.js';
export const manifests: Array<UmbExtensionManifest | UmbExtensionManifestKind> = [
{
type: 'kind',
alias: 'Umb.Kind.TreeView.Card',
matchKind: 'card',
matchType: 'treeView',
manifest: {
type: 'treeView',
element: UmbCardTreeViewElement,
weight: 800,
meta: {
label: '#tree_cardViewLabel',
icon: 'icon-grid',
},
},
},
];
@@ -0,0 +1,164 @@
import type { UmbTreeItemModel, UmbTreeRootModel, UmbTreeStartNode } from '../../types.js';
import { UmbTreeViewElementBase } from '../tree-view-element-base.js';
import { css, customElement, html, nothing, repeat, state } from '@umbraco-cms/backoffice/external/lit';
@customElement('umb-classic-tree-view')
export class UmbClassicTreeViewElement extends UmbTreeViewElementBase {
@state()
private _rootItems: UmbTreeItemModel[] = [];
@state()
private _hasPreviousItems = false;
@state()
private _hasNextItems = false;
@state()
private _isLoadingPrevChildren = false;
@state()
private _isLoadingNextChildren = false;
@state()
private _hideTreeRoot = false;
@state()
private _hideTreeItemActions = false;
@state()
private _isMenu = false;
@state()
private _startNode?: UmbTreeStartNode;
protected override _observeContext() {
super._observeContext();
this.observe(this._treeContext?.rootItems, (rootItems) => (this._rootItems = rootItems ?? []), '_observeRootItems');
this.observe(
this._treeContext?.isLoadingPrevChildren,
(value) => (this._isLoadingPrevChildren = value ?? false),
'_observeIsLoadingPrevChildren',
);
this.observe(
this._treeContext?.isLoadingNextChildren,
(value) => (this._isLoadingNextChildren = value ?? false),
'_observeIsLoadingNextChildren',
);
this.observe(
this._treeContext?.targetPagination?.totalPrevItems,
(value) => (this._hasPreviousItems = value ? value > 0 : false),
'_observeTotalPrevItems',
);
this.observe(
this._treeContext?.targetPagination?.totalNextItems,
(value) => (this._hasNextItems = value ? value > 0 : false),
'_observeTotalNextItems',
);
this.observe(
this._treeContext?.hideTreeRoot,
(value) => (this._hideTreeRoot = value ?? false),
'_observeHideTreeRoot',
);
this.observe(
this._treeContext?.hideTreeItemActions,
(value) => (this._hideTreeItemActions = value ?? false),
'_observeHideTreeItemActions',
);
this.observe(this._treeContext?.isMenu, (value) => (this._isMenu = value ?? false), '_observeIsMenu');
this.observe(
this._treeContext?.startNode,
(value) => (this._startNode = value),
'_observeStartNode',
);
}
#onLoadPrev(event: Event) {
event.stopPropagation();
this._treeContext?.loadPrevItems?.();
}
#onLoadNext(event: Event) {
event.stopPropagation();
this._treeContext?.loadMore?.();
}
override render() {
if (this._isMenu) {
return html`${this.#renderTreeRoot()} ${this.#renderRootItems()}`;
}
// When the tree root is hidden or we are drilled into a start node only the children are shown.
// With no children there is nothing to frame, so render nothing and let the tree host present the empty state.
if ((this._hideTreeRoot || this._startNode) && this._rootItems.length === 0) {
return nothing;
}
return html`
<uui-box>
${this.#renderTreeRoot()} ${this.#renderRootItems()}
</uui-box>
`;
}
#renderTreeRoot() {
// When drilled into a start node, the root node is replaced by the drilled children via #renderRootItems.
if (this._hideTreeRoot || this._startNode || this._treeRoot === undefined) return nothing;
return html`
<umb-tree-item
.entityType=${(this._treeRoot as UmbTreeRootModel).entityType}
.props=${{
hideActions: this._hideTreeItemActions,
item: this._treeRoot,
isMenu: this._isMenu,
}}></umb-tree-item>
`;
}
#renderRootItems() {
// Render when hideTreeRoot is true, OR when drilled into a start node (startNode replaces the root node).
if (!this._hideTreeRoot && !this._startNode) return nothing;
return html`
${this.#renderLoadPrevButton()}
${repeat(
this._rootItems,
(item, index) => item.name + '___' + index,
(item) => html`
<umb-tree-item
.entityType=${item.entityType}
.props=${{
hideActions: this._hideTreeItemActions,
item,
isMenu: this._isMenu,
}}></umb-tree-item>
`,
)}
${this.#renderLoadNextButton()}
`;
}
#renderLoadPrevButton() {
if (!this._hasPreviousItems) return nothing;
return html`<umb-tree-load-prev-button
@click=${this.#onLoadPrev}
.loading=${this._isLoadingPrevChildren}></umb-tree-load-prev-button>`;
}
#renderLoadNextButton() {
if (!this._hasNextItems) return nothing;
return html`<umb-tree-load-more-button
@click=${this.#onLoadNext}
.loading=${this._isLoadingNextChildren}></umb-tree-load-more-button>`;
}
static override styles = css`
:host {
display: contents;
}
`;
}
export default UmbClassicTreeViewElement;
declare global {
interface HTMLElementTagNameMap {
'umb-classic-tree-view': UmbClassicTreeViewElement;
}
}
@@ -0,0 +1,20 @@
import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension-registry';
import UmbClassicTreeViewElement from './classic-tree-view.element.js';
export const manifests: Array<UmbExtensionManifest | UmbExtensionManifestKind> = [
{
type: 'kind',
alias: 'Umb.Kind.TreeView.Classic',
matchKind: 'classic',
matchType: 'treeView',
manifest: {
type: 'treeView',
element: UmbClassicTreeViewElement,
weight: 1000,
meta: {
label: '#tree_classicViewLabel',
icon: 'icon-blockquote',
},
},
},
];
@@ -0,0 +1,3 @@
export type * from './types.js';
export * from './tree-view.manager.js';
export * from './tree-view-element-base.js';
@@ -0,0 +1,10 @@
import { manifests as classicManifests } from './classic/manifests.js';
import { manifests as cardManifests } from './card/manifests.js';
import { manifests as tableManifests } from './table/manifests.js';
import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension-registry';
export const manifests: Array<UmbExtensionManifest | UmbExtensionManifestKind> = [
...classicManifests,
...cardManifests,
...tableManifests,
];
@@ -0,0 +1 @@
export type * from './types.js';
@@ -0,0 +1,20 @@
import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension-registry';
import { UmbTableTreeViewElement } from './table-tree-view.element.js';
export const manifests: Array<UmbExtensionManifest | UmbExtensionManifestKind> = [
{
type: 'kind',
alias: 'Umb.Kind.TreeView.Table',
matchKind: 'table',
matchType: 'treeView',
manifest: {
type: 'treeView',
element: UmbTableTreeViewElement,
weight: 900,
meta: {
label: '#tree_tableViewLabel',
icon: 'icon-table',
},
},
},
];
@@ -0,0 +1,387 @@
import type { UmbTreeItemModel } from '../../types.js';
import { UmbTreeViewElementBase } from '../tree-view-element-base.js';
import type { ManifestTreeViewTableKind, MetaTreeViewTableKindColumn } from './types.js';
import { UmbTreeItemApiBase } from '../../tree-item/tree-item-base/tree-item-api-base.js';
import {
css,
customElement,
html,
nothing,
property,
state,
type PropertyValues,
} from '@umbraco-cms/backoffice/external/lit';
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
import { UmbElementControllerHost } from '@umbraco-cms/backoffice/controller-api';
import { UmbEntityContext } from '@umbraco-cms/backoffice/entity';
import { getItemFallbackIcon } from '@umbraco-cms/backoffice/entity-item';
import type { UmbObserverController } from '@umbraco-cms/backoffice/observable-api';
import type {
UmbTableColumn,
UmbTableConfig,
UmbTableDeselectedEvent,
UmbTableItem,
UmbTableSelectedEvent,
} from '@umbraco-cms/backoffice/components';
import './tree-name-table-column-layout.element.js';
import '@umbraco-cms/backoffice/entity-action';
class UmbTableTreeViewItemApi extends UmbTreeItemApiBase<UmbTreeItemModel> {}
type UmbTableTreeViewRowContext = {
host: UmbElementControllerHost;
entityContext: UmbEntityContext;
api: UmbTableTreeViewItemApi;
noAccessObserver: UmbObserverController<boolean>;
pathObserver: UmbObserverController<string>;
isActiveObserver: UmbObserverController<boolean>;
currentNoAccess: boolean;
currentPath: string;
currentIsActive: boolean;
};
@customElement('umb-table-tree-view')
export class UmbTableTreeViewElement extends UmbTreeViewElementBase<UmbTreeItemModel> {
private _items: Array<UmbTreeItemModel> = [];
@state()
private _hideTreeItemActions = false;
@state()
private _tableRows: Array<UmbTableItem> = [];
#manifest?: ManifestTreeViewTableKind;
@property({ attribute: false })
set manifest(value: ManifestTreeViewTableKind | undefined) {
this.#manifest = value;
this.#createTableRows();
}
get manifest(): ManifestTreeViewTableKind | undefined {
return this.#manifest;
}
#tableConfig: UmbTableConfig = { allowSelection: false, allowSelectAll: false, selectOnly: false };
get #manifestColumns(): Array<MetaTreeViewTableKindColumn> {
return this.#manifest?.meta?.columns ?? [];
}
#itemMap = new Map<string, UmbTreeItemModel>();
#rowContexts = new Map<string, UmbTableTreeViewRowContext>();
#onRowRendered = (element: HTMLElement | undefined, item: UmbTableItem) => {
if (!element) {
const existing = this.#rowContexts.get(item.id);
if (existing) {
existing.noAccessObserver.destroy();
existing.pathObserver.destroy();
existing.isActiveObserver.destroy();
existing.host.destroy();
this.#rowContexts.delete(item.id);
}
return;
}
const existing = this.#rowContexts.get(item.id);
if (existing) {
existing.entityContext.setEntityType(item.entityType);
existing.entityContext.setUnique(item.id);
const treeItem = this.#itemMap.get(item.id);
if (treeItem) existing.api.setTreeItem(treeItem);
return;
}
const host = new UmbElementControllerHost(element);
host.hostConnected();
const entityContext = new UmbEntityContext(host);
entityContext.setEntityType(item.entityType);
entityContext.setUnique(item.id);
const api = new UmbTableTreeViewItemApi(host);
const treeItem = this.#itemMap.get(item.id);
if (treeItem) api.setTreeItem(treeItem);
const ctx: UmbTableTreeViewRowContext = {
host,
entityContext,
api,
currentNoAccess: false,
currentPath: '',
currentIsActive: false,
noAccessObserver: undefined!,
pathObserver: undefined!,
isActiveObserver: undefined!,
};
// Register before observers so synchronous emissions see the context.
this.#rowContexts.set(item.id, ctx);
ctx.noAccessObserver = this.observe(
api.noAccess,
(noAccess) => {
ctx.currentNoAccess = noAccess ?? false;
this.#updateRowSelectable(item.id);
},
`_observeNoAccess_${item.id}`,
);
ctx.pathObserver = this.observe(
api.path,
(path) => {
ctx.currentPath = path ?? '';
const updated = this.#updateRowHref(item.id);
if (updated) this._tableRows = updated;
},
`_observePath_${item.id}`,
);
ctx.isActiveObserver = this.observe(
api.isActive,
(isActive) => {
ctx.currentIsActive = isActive ?? false;
this.#updateRowActive(item.id);
},
`_observeIsActive_${item.id}`,
);
};
#updateRowSelectable(id: string) {
const idx = this._tableRows.findIndex((r) => r.id === id);
if (idx === -1) return;
const ctx = this.#rowContexts.get(id);
const treeItem = this.#itemMap.get(id);
const selectable = !(ctx?.currentNoAccess ?? false) && (treeItem ? this._isSelectableItem(treeItem) : false);
if (this._tableRows[idx].selectable === selectable) return;
this._tableRows = [
...this._tableRows.slice(0, idx),
{ ...this._tableRows[idx], selectable },
...this._tableRows.slice(idx + 1),
];
}
#updateRowHref(id: string, rows = this._tableRows): UmbTableItem[] | null {
const idx = rows.findIndex((r) => r.id === id);
if (idx === -1) return null;
const ctx = this.#rowContexts.get(id);
const treeItem = this.#itemMap.get(id);
if (!treeItem) return null;
const href = this._selectable ? undefined : ctx?.currentPath || undefined;
const nameData = rows[idx].data.find((d) => d.columnAlias === 'name');
if (nameData?.value?.href === href) return null;
return [
...rows.slice(0, idx),
{
...rows[idx],
data: rows[idx].data.map((d) => (d.columnAlias === 'name' ? { ...d, value: { ...d.value, href } } : d)),
},
...rows.slice(idx + 1),
];
}
#updateRowActive(id: string) {
const idx = this._tableRows.findIndex((r) => r.id === id);
if (idx === -1) return;
const isActive = this.#rowContexts.get(id)?.currentIsActive ?? false;
if (this._tableRows[idx].active === isActive) return;
this._tableRows = [
...this._tableRows.slice(0, idx),
{ ...this._tableRows[idx], active: isActive },
...this._tableRows.slice(idx + 1),
];
}
protected override _observeContext() {
super._observeContext();
this.observe(
this._treeContext?.currentPageItems,
(items) => {
this._items = items ?? [];
this.#createTableRows();
},
'_observeCurrentPageItems',
);
this.observe(
this._treeContext?.hideTreeItemActions,
(value) => (this._hideTreeItemActions = value ?? false),
'_observeHideTreeItemActions',
);
}
#buildTableColumns(): Array<UmbTableColumn> {
const nameColumn: UmbTableColumn = {
name: this.localize.term('general_name'),
alias: 'name',
elementName: 'umb-tree-name-table-column-layout',
};
const manifestColumns: Array<UmbTableColumn> = this.#manifestColumns.map((col) => ({
name: this.localize.string(col.label),
alias: col.field,
}));
const entityActionsColumn: UmbTableColumn = {
name: '',
alias: 'entityActions',
align: 'right',
elementName: 'umb-entity-actions-table-column-view',
};
return [nameColumn, ...manifestColumns, ...(this._hideTreeItemActions ? [] : [entityActionsColumn])];
}
#toTableRow(item: UmbTreeItemModel): UmbTableItem {
const id = item.unique;
const icon = item.isFolder ? 'icon-folder' : (item.icon ?? getItemFallbackIcon());
const ctx = this.#rowContexts.get(id);
const noAccess = ctx?.currentNoAccess ?? false;
const href = this._selectable ? undefined : ctx?.currentPath || undefined;
const isActive = ctx?.currentIsActive ?? false;
const name = item.name;
const manifestColumnData = this.#manifestColumns.map((col) => {
const rawValue = (item as unknown as Record<string, unknown>)[col.field];
if (col.valueType) {
return {
columnAlias: col.field,
value: html`<umb-value-summary-extension
.valueType=${col.valueType}
.value=${rawValue}></umb-value-summary-extension>`,
};
}
return { columnAlias: col.field, value: rawValue };
});
return {
id,
icon,
entityType: item.entityType,
hasChildren: item.hasChildren,
selectable: !noAccess && this._isSelectableItem(item as UmbTreeItemModel),
active: isActive,
data: [
{
columnAlias: 'name',
value: {
name,
href,
onOpen: item.hasChildren ? () => this._treeContext?.open?.(item as UmbTreeItemModel) : undefined,
},
},
...manifestColumnData,
...(this._hideTreeItemActions ? [] : [{ columnAlias: 'entityActions', value: { name } }]),
],
};
}
#createTableRows() {
const items = this._items;
this.#itemMap.clear();
for (const item of items) {
this.#itemMap.set(item.unique, item);
}
this._tableRows = items.map((item) => this.#toTableRow(item));
const currentIds = new Set(this._tableRows.map((row) => row.id));
for (const [id, ctx] of this.#rowContexts) {
if (!currentIds.has(id)) {
ctx.noAccessObserver.destroy();
ctx.pathObserver.destroy();
ctx.isActiveObserver.destroy();
ctx.host.destroy();
this.#rowContexts.delete(id);
}
}
}
override willUpdate(changedProperties: PropertyValues) {
super.willUpdate(changedProperties);
if (changedProperties.has('_selectable') || changedProperties.has('_selectOnly')) {
this.#tableConfig = {
allowSelection: this._selectable,
allowSelectAll: false,
selectOnly: this._selectOnly,
};
let rows = this._tableRows;
for (const id of this.#itemMap.keys()) {
rows = this.#updateRowHref(id, rows) ?? rows;
}
if (rows !== this._tableRows) this._tableRows = rows;
}
if (changedProperties.has('_hideTreeItemActions')) {
this.#createTableRows();
}
}
#onSelected(event: UmbTableSelectedEvent) {
event.stopPropagation();
const itemId = event.getItemId();
if (itemId) this._selectItem(itemId);
}
#onDeselected(event: UmbTableDeselectedEvent) {
event.stopPropagation();
const itemId = event.getItemId();
if (itemId) this._deselectItem(itemId);
}
override disconnectedCallback() {
super.disconnectedCallback();
for (const [, ctx] of this.#rowContexts) {
ctx.noAccessObserver.destroy();
ctx.pathObserver.destroy();
ctx.isActiveObserver.destroy();
ctx.host.destroy();
}
this.#rowContexts.clear();
this.#itemMap.clear();
}
override render() {
if (!this._tableRows.length) return nothing;
return html`
<umb-table
.config=${this.#tableConfig}
.columns=${this.#buildTableColumns()}
.items=${this._tableRows}
.selection=${this._selection}
.onRowRendered=${this.#onRowRendered}
@selected=${this.#onSelected}
@deselected=${this.#onDeselected}></umb-table>
<umb-tree-pagination></umb-tree-pagination>
`;
}
static override styles = [
UmbTextStyles,
css`
:host {
display: block;
}
`,
];
}
export default UmbTableTreeViewElement;
declare global {
interface HTMLElementTagNameMap {
'umb-table-tree-view': UmbTableTreeViewElement;
}
}
@@ -0,0 +1,48 @@
import { html, nothing, customElement, property } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import type { UmbTableColumn, UmbTableColumnLayoutElement, UmbTableItem } from '@umbraco-cms/backoffice/components';
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
export interface UmbTreeNameTableColumnValue {
name: string;
href?: string;
onOpen?: () => void;
}
@customElement('umb-tree-name-table-column-layout')
export class UmbTreeNameTableColumnLayoutElement extends UmbLitElement implements UmbTableColumnLayoutElement {
column!: UmbTableColumn;
item!: UmbTableItem;
@property({ attribute: false })
value!: UmbTreeNameTableColumnValue;
#onOpenClick(e: Event) {
e.stopPropagation();
this.value.onOpen?.();
}
override render() {
if (!this.value) return nothing;
const name = this.localize.string(this.value.name);
if (this.value.href) {
return html`<uui-button compact label=${name} href=${this.value.href}>${name}</uui-button>`;
}
if (this.value.onOpen) {
return html`<uui-button compact label=${name} @click=${this.#onOpenClick}>${name}</uui-button>`;
}
return html`<span>${name}</span>`;
}
static override styles = [UmbTextStyles];
}
declare global {
interface HTMLElementTagNameMap {
'umb-tree-name-table-column-layout': UmbTreeNameTableColumnLayoutElement;
}
}
@@ -0,0 +1,27 @@
import type { ManifestTreeView, MetaTreeView } from '../tree-view.extension.js';
export interface MetaTreeViewTableKindColumn {
/** The property name on the tree item model to display in this column. */
field: string;
/** The column header label. Supports localization strings (e.g. `#general_status`). */
label: string;
/** Optional value type for rendering a value summary in this column. */
valueType?: keyof UmbValueTypeMap;
}
export interface MetaTreeViewTableKind extends Partial<MetaTreeView> {
/** Additional columns to render between the name and entity actions columns. */
columns?: Array<MetaTreeViewTableKindColumn>;
}
export interface ManifestTreeViewTableKind extends Omit<ManifestTreeView, 'meta'> {
type: 'treeView';
kind: 'table';
meta: MetaTreeViewTableKind;
}
declare global {
interface UmbExtensionManifestMap {
umbTreeViewTableKind: ManifestTreeViewTableKind;
}
}
@@ -0,0 +1,77 @@
import type { UmbTreeContext } from '../tree.context.interface.js';
import type { UmbTreeItemModel, UmbTreeRootModel } from '../types.js';
import { UMB_TREE_CONTEXT } from '../tree.context.token.js';
import { state } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
/**
* Base class for tree view elements, providing the tree's root and selection state to subclasses.
* @template TreeItemType - The tree item model rendered by this view.
* @template TreeRootType - The tree root model for this view.
*/
export abstract class UmbTreeViewElementBase<
TreeItemType extends UmbTreeItemModel = UmbTreeItemModel,
TreeRootType extends UmbTreeRootModel = UmbTreeRootModel,
> extends UmbLitElement {
protected _treeContext?: UmbTreeContext<TreeItemType, TreeRootType>;
@state()
protected _treeRoot?: TreeRootType;
@state()
protected _selectable = false;
@state()
protected _selectOnly = false;
@state()
protected _selection: Array<string | null> = [];
constructor() {
super();
this.consumeContext(UMB_TREE_CONTEXT, (context) => {
this._treeContext = context as UmbTreeContext<TreeItemType, TreeRootType>;
this._observeContext();
});
}
protected _observeContext() {
this.observe(
this._treeContext?.treeRoot,
(treeRoot) => (this._treeRoot = treeRoot as TreeRootType | undefined),
'_observeTreeRoot',
);
this.observe(
this._treeContext?.selection.selectable,
(selectable) => (this._selectable = selectable ?? false),
'_observeSelectable',
);
this.observe(
this._treeContext?.selectOnly,
(selectOnly) => (this._selectOnly = selectOnly ?? false),
'_observeSelectOnly',
);
this.observe(
this._treeContext?.selection.selection,
(selection) => (this._selection = selection ?? []),
'_observeSelection',
);
}
protected _isSelectableItem(item: TreeItemType): boolean {
if (!this._selectable) return false;
return this._treeContext?.selectableFilter?.(item) ?? true;
}
protected _isSelectedItem(unique: string | null): boolean {
return this._treeContext?.selection.isSelected(unique) ?? false;
}
protected _selectItem(unique: string | null) {
this._treeContext?.selection.select(unique);
}
protected _deselectItem(unique: string | null) {
this._treeContext?.selection.deselect(unique);
}
}
@@ -0,0 +1,34 @@
import type { ManifestElement, ManifestWithDynamicConditions } from '@umbraco-cms/backoffice/extension-api';
export interface ManifestTreeView
extends ManifestElement,
ManifestWithDynamicConditions<UmbExtensionConditionConfig> {
type: 'treeView';
/**
* The tree aliases this view applies to. When omitted, the view applies to all trees.
*/
forTrees?: Array<string>;
meta: MetaTreeView;
}
export interface MetaTreeView {
/**
* The friendly name of the tree view
*/
label: string;
/**
* An icon to represent the tree view
* @examples [
* "icon-list",
* "icon-grid"
* ]
*/
icon: string;
}
declare global {
interface UmbExtensionManifestMap {
umbTreeView: ManifestTreeView;
}
}
@@ -0,0 +1,119 @@
import type { ManifestTreeView } from './tree-view.extension.js';
import { UmbTreeViewManager } from './tree-view.manager.js';
import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';
import { expect } from '@open-wc/testing';
import { Observable } from '@umbraco-cms/backoffice/external/rxjs';
import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api';
import { customElement } from '@umbraco-cms/backoffice/external/lit';
@customElement('umb-test-tree-view-manager-host')
class UmbTestControllerHostElement extends UmbControllerHostElementMixin(HTMLElement) {}
const TREE_ALIAS = 'UmbTest.Tree.ViewManager';
const VIEW_LOW_ALIAS = 'UmbTest.TreeView.LowWeight';
const VIEW_HIGH_ALIAS = 'UmbTest.TreeView.HighWeight';
const testViews: Array<ManifestTreeView> = [
{
type: 'treeView',
alias: VIEW_LOW_ALIAS,
name: 'Low Weight View',
weight: 100,
meta: { label: 'Low', icon: 'icon-list' },
forTrees: [TREE_ALIAS],
},
{
type: 'treeView',
alias: VIEW_HIGH_ALIAS,
name: 'High Weight View',
weight: 900,
meta: { label: 'High', icon: 'icon-grid' },
forTrees: [TREE_ALIAS],
},
];
umbExtensionsRegistry.registerMany(testViews);
describe('UmbTreeViewManager', () => {
let manager: UmbTreeViewManager;
beforeEach(() => {
const hostElement = new UmbTestControllerHostElement();
manager = new UmbTreeViewManager(hostElement);
});
describe('Public API', () => {
describe('properties', () => {
it('has a views property', () => {
expect(manager).to.have.property('views').to.be.an.instanceOf(Observable);
});
it('has a currentView property', () => {
expect(manager).to.have.property('currentView').to.be.an.instanceOf(Observable);
});
});
describe('methods', () => {
it('has a setTreeAlias method', () => {
expect(manager).to.have.property('setTreeAlias').that.is.a('function');
});
it('has a setCurrentView method', () => {
expect(manager).to.have.property('setCurrentView').that.is.a('function');
});
it('has a getCurrentView method', () => {
expect(manager).to.have.property('getCurrentView').that.is.a('function');
});
});
});
describe('setCurrentView / getCurrentView', () => {
it('sets and returns the active view', () => {
manager.setCurrentView(testViews[0]);
expect(manager.getCurrentView()?.alias).to.equal(VIEW_LOW_ALIAS);
});
it('updates the currentView observable', (done) => {
manager.setCurrentView(testViews[1]);
manager.currentView.subscribe((value) => {
if (value?.alias === VIEW_HIGH_ALIAS) {
expect(value.alias).to.equal(VIEW_HIGH_ALIAS);
done();
}
});
});
});
describe('setTreeAlias', () => {
it('populates views from the extension registry for the given alias', (done) => {
manager.setTreeAlias(TREE_ALIAS);
manager.views.subscribe((views) => {
if (views.length >= 2) {
expect(views).to.have.lengthOf(2);
done();
}
});
});
it('selects the highest-weight view as the default current view', (done) => {
manager.setTreeAlias(TREE_ALIAS);
manager.currentView.subscribe((view) => {
if (view?.alias === VIEW_HIGH_ALIAS) {
expect(view.alias).to.equal(VIEW_HIGH_ALIAS);
done();
}
});
});
it('falls back to the built-in classic view when no manifests match the alias', (done) => {
manager.setTreeAlias('UmbTest.Tree.NoViewsRegistered');
manager.currentView.subscribe((view) => {
if (view) {
expect(view.alias).to.equal('Umb.TreeView.Classic.Fallback');
done();
}
});
});
});
});
@@ -0,0 +1,141 @@
import type { ManifestTreeView } from './tree-view.extension.js';
import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
import { UmbExtensionsManifestInitializer } from '@umbraco-cms/backoffice/extension-api';
import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';
import { UmbArrayState, UmbObjectState } from '@umbraco-cms/backoffice/observable-api';
import { UmbDeprecation } from '@umbraco-cms/backoffice/utils';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import type {
UmbInteractionMemoryManager,
UmbInteractionMemoryModel,
} from '@umbraco-cms/backoffice/interaction-memory';
/**
* Fallback used when no `treeView` manifests are registered.
* Trees should register at least one treeView manifest using `kind: 'classic'`.
* @deprecated Register a treeView manifest with `kind: 'classic'` on your tree instead.
*/
const CLASSIC_FALLBACK: ManifestTreeView = {
type: 'treeView',
kind: 'classic',
alias: 'Umb.TreeView.Classic.Fallback',
name: 'Classic Tree View (fallback)',
element: () => import('./classic/classic-tree-view.element.js'),
weight: 0,
meta: {
label: '#tree_classicViewLabel',
icon: 'icon-blockquote',
},
};
const MEMORY_UNIQUE = 'UmbTreeCurrentView';
/**
* Construction arguments for {@link UmbTreeViewManager}.
*/
export interface UmbTreeViewManagerArgs {
/**
* When provided, the selected tree view is remembered and restored across sessions.
*/
interactionMemoryManager?: UmbInteractionMemoryManager;
}
/**
* Manages the available views for a tree and tracks which one is currently active.
*/
export class UmbTreeViewManager extends UmbControllerBase {
#views = new UmbArrayState<ManifestTreeView>([], (x) => x.alias);
public readonly views = this.#views.asObservable();
#currentView = new UmbObjectState<ManifestTreeView | undefined>(undefined);
public readonly currentView = this.#currentView.asObservable();
#treeAlias?: string;
#extensionsInitializer?: UmbExtensionsManifestInitializer<any, any>;
#interactionMemoryManager?: UmbInteractionMemoryManager;
#muteMemoryObservation = false;
/**
* @param {UmbControllerHost} host - The controller host this manager is bound to.
* @param {UmbTreeViewManagerArgs} [args] - Optional construction arguments.
*/
constructor(host: UmbControllerHost, args?: UmbTreeViewManagerArgs) {
super(host);
this.#interactionMemoryManager = args?.interactionMemoryManager;
if (this.#interactionMemoryManager) {
this.#observeInteractionMemory();
}
}
setTreeAlias(treeAlias: string) {
if (this.#treeAlias === treeAlias) return;
this.#treeAlias = treeAlias;
this.#extensionsInitializer?.destroy();
this.#extensionsInitializer = new UmbExtensionsManifestInitializer(
this,
umbExtensionsRegistry,
'treeView',
(manifest: ManifestTreeView) => !manifest.forTrees?.length || manifest.forTrees.includes(treeAlias),
(result) => {
const views = result.map((v) => v.manifest);
this.#views.setValue(views);
if (!views.length) {
// No treeView manifests registered for this tree — use the built-in classic fallback.
new UmbDeprecation({
removeInVersion: '20.0.0',
deprecated: 'Implicit classic tree view fallback',
solution:
"Register a treeView manifest with `kind: 'classic'` on your tree. The automatic fallback will be removed in Umbraco 20.",
}).warn();
this.#currentView.setValue(CLASSIC_FALLBACK);
return;
}
const storedAlias = this.#interactionMemoryManager?.getMemory(MEMORY_UNIQUE)?.value?.alias;
const initialView = storedAlias ? (views.find((v) => v.alias === storedAlias) ?? views[0]) : views[0];
this.#writeToMemory(initialView);
this.#currentView.setValue(initialView);
},
);
}
setCurrentView(view: ManifestTreeView) {
this.#writeToMemory(view);
this.#currentView.setValue(view);
}
getCurrentView(): ManifestTreeView | undefined {
return this.#currentView.getValue();
}
#writeToMemory(view: ManifestTreeView) {
if (!this.#interactionMemoryManager) return;
const memory: UmbInteractionMemoryModel = { unique: MEMORY_UNIQUE, value: { alias: view.alias } };
this.#muteMemoryObservation = true;
this.#interactionMemoryManager.setMemory(memory);
this.#muteMemoryObservation = false;
}
#observeInteractionMemory() {
this.observe(
this.#interactionMemoryManager!.memory(MEMORY_UNIQUE),
(memory) => {
if (this.#muteMemoryObservation) return;
if (!memory) return;
const views = this.#views.getValue();
if (!views.length) return; // extensions not loaded yet; initializer callback will handle it
const match = views.find((v) => v.alias === memory.value?.alias);
if (match) {
this.#currentView.setValue(match);
}
},
'umbTreeViewMemoryObserver',
);
}
}
@@ -0,0 +1,2 @@
export type * from './tree-view.extension.js';
export type * from './table/types.js';
@@ -0,0 +1 @@
export * from './tree-workspace-view.element.js';
@@ -0,0 +1,21 @@
import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension-registry';
import { UmbTreeWorkspaceViewElement } from './tree-workspace-view.element.js';
export const manifests: Array<UmbExtensionManifest | UmbExtensionManifestKind> = [
{
type: 'kind',
alias: 'Umb.Kind.WorkspaceView.Tree',
matchKind: 'tree',
matchType: 'workspaceView',
manifest: {
type: 'workspaceView',
kind: 'tree',
element: UmbTreeWorkspaceViewElement,
meta: {
label: '#tree_children',
pathname: 'children',
icon: 'icon-bulleted-list',
},
},
},
];
@@ -0,0 +1,99 @@
import { UMB_ENTITY_CONTEXT, type UmbEntityModel } from '@umbraco-cms/backoffice/entity';
import type { ManifestWorkspaceViewTreeKind } from './types.js';
import { html, nothing, customElement, property, state, css } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { UMB_INTERACTION_MEMORY_CONTEXT } from '@umbraco-cms/backoffice/interaction-memory';
import type { UmbInteractionMemoryModel } from '@umbraco-cms/backoffice/interaction-memory';
import type { UmbTreeElement } from '../tree.element.js';
import type { PropertyValues } from '@umbraco-cms/backoffice/external/lit';
@customElement('umb-tree-workspace-view')
export class UmbTreeWorkspaceViewElement extends UmbLitElement {
@property({ type: Object, attribute: false })
public manifest?: ManifestWorkspaceViewTreeKind;
@state()
private _parent?: UmbEntityModel;
@state()
private _interactionMemories?: Array<UmbInteractionMemoryModel>;
#interactionMemoryContext?: typeof UMB_INTERACTION_MEMORY_CONTEXT.TYPE;
constructor() {
super();
this.consumeContext(UMB_ENTITY_CONTEXT, (context) => {
const entityType = context?.getEntityType();
const unique = context?.getUnique();
this._parent = entityType && unique !== undefined ? { entityType, unique } : undefined;
});
this.consumeContext(UMB_INTERACTION_MEMORY_CONTEXT, (context) => {
this.#interactionMemoryContext = context;
this.#readInteractionMemory();
});
}
protected override updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
if (changedProperties.has('manifest')) {
this.#readInteractionMemory();
}
}
#readInteractionMemory() {
const alias = this.manifest?.alias;
if (!alias || !this.#interactionMemoryContext) return;
const stored = this.#interactionMemoryContext.memory.getMemory(alias);
this._interactionMemories = stored?.memories ?? [];
}
#onInteractionMemoriesChange(event: Event) {
event.stopPropagation();
const alias = this.manifest?.alias;
if (!alias || !this.#interactionMemoryContext) return;
const tree = event.currentTarget as UmbTreeElement;
const memories = tree.interactionMemories;
if (memories.length > 0) {
this.#interactionMemoryContext.memory.setMemory({ unique: alias, memories });
} else {
this.#interactionMemoryContext.memory.deleteMemory(alias);
}
}
override render() {
if (!this.manifest) return html` <div>No Manifest</div>`;
if (!this.manifest.meta.treeAlias) return html` <div>No Tree Alias in Manifest</div>`;
if (this._parent === undefined) return nothing;
return html`<umb-tree
data-mark="tree:${this.manifest.meta.treeAlias}"
alias=${this.manifest.meta.treeAlias}
.props=${{
hideToolbar: false,
hideTreeActions: false,
hideTreeRoot: true,
startNode: this._parent,
interactionMemories: this._interactionMemories,
selectionConfiguration: {
selectable: false,
},
}}
@interaction-memories-change=${this.#onInteractionMemoriesChange}></umb-tree>`;
}
static override styles = css`
:host {
display: block;
padding: var(--uui-size-layout-1);
}
`;
}
export { UmbTreeWorkspaceViewElement as element };
declare global {
interface HTMLElementTagNameMap {
'umb-tree-workspace-view': UmbTreeWorkspaceViewElement;
}
}
@@ -0,0 +1,17 @@
import type { ManifestWorkspaceView, MetaWorkspaceView } from '@umbraco-cms/backoffice/workspace';
export interface ManifestWorkspaceViewTreeKind extends ManifestWorkspaceView {
type: 'workspaceView';
kind: 'tree';
meta: MetaWorkspaceViewTreeKind;
}
export interface MetaWorkspaceViewTreeKind extends MetaWorkspaceView {
treeAlias: string;
}
declare global {
interface UmbExtensionManifestMap {
umbManifestWorkspaceViewTreeKind: ManifestWorkspaceViewTreeKind;
}
}
@@ -2,5 +2,6 @@ export * from './constants.js';
export * from './context/index.js';
export * from './variant-id.class.js';
export * from './variant-object-compare.function.js';
export * from './variant-resolver.js';
export type * from './types.js';
@@ -0,0 +1,119 @@
import { expect } from '@open-wc/testing';
import { customElement } from 'lit/decorators.js';
import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api';
import { UmbVariantContext } from './context/variant.context.js';
import { UmbVariantResolver } from './variant-resolver.js';
import type { Observable } from '@umbraco-cms/backoffice/observable-api';
@customElement('umb-test-variant-resolver-host')
class UmbTestControllerHostElement extends UmbControllerHostElementMixin(HTMLElement) {}
interface TestVariant {
culture: string | null;
segment: string | null;
name?: string;
}
// Resolves once the observable emits a value matching the predicate.
function observeValue<T>(observable: Observable<T>, predicate: (value: T) => boolean): Promise<T> {
return new Promise<T>((resolve) => {
const subscription = observable.subscribe((value) => {
if (predicate(value)) {
resolve(value);
queueMicrotask(() => subscription.unsubscribe());
}
});
});
}
describe('UmbVariantResolver', () => {
let hostElement: UmbTestControllerHostElement;
let variantContext: UmbVariantContext;
let controller: UmbVariantResolver<TestVariant>;
beforeEach(async () => {
hostElement = new UmbTestControllerHostElement();
document.body.appendChild(hostElement);
variantContext = new UmbVariantContext(hostElement);
await variantContext.setCulture('en-US');
await variantContext.setFallbackCulture('en-US');
await variantContext.setAppCulture('en-US');
controller = new UmbVariantResolver<TestVariant>(hostElement);
// Wait until the controller has consumed the variant context.
await observeValue(controller.displayCulture, (culture) => culture === 'en-US');
await observeValue(controller.fallbackCulture, (culture) => culture === 'en-US');
});
afterEach(() => {
document.body.innerHTML = '';
});
it('exposes the display and fallback culture from the variant context', async () => {
expect(await observeValue(controller.displayCulture, (culture) => culture === 'en-US')).to.equal('en-US');
expect(await observeValue(controller.fallbackCulture, (culture) => culture === 'en-US')).to.equal('en-US');
});
it('resolves the variant matching the display culture', () => {
controller.setVariants([
{ culture: 'en-US', segment: null },
{ culture: 'da-DK', segment: null },
]);
expect(controller.getVariant()?.culture).to.equal('en-US');
});
it('resolves the invariant variant regardless of culture', () => {
const invariant: TestVariant = { culture: null, segment: null, name: 'Invariant' };
controller.setVariants([invariant]);
expect(controller.getVariant()).to.equal(invariant);
expect(controller.getFallbackVariant()).to.equal(undefined);
});
it('resolves the fallback variant from the fallback culture', async () => {
await variantContext.setCulture('de-DE');
await observeValue(controller.displayCulture, (culture) => culture === 'de-DE');
controller.setVariants([
{ culture: 'en-US', segment: null },
{ culture: 'fr-FR', segment: null },
]);
// de-DE has no variant, so there is no display match...
expect(controller.getVariant()).to.equal(undefined);
// ...but the fallback culture (en-US) does.
expect(controller.getFallbackVariant()?.culture).to.equal('en-US');
});
it('re-resolves when the display culture changes at runtime', async () => {
controller.setVariants([
{ culture: 'en-US', segment: null },
{ culture: 'da-DK', segment: null },
]);
expect(controller.getVariant()?.culture).to.equal('en-US');
await variantContext.setCulture('da-DK');
await observeValue(controller.variant, (variant) => variant?.culture === 'da-DK');
expect(controller.getVariant()?.culture).to.equal('da-DK');
});
it('exposes the resolved variant culture via the culture observable', async () => {
controller.setVariants([{ culture: 'en-US', segment: null }]);
const culture = await observeValue(controller.culture, (value) => value === 'en-US');
expect(culture).to.equal('en-US');
});
it('resolves to undefined when there are no variants', () => {
controller.setVariants([]);
expect(controller.getVariant()).to.equal(undefined);
expect(controller.getFallbackVariant()).to.equal(undefined);
});
it('treats undefined variants as an empty set', () => {
controller.setVariants(undefined);
expect(controller.getVariant()).to.equal(undefined);
expect(controller.getVariants()).to.eql([]);
});
});
@@ -0,0 +1,140 @@
import { UMB_VARIANT_CONTEXT } from './context/constants.js';
import type { UmbVariantContext } from './context/variant.context.js';
import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
import { UmbArrayState, UmbObjectState, UmbStringState } from '@umbraco-cms/backoffice/observable-api';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
export interface UmbVariantLike {
culture: string | null;
segment?: string | null;
}
/**
* Resolves which variant of a given set matches the ambient variant context.
* @exports
* @class UmbVariantResolver
*/
export class UmbVariantResolver<VariantType extends UmbVariantLike = UmbVariantLike> extends UmbControllerBase {
#variants = new UmbArrayState<VariantType>([], (variant) => `${variant.culture}:${variant.segment ?? null}`);
#displayCulture = new UmbStringState<string | null | undefined>(undefined);
public readonly displayCulture = this.#displayCulture.asObservable();
#fallbackCulture = new UmbStringState<string | null | undefined>(undefined);
public readonly fallbackCulture = this.#fallbackCulture.asObservable();
#variant = new UmbObjectState<VariantType | undefined>(undefined);
public readonly variant = this.#variant.asObservable();
public readonly culture = this.#variant.asObservablePart((variant) => variant?.culture);
#fallbackVariant = new UmbObjectState<VariantType | undefined>(undefined);
public readonly fallbackVariant = this.#fallbackVariant.asObservable();
constructor(host: UmbControllerHost) {
super(host);
this.consumeContext(UMB_VARIANT_CONTEXT, (context) => {
this.#observeContext(context);
});
}
#observeContext(context: UmbVariantContext | undefined) {
this.observe(
context?.displayCulture,
(displayCulture) => {
if (displayCulture === undefined) return;
this.#displayCulture.setValue(displayCulture);
this.#process();
},
'umbObserveDisplayCulture',
);
this.observe(
context?.fallbackCulture,
(fallbackCulture) => {
if (fallbackCulture === undefined) return;
this.#fallbackCulture.setValue(fallbackCulture);
this.#process();
},
'umbObserveFallbackCulture',
);
}
/**
* Sets the variants to resolve against the ambient variant context.
* @param {Array<VariantType> | undefined} variants - The variants to resolve from.
* @memberof UmbVariantResolver
*/
setVariants(variants: Array<VariantType> | undefined): void {
this.#variants.setValue(variants ?? []);
this.#process();
}
/**
* Gets the current set of variants.
* @returns {Array<VariantType>} The current variants.
* @memberof UmbVariantResolver
*/
getVariants(): Array<VariantType> {
return this.#variants.getValue();
}
/**
* Gets the current display culture.
* @returns {string | null | undefined} The display culture.
* @memberof UmbVariantResolver
*/
getDisplayCulture(): string | null | undefined {
return this.#displayCulture.getValue();
}
/**
* Gets the current fallback culture.
* @returns {string | null | undefined} The fallback culture.
* @memberof UmbVariantResolver
*/
getFallbackCulture(): string | null | undefined {
return this.#fallbackCulture.getValue();
}
/**
* Gets the variant matching the display culture (or the invariant variant).
* @returns {VariantType | undefined} The resolved variant.
* @memberof UmbVariantResolver
*/
getVariant(): VariantType | undefined {
return this.#variant.getValue();
}
/**
* Gets the variant matching the fallback culture.
* @returns {VariantType | undefined} The resolved fallback variant.
* @memberof UmbVariantResolver
*/
getFallbackVariant(): VariantType | undefined {
return this.#fallbackVariant.getValue();
}
#process() {
const variants = this.#variants.getValue();
if (variants.length === 0) {
this.#variant.setValue(undefined);
this.#fallbackVariant.setValue(undefined);
return;
}
// Invariant content has a single variant with a null culture; it is always the match.
if (variants[0].culture === null) {
this.#variant.setValue(variants[0]);
this.#fallbackVariant.setValue(undefined);
return;
}
const displayCulture = this.#displayCulture.getValue();
const fallbackCulture = this.#fallbackCulture.getValue();
this.#variant.setValue(variants.find((variant) => variant.culture === displayCulture));
this.#fallbackVariant.setValue(variants.find((variant) => variant.culture === fallbackCulture));
}
}
@@ -1,17 +1,19 @@
import { UMB_DOCUMENT_TYPE_ENTITY_TYPE, UMB_DOCUMENT_TYPE_ROOT_ENTITY_TYPE } from '../entity.js';
import { UMB_DOCUMENT_TYPE_ROOT_WORKSPACE_ALIAS } from '../constants.js';
import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension-registry';
import {
UMB_DOCUMENT_TYPE_TREE_ALIAS,
UMB_DOCUMENT_TYPE_TREE_REPOSITORY_ALIAS,
UMB_DOCUMENT_TYPE_FOLDER_ENTITY_TYPE,
UMB_DOCUMENT_TYPE_FOLDER_WORKSPACE_ALIAS,
UMB_DOCUMENT_TYPE_TREE_ITEM_CHILDREN_COLLECTION_ALIAS,
} from './constants.js';
import { manifests as folderManifests } from './folder/manifests.js';
import { manifests as treeItemChildrenManifests } from './tree-item-children/manifests.js';
import { manifests as viewManifests } from './views/manifests.js';
import { UMB_WORKSPACE_CONDITION_ALIAS } from '@umbraco-cms/backoffice/workspace';
import { UMB_TREE_ALIAS_CONDITION } from '@umbraco-cms/backoffice/tree';
export const manifests: Array<UmbExtensionManifest> = [
export const manifests: Array<UmbExtensionManifest | UmbExtensionManifestKind> = [
{
type: 'repository',
alias: UMB_DOCUMENT_TYPE_TREE_REPOSITORY_ALIAS,
@@ -38,16 +40,27 @@ export const manifests: Array<UmbExtensionManifest> = [
UMB_DOCUMENT_TYPE_FOLDER_ENTITY_TYPE,
],
},
{
type: 'treeItemCard',
kind: 'default',
alias: 'Umb.TreeItemCard.DocumentType',
name: 'Document Type Tree Item Card',
forEntityTypes: [
UMB_DOCUMENT_TYPE_ROOT_ENTITY_TYPE,
UMB_DOCUMENT_TYPE_ENTITY_TYPE,
UMB_DOCUMENT_TYPE_FOLDER_ENTITY_TYPE,
],
},
{
type: 'workspaceView',
kind: 'collection',
alias: 'Umb.WorkspaceView.DocumentType.TreeItemChildrenCollection',
name: 'Document Type Tree Item Children Collection Workspace View',
kind: 'tree',
alias: 'Umb.WorkspaceView.DocumentType.Tree',
name: 'Document Type Tree Item Children Workspace View',
meta: {
label: '#general_design',
pathname: 'design',
icon: 'icon-member-dashed-line',
collectionAlias: UMB_DOCUMENT_TYPE_TREE_ITEM_CHILDREN_COLLECTION_ALIAS,
label: '#tree_children',
pathname: 'children',
icon: 'icon-bulleted-list',
treeAlias: UMB_DOCUMENT_TYPE_TREE_ALIAS,
},
conditions: [
{
@@ -56,6 +69,19 @@ export const manifests: Array<UmbExtensionManifest> = [
},
],
},
{
type: 'treeAction',
kind: 'create',
name: 'Document Type Tree Create Action',
alias: 'Umb.TreeAction.DocumentType.Create',
conditions: [
{
alias: UMB_TREE_ALIAS_CONDITION,
match: UMB_DOCUMENT_TYPE_TREE_ALIAS,
},
],
},
...viewManifests,
...folderManifests,
...treeItemChildrenManifests,
];
@@ -1,2 +1,6 @@
/**
* @deprecated Deprecated since v18. Scheduled for removal in Umbraco 20.
*/
export const UMB_DOCUMENT_TYPE_TREE_ITEM_CHILDREN_COLLECTION_ALIAS = 'Umb.Collection.DocumentType.TreeItemChildren';
export * from './repository/constants.js';
@@ -1,2 +1,5 @@
/**
* @deprecated Deprecated since v18. Scheduled for removal in Umbraco 20.
*/
export const UMB_DOCUMENT_TYPE_TREE_ITEM_CHILDREN_COLLECTION_REPOSITORY_ALIAS =
'Umb.Repository.DocumentType.TreeItemChildrenCollection';
@@ -1,10 +1,19 @@
import { UMB_DOCUMENT_TYPE_TREE_REPOSITORY_ALIAS } from '../../../constants.js';
import { UmbTreeItemChildrenCollectionRepositoryBase } from '@umbraco-cms/backoffice/tree';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import { UmbDeprecation } from '@umbraco-cms/backoffice/utils';
/**
* @deprecated Deprecated since v18. Scheduled for removal in Umbraco 20.
*/
export class UmbDocumentTypeTreeItemChildrenCollectionRepository extends UmbTreeItemChildrenCollectionRepositoryBase {
constructor(host: UmbControllerHost) {
super(host);
new UmbDeprecation({
deprecated: 'UmbDocumentTypeTreeItemChildrenCollectionRepository',
removeInVersion: '20.0.0',
solution: 'Use UmbDocumentTypeTreeRepository instead.',
}).warn();
this._setTreeRepositoryAlias(UMB_DOCUMENT_TYPE_TREE_REPOSITORY_ALIAS);
}
}
@@ -11,6 +11,10 @@ import { UmbModalRouteRegistrationController, type UmbModalRouteBuilder } from '
import { UMB_WORKSPACE_MODAL } from '@umbraco-cms/backoffice/workspace';
const elementName = 'umb-document-type-tree-item-table-collection-view';
/**
* @deprecated Deprecated since v18. Scheduled for removal in Umbraco 20.
*/
@customElement(elementName)
export class UmbDocumentTypeTreeItemTableCollectionViewElement extends UmbLitElement {
@state()
@@ -0,0 +1,36 @@
import { UMB_DOCUMENT_TYPE_TREE_ALIAS } from '../constants.js';
import { UMB_BOOLEAN_VALUE_TYPE } from '@umbraco-cms/backoffice/value-type';
import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension-registry';
export const manifests: Array<UmbExtensionManifest | UmbExtensionManifestKind> = [
{
type: 'treeView',
kind: 'classic',
alias: 'Umb.TreeView.DocumentType.Classic',
name: 'Document Type Classic Tree View',
forTrees: [UMB_DOCUMENT_TYPE_TREE_ALIAS],
},
{
type: 'treeView',
kind: 'card',
alias: 'Umb.TreeView.DocumentType.Card',
name: 'Document Type Card Tree View',
forTrees: [UMB_DOCUMENT_TYPE_TREE_ALIAS],
},
{
type: 'treeView',
kind: 'table',
alias: 'Umb.TreeView.DocumentType.Table',
name: 'Document Type Table Tree View',
forTrees: [UMB_DOCUMENT_TYPE_TREE_ALIAS],
meta: {
columns: [
{
field: 'isElement',
label: '#contentTypeEditor_elementType',
valueType: UMB_BOOLEAN_VALUE_TYPE,
},
],
},
},
];
@@ -21,9 +21,9 @@ import type {
UmbEntityCollectionItemElement,
} from '@umbraco-cms/backoffice/collection';
import { UmbDocumentVariantState } from '../../variant-state.js';
import { getDocumentVariantStateTagConfig } from '../../variant-state/utils.js';
import { UmbEntityContentTypeEntityContext } from '@umbraco-cms/backoffice/content-type';
import { UMB_DOCUMENT_TYPE_ENTITY_TYPE } from '@umbraco-cms/backoffice/document-type';
import type { UUIInterfaceColor } from '@umbraco-cms/backoffice/external/uui';
import { fromCamelCase } from '@umbraco-cms/backoffice/utils';
@customElement('umb-document-collection-item-card')
@@ -147,22 +147,6 @@ export class UmbDocumentCollectionItemCardElement extends UmbLitElement implemen
}
}
#getStateTagConfig(): { color: UUIInterfaceColor; label: string } | undefined {
if (!this._state) return;
switch (this._state) {
case UmbDocumentVariantState.PUBLISHED:
return { color: 'positive', label: this.localize.term('content_published') };
case UmbDocumentVariantState.PUBLISHED_PENDING_CHANGES:
return { color: 'warning', label: this.localize.term('content_publishedPendingChanges') };
case UmbDocumentVariantState.DRAFT:
return { color: 'default', label: this.localize.term('content_unpublished') };
case UmbDocumentVariantState.NOT_CREATED:
return { color: 'danger', label: this.localize.term('content_notCreated') };
default:
return { color: 'danger', label: fromCamelCase(this._state) };
}
}
override render() {
if (!this.item) return nothing;
return html`
@@ -188,9 +172,9 @@ export class UmbDocumentCollectionItemCardElement extends UmbLitElement implemen
}
#renderState() {
const tagConfig = this.#getStateTagConfig();
if (!tagConfig) return nothing;
return html`<uui-tag slot="tag" id="state" color=${tagConfig.color} look="secondary">${tagConfig.label}</uui-tag>`;
if (!this._state) return nothing;
const { color, label } = getDocumentVariantStateTagConfig(this._state, this.localize);
return html`<uui-tag slot="tag" id="state" color=${color} look="secondary">${label}</uui-tag>`;
}
#renderProperties() {
@@ -1,11 +1,9 @@
import { UmbDocumentItemDataResolver } from '../../../../item/index.js';
import type { UmbEditableDocumentCollectionItemModel } from '../../../types.js';
import { customElement, html, property, state } from '@umbraco-cms/backoffice/external/lit';
import { fromCamelCase } from '@umbraco-cms/backoffice/utils';
import { UmbDocumentVariantState } from '../../../../variant-state.js';
import { getDocumentVariantStateTagConfig } from '../../../../variant-state/utils.js';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import type { UmbTableColumn, UmbTableColumnLayoutElement, UmbTableItem } from '@umbraco-cms/backoffice/components';
import type { UUIInterfaceColor } from '@umbraco-cms/backoffice/external/uui';
@customElement('umb-document-table-column-state')
export class UmbDocumentTableColumnStateElement extends UmbLitElement implements UmbTableColumnLayoutElement {
@@ -35,24 +33,9 @@ export class UmbDocumentTableColumnStateElement extends UmbLitElement implements
this.#resolver.observe(this.#resolver.state, (state) => (this._state = state || ''));
}
#getStateTagConfig(): { color: UUIInterfaceColor; label: string } {
switch (this._state) {
case UmbDocumentVariantState.PUBLISHED:
return { color: 'positive', label: this.localize.term('content_published') };
case UmbDocumentVariantState.PUBLISHED_PENDING_CHANGES:
return { color: 'warning', label: this.localize.term('content_publishedPendingChanges') };
case UmbDocumentVariantState.DRAFT:
return { color: 'default', label: this.localize.term('content_unpublished') };
case UmbDocumentVariantState.NOT_CREATED:
return { color: 'danger', label: this.localize.term('content_notCreated') };
default:
return { color: 'danger', label: fromCamelCase(this._state) };
}
}
override render() {
const tagConfig = this.#getStateTagConfig();
return html`<uui-tag color=${tagConfig.color} look="secondary">${tagConfig.label}</uui-tag>`;
const { color, label } = getDocumentVariantStateTagConfig(this._state, this.localize);
return html`<uui-tag color=${color} look="secondary">${label}</uui-tag>`;
}
}
@@ -20,6 +20,7 @@ export * from './tree/index.js';
export * from './url/index.js';
export * from './user-permissions/index.js';
export * from './variant-state.js';
export * from './variant-state/index.js';
export { UMB_CONTENT_MENU_ALIAS } from './menu/manifests.js';
export { UMB_DOCUMENT_COLLECTION_ALIAS } from './collection/constants.js';
@@ -2,50 +2,56 @@ import { expect } from '@open-wc/testing';
import { customElement } from 'lit/decorators.js';
import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api';
import { UmbDocumentItemDataResolver } from './document-item-data-resolver.js';
import { UmbDocumentVariantState } from '../variant-state.js';
import { UmbVariantContext } from '@umbraco-cms/backoffice/variant';
import type { Observable } from '@umbraco-cms/backoffice/observable-api';
// ============================================
// SETUP: Create a test host element
// ============================================
// Controllers need a "host" element to attach to.
// This creates a simple HTML element that can host controllers.
@customElement('umb-test-controller-host')
class UmbTestControllerHostElement extends UmbControllerHostElementMixin(HTMLElement) {}
// Reads the current value of an observable once (resolves synchronously with the latest emission).
function observeFirst<T>(observable: Observable<T>): Promise<T> {
return new Promise<T>((resolve) => {
const subscription = observable.subscribe((value) => {
resolve(value);
queueMicrotask(() => subscription.unsubscribe());
});
});
}
function makeData(overrides: Record<string, unknown> = {}) {
return {
entityType: 'document',
unique: 'test-123',
documentType: { unique: 'dt-1', icon: 'icon-document', collection: null },
isTrashed: false,
flags: [],
variants: [{ culture: 'en-US', segment: null, name: 'English Title', state: UmbDocumentVariantState.PUBLISHED }],
...overrides,
};
}
describe('UmbDocumentItemDataResolver', () => {
let hostElement: UmbTestControllerHostElement;
let resolver: UmbDocumentItemDataResolver<any>;
let variantContext: UmbVariantContext;
// ============================================
// beforeEach: Runs before EACH test
// ============================================
beforeEach(async () => {
// 1. Create a host element
hostElement = new UmbTestControllerHostElement();
document.body.appendChild(hostElement);
// 2. Create and set up the variant context
// This tells the resolver which culture to use
variantContext = new UmbVariantContext(hostElement);
await variantContext.setCulture('en-US');
await variantContext.setFallbackCulture('en-US');
await variantContext.setAppCulture('en-US');
// 3. Create the resolver (it will consume the context automatically)
resolver = new UmbDocumentItemDataResolver(hostElement);
});
// ============================================
// afterEach: Cleanup after EACH test
// ============================================
afterEach(() => {
document.body.innerHTML = '';
});
// ============================================
// Test Group: Public API
// ============================================
describe('Public API', () => {
it('has a name observable', () => {
expect(resolver).to.have.property('name');
@@ -64,12 +70,8 @@ describe('UmbDocumentItemDataResolver', () => {
});
});
// ============================================
// Test Group: Name Fallback Behavior (THE BUG FIX)
// ============================================
describe('name fallback behavior', () => {
it('should use current variant name when available', async () => {
// ARRANGE: Create mock data with a name for current culture
const mockData = {
entityType: 'document',
unique: 'test-123',
@@ -78,16 +80,13 @@ describe('UmbDocumentItemDataResolver', () => {
variants: [{ culture: 'en-US', name: 'English Title', state: 'Published' }],
};
// ACT: Set the data
resolver.setData(mockData);
// ASSERT: Name should be the variant name
const name = await resolver.getName();
expect(name).to.equal('English Title');
});
it('should fall back to fallback culture name in parentheses', async () => {
// ARRANGE: Current culture (de-DE) has no name, fallback (en-US) has name
await variantContext.setCulture('de-DE');
await variantContext.setFallbackCulture('en-US');
@@ -102,19 +101,13 @@ describe('UmbDocumentItemDataResolver', () => {
],
};
// ACT
resolver.setData(mockData);
// ASSERT: Should use fallback name in parentheses
const name = await resolver.getName();
expect(name).to.equal('(English Title)');
});
it('should fall back to first variant with name when current and fallback have no name', async () => {
// ARRANGE: This is THE BUG FIX test!
// - Current culture (de-DE) has no name
// - Fallback culture (es-ES) has no name
// - But fr-FR has a name
await variantContext.setCulture('de-DE');
await variantContext.setFallbackCulture('es-ES');
@@ -130,16 +123,13 @@ describe('UmbDocumentItemDataResolver', () => {
],
};
// ACT
resolver.setData(mockData);
// ASSERT: Should find and use the French name (first with a value)
const name = await resolver.getName();
expect(name).to.equal('(Titre Français)');
});
it('should return (Untitled) when no variants have names', async () => {
// ARRANGE: No variant has a name
const mockData = {
entityType: 'document',
unique: 'test-123',
@@ -151,12 +141,146 @@ describe('UmbDocumentItemDataResolver', () => {
],
};
// ACT
resolver.setData(mockData);
// ASSERT: Should show (Untitled) placeholder
const name = await resolver.getName();
expect(name).to.equal('(Untitled)');
});
});
describe('state and draft resolution', () => {
it('resolves the current variant state', async () => {
resolver.setData(
makeData({ variants: [{ culture: 'en-US', name: 'x', state: UmbDocumentVariantState.PUBLISHED }] }),
);
expect(await resolver.getState()).to.equal(UmbDocumentVariantState.PUBLISHED);
});
it('marks the item as a draft when the current variant is a draft', async () => {
resolver.setData(makeData({ variants: [{ culture: 'en-US', name: 'x', state: UmbDocumentVariantState.DRAFT }] }));
expect(await resolver.getState()).to.equal(UmbDocumentVariantState.DRAFT);
expect(await resolver.getIsDraft()).to.equal(true);
});
it('is not a draft when the current variant is published', async () => {
resolver.setData(
makeData({ variants: [{ culture: 'en-US', name: 'x', state: UmbDocumentVariantState.PUBLISHED }] }),
);
expect(await resolver.getIsDraft()).to.equal(false);
});
it('falls back to NotCreated when the current culture has no variant', async () => {
await variantContext.setCulture('de-DE');
await variantContext.setFallbackCulture('en-US');
resolver.setData(
makeData({ variants: [{ culture: 'en-US', name: 'English', state: UmbDocumentVariantState.PUBLISHED }] }),
);
expect(await resolver.getState()).to.equal(UmbDocumentVariantState.NOT_CREATED);
});
});
describe('icon', () => {
it('resolves the icon from the document type', async () => {
resolver.setData(makeData({ documentType: { unique: 'dt-1', icon: 'icon-article', collection: null } }));
expect(await resolver.getIcon()).to.equal('icon-article');
});
});
describe('invariant documents', () => {
it('uses the single invariant variant for name and state', async () => {
resolver.setData(
makeData({
variants: [
{ culture: null, segment: null, name: 'Invariant Name', state: UmbDocumentVariantState.PUBLISHED },
],
}),
);
expect(await resolver.getName()).to.equal('Invariant Name');
expect(await resolver.getState()).to.equal(UmbDocumentVariantState.PUBLISHED);
});
});
describe('reactive culture switching', () => {
it('updates name and state when the display culture changes at runtime', async () => {
await variantContext.setFallbackCulture('en-US');
resolver.setData(
makeData({
variants: [
{ culture: 'en-US', segment: null, name: 'English Title', state: UmbDocumentVariantState.PUBLISHED },
{ culture: 'da-DK', segment: null, name: 'Dansk Titel', state: UmbDocumentVariantState.DRAFT },
],
}),
);
expect(await resolver.getName()).to.equal('English Title');
expect(await resolver.getState()).to.equal(UmbDocumentVariantState.PUBLISHED);
await variantContext.setCulture('da-DK');
expect(await resolver.getName()).to.equal('Dansk Titel');
expect(await resolver.getState()).to.equal(UmbDocumentVariantState.DRAFT);
});
});
describe('current culture lookup', () => {
it('getCulture returns the resolved display culture', async () => {
resolver.setData(makeData());
// Awaiting a variant-aware value guarantees the variant context has been consumed.
await resolver.getName();
expect(resolver.getCulture()).to.equal('en-US');
});
});
describe('dates and flags', () => {
it('resolves the create date from the current variant', async () => {
const createDate = new Date('2024-01-01T00:00:00Z');
resolver.setData(
makeData({
variants: [{ culture: 'en-US', name: 'x', state: UmbDocumentVariantState.PUBLISHED, createDate }],
}),
);
expect(await resolver.getCreateDate()).to.equal(createDate);
});
it('combines document-level and current-variant flags', async () => {
resolver.setData(
makeData({
flags: [{ alias: 'doc-flag' }],
variants: [
{
culture: 'en-US',
name: 'x',
state: UmbDocumentVariantState.PUBLISHED,
flags: [{ alias: 'variant-flag' }],
},
],
}),
);
// Ensure variant-aware values are computed before reading the flags observable.
await resolver.getName();
const flags = await observeFirst(resolver.flags);
expect(flags.map((flag) => flag.alias)).to.have.members(['doc-flag', 'variant-flag']);
});
});
describe('pass-through item fields', () => {
it('resolves entityType, unique and isTrashed', async () => {
resolver.setData(makeData({ unique: 'abc', isTrashed: true }));
expect(await resolver.getEntityType()).to.equal('document');
expect(await resolver.getUnique()).to.equal('abc');
expect(await resolver.getIsTrashed()).to.equal(true);
});
it('reports hasCollection based on the document type', () => {
resolver.setData(
makeData({ documentType: { unique: 'dt-1', icon: 'icon-document', collection: { unique: 'col-1' } } }),
);
expect(resolver.getHasCollection()).to.equal(true);
});
it('hasCollection is false when the document type has no collection', () => {
resolver.setData(makeData());
expect(resolver.getHasCollection()).to.equal(false);
});
});
});
@@ -9,32 +9,13 @@ import {
} from '@umbraco-cms/backoffice/observable-api';
import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
import type { UmbEntityFlag } from '@umbraco-cms/backoffice/entity-flag';
import { UMB_VARIANT_CONTEXT } from '@umbraco-cms/backoffice/variant';
import { UmbVariantResolver } from '@umbraco-cms/backoffice/variant';
import type { Observable } from '@umbraco-cms/backoffice/observable-api';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import type { UmbItemDataResolver } from '@umbraco-cms/backoffice/entity-item';
import type { UmbVariantContext } from '@umbraco-cms/backoffice/variant';
type UmbDocumentItemDataResolverModel = Omit<UmbDocumentItemModel, 'parent' | 'hasChildren'>;
/**
*
* @param {Array<UmbDocumentItemVariantModel>} variants - An array of variants to check
* @returns {boolean} Returns true if the variants are invariant, false otherwise
*/
function isVariantsInvariant(variants: Array<UmbDocumentItemVariantModel>): boolean {
return variants?.[0]?.culture === null;
}
/**
*
* @param {Array<UmbDocumentItemVariantModel>} variants - An array of variants to search
* @param {string} culture - The culture to find
* @returns {T | undefined} Returns the variant with the matching culture, or undefined if not found
*/
function findVariant<T extends UmbDocumentItemVariantModel>(variants: Array<T>, culture: string): T | undefined {
return variants.find((x) => x.culture === culture);
}
/**
* A controller for resolving data for a document item
* @exports
@@ -72,39 +53,28 @@ export class UmbDocumentItemDataResolver<DocumentItemModel extends UmbDocumentIt
#flags = new UmbArrayState<UmbEntityFlag>([], (data) => data.alias);
public readonly flags = this.#flags.asObservable();
#variantContext?: UmbVariantContext;
#fallbackCulture?: string | null;
#displayCulture?: string | null;
#variantResolver: UmbVariantResolver<UmbDocumentItemVariantModel>;
constructor(host: UmbControllerHost) {
super(host);
this.consumeContext(UMB_VARIANT_CONTEXT, (context) => {
this.#variantContext = context;
this.#observeVariantContext();
});
}
this.#variantResolver = new UmbVariantResolver<UmbDocumentItemVariantModel>(this);
#observeVariantContext() {
// Recompute when either the ambient culture or the resolved variant changes. Observing the cultures
// triggers a recompute when a culture arrives even if the matched variant is unchanged (clearing the
// guard below); observing the variants ensures the recompute reads the freshly resolved variant.
this.observe(this.#variantResolver.displayCulture, () => this.#setVariantAwareValues(), 'umbObserveDisplayCulture');
this.observe(
this.#variantContext?.displayCulture,
(displayCulture) => {
if (displayCulture === undefined) return;
this.#displayCulture = displayCulture;
this.#setVariantAwareValues();
},
'umbObserveVariantId',
);
this.observe(
this.#variantContext?.fallbackCulture,
(fallbackCulture) => {
if (fallbackCulture === undefined) return;
this.#fallbackCulture = fallbackCulture;
this.#setVariantAwareValues();
},
this.#variantResolver.fallbackCulture,
() => this.#setVariantAwareValues(),
'umbObserveFallbackCulture',
);
this.observe(this.#variantResolver.variant, () => this.#setVariantAwareValues(), 'umbObserveVariant');
this.observe(
this.#variantResolver.fallbackVariant,
() => this.#setVariantAwareValues(),
'umbObserveFallbackVariant',
);
}
/**
@@ -113,7 +83,7 @@ export class UmbDocumentItemDataResolver<DocumentItemModel extends UmbDocumentIt
* @memberof UmbDocumentItemDataResolver
*/
getCulture(): string | null | undefined {
return this.#displayCulture || this.#fallbackCulture;
return this.#variantResolver.getDisplayCulture() || this.#variantResolver.getFallbackCulture();
}
/**
@@ -132,6 +102,7 @@ export class UmbDocumentItemDataResolver<DocumentItemModel extends UmbDocumentIt
*/
setData(data: DocumentItemModel | undefined) {
this.#data.setValue(data);
this.#variantResolver.setVariants(data?.variants);
this.#setVariantAwareValues();
}
@@ -226,10 +197,9 @@ export class UmbDocumentItemDataResolver<DocumentItemModel extends UmbDocumentIt
}
#setVariantAwareValues() {
if (!this.#variantContext) return;
if (!this.#displayCulture) return;
if (!this.#fallbackCulture) return;
if (!this.#data) return;
if (!this.#variantResolver.getDisplayCulture()) return;
if (!this.#variantResolver.getFallbackCulture()) return;
if (!this.getData()) return;
this.#setName();
this.#setIsDraft();
this.#setState();
@@ -239,68 +209,44 @@ export class UmbDocumentItemDataResolver<DocumentItemModel extends UmbDocumentIt
}
#setName() {
const variant = this.#getCurrentVariant();
const variant = this.#variantResolver.getVariant();
if (variant?.name) {
this.#name.setValue(variant.name);
return;
}
const variants = this.getData()?.variants;
if (variants) {
// Try fallback culture first, then first variant with any name
const fallbackName = findVariant(variants, this.#fallbackCulture!)?.name ?? variants.find((x) => x.name)?.name;
// Try fallback culture first, then first variant with any name
const fallbackName =
this.#variantResolver.getFallbackVariant()?.name ?? this.#variantResolver.getVariants().find((x) => x.name)?.name;
if (fallbackName) {
this.#name.setValue(`(${fallbackName})`);
return;
}
if (fallbackName) {
this.#name.setValue(`(${fallbackName})`);
return;
}
this.#name.setValue('(Untitled)');
}
#setIsDraft() {
const variant = this.#getCurrentVariant();
const variant = this.#variantResolver.getVariant();
const isDraft = variant?.state === UmbDocumentVariantState.DRAFT || false;
this.#isDraft.setValue(isDraft);
}
#setState() {
const variant = this.#getCurrentVariant();
const variant = this.#variantResolver.getVariant();
const state = variant?.state || UmbDocumentVariantState.NOT_CREATED;
this.#state.setValue(state);
}
async #setCreateDate() {
const variant = await this.#getCurrentVariant();
if (variant) {
this.#createDate.setValue(variant.createDate);
return;
}
const variants = this.getData()?.variants;
if (variants) {
const fallbackCreateDate = findVariant(variants, this.#fallbackCulture!)?.createDate;
this.#createDate.setValue(fallbackCreateDate);
} else {
this.#createDate.setValue(undefined);
}
#setCreateDate() {
const variant = this.#variantResolver.getVariant();
this.#createDate.setValue((variant ?? this.#variantResolver.getFallbackVariant())?.createDate);
}
async #setUpdateDate() {
const variant = await this.#getCurrentVariant();
if (variant) {
this.#updateDate.setValue(variant.updateDate);
return;
}
const variants = this.getData()?.variants;
if (variants) {
const fallbackUpdateDate = findVariant(variants, this.#fallbackCulture!)?.updateDate;
this.#updateDate.setValue(fallbackUpdateDate);
} else {
this.#updateDate.setValue(undefined);
}
#setUpdateDate() {
const variant = this.#variantResolver.getVariant();
this.#updateDate.setValue((variant ?? this.#variantResolver.getFallbackVariant())?.updateDate);
}
#setFlags() {
@@ -311,18 +257,7 @@ export class UmbDocumentItemDataResolver<DocumentItemModel extends UmbDocumentIt
}
const flags = data.flags ?? [];
const variantFlags = this.#getCurrentVariant()?.flags ?? [];
const variantFlags = this.#variantResolver.getVariant()?.flags ?? [];
this.#flags.setValue([...flags, ...variantFlags]);
}
#getCurrentVariant() {
const variants = this.getData()?.variants;
if (!variants) return undefined;
if (isVariantsInvariant(variants)) {
return variants[0];
}
return findVariant(variants, this.#displayCulture!);
}
}
@@ -21,6 +21,7 @@ import { manifests as trackedReferenceManifests } from './reference/manifests.js
import { manifests as treeManifests } from './tree/manifests.js';
import { manifests as urlManifests } from './url/manifests.js';
import { manifests as userPermissionManifests } from './user-permissions/manifests.js';
import { manifests as variantStateManifests } from './variant-state/manifests.js';
import { manifests as workspaceManifests } from './workspace/manifests.js';
import { manifests as allowEditInvariantFromNonDefaultManifests } from './allow-edit-invariant-from-non-default/manifests.js';
import * as entryPointModule from './entry-point.js';
@@ -51,6 +52,7 @@ export const manifests: Array<UmbExtensionManifest | UmbExtensionManifestKind> =
...treeManifests,
...urlManifests,
...userPermissionManifests,
...variantStateManifests,
...workspaceManifests,
...allowEditInvariantFromNonDefaultManifests,
{
@@ -0,0 +1,2 @@
export const UMB_DOCUMENT_TREE_REPOSITORY_ALIAS = 'Umb.Repository.Document.Tree';
export const UMB_DOCUMENT_TREE_ALIAS = 'Umb.Tree.Document';
@@ -1,10 +1,12 @@
import { UMB_DOCUMENT_ENTITY_TYPE, UMB_DOCUMENT_ROOT_ENTITY_TYPE } from '../entity.js';
import { UMB_DOCUMENT_TREE_ALIAS, UMB_DOCUMENT_TREE_REPOSITORY_ALIAS } from './constants.js';
import { manifests as reloadTreeItemChildrenManifests } from './reload-tree-item-children/manifests.js';
import { manifests as viewManifests } from './views/manifests.js';
import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension-registry';
export const UMB_DOCUMENT_TREE_REPOSITORY_ALIAS = 'Umb.Repository.Document.Tree';
export const UMB_DOCUMENT_TREE_ALIAS = 'Umb.Tree.Document';
export { UMB_DOCUMENT_TREE_ALIAS, UMB_DOCUMENT_TREE_REPOSITORY_ALIAS } from './constants.js';
export const manifests: Array<UmbExtensionManifest> = [
export const manifests: Array<UmbExtensionManifest | UmbExtensionManifestKind> = [
{
type: 'repository',
alias: UMB_DOCUMENT_TREE_REPOSITORY_ALIAS,
@@ -36,5 +38,14 @@ export const manifests: Array<UmbExtensionManifest> = [
name: 'Document Tree Root',
forEntityTypes: [UMB_DOCUMENT_ROOT_ENTITY_TYPE],
},
{
type: 'treeItemCard',
kind: 'default',
alias: 'Umb.TreeItemCard.Document',
name: 'Document Tree Item Card',
element: () => import('./tree-item/document-tree-item-card.element.js'),
forEntityTypes: [UMB_DOCUMENT_ENTITY_TYPE],
},
...viewManifests,
...reloadTreeItemChildrenManifests,
];
@@ -0,0 +1,154 @@
import type { UmbDocumentTreeItemModel } from '../types.js';
import { UmbDocumentItemDataResolver } from '../../item/index.js';
import { UmbDocumentVariantState } from '../../variant-state.js';
import { getDocumentVariantStateTagConfig } from '../../variant-state/utils.js';
import { getItemFallbackIcon } from '@umbraco-cms/backoffice/entity-item';
import { customElement, html, ifDefined, nothing, property, state } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import type { UmbTreeItemApi } from '@umbraco-cms/backoffice/tree';
const elementName = 'umb-document-tree-item-card';
@customElement(elementName)
export class UmbDocumentTreeItemCardElement extends UmbLitElement {
#api?: UmbTreeItemApi;
#item = new UmbDocumentItemDataResolver(this);
@property({ type: Object, attribute: false })
public set api(value: UmbTreeItemApi | undefined) {
this.#api = value;
if (value) {
this.observe(value.isSelectable, (v) => (this._isSelectable = v), '_observeIsSelectable');
this.observe(value.isSelectableContext, (v) => (this._isSelectableContext = v), '_observeIsSelectableContext');
this.observe(value.selectOnly, (v) => (this._selectOnly = v), '_observeSelectOnly');
this.observe(value.isSelected, (v) => (this._isSelected = v), '_observeIsSelected');
this.observe(value.isActive, (v) => (this._isActive = v), '_observeIsActive');
this.observe(value.hasChildren, (v) => (this._hasChildren = v), '_observeHasChildren');
this.observe(value.noAccess, (v) => (this._noAccess = v), '_observeNoAccess');
this.observe(value.path, (v) => (this._path = v), '_observePath');
this.observe(value.hasActions, (v) => (this._hasActions = v), '_observeHasActions');
}
}
public get api(): UmbTreeItemApi | undefined {
return this.#api;
}
@property({ type: Object, attribute: false })
public set item(value: UmbDocumentTreeItemModel | undefined) {
this.#item.setData(value);
}
@state()
private _name = '';
@state()
private _icon?: string;
@state()
private _state?: UmbDocumentVariantState | null;
@state()
private _isSelectable = false;
@state()
private _isSelectableContext = false;
@state()
private _selectOnly = false;
@state()
private _isSelected = false;
@state()
private _isActive = false;
@state()
private _hasChildren = false;
@state()
private _noAccess = false;
@state()
private _path = '';
@state()
private _hasActions = false;
constructor() {
super();
this.observe(this.#item.name, (name) => (this._name = name ?? ''), '_observeName');
this.observe(this.#item.icon, (icon) => (this._icon = icon), '_observeIcon');
this.observe(this.#item.state, (state) => (this._state = state), '_observeState');
}
#onSelected(e: CustomEvent) {
e.stopPropagation();
this.#api?.select();
}
#onDeselected(e: CustomEvent) {
e.stopPropagation();
this.#api?.deselect();
}
#onOpen(e: Event) {
if (!this._hasChildren) return;
e.stopPropagation();
this.#api?.open();
}
#onKeyDown(e: KeyboardEvent) {
if (e.key === 'ArrowRight' && this._hasChildren) {
e.stopPropagation();
this.#api?.open();
}
}
override render() {
const href = this._isSelectableContext ? undefined : this._path || undefined;
return html`
<umb-figure-card
name=${this.localize.string(this._name)}
href=${ifDefined(href)}
?selectable=${this._isSelectable}
?select-only=${this._selectOnly || (!this._hasChildren && this._isSelectableContext)}
?selected=${this._isSelected}
?active=${this._isActive}
?has-children=${this._hasChildren}
?disabled=${this._noAccess}
background-color="var(--uui-color-surface)"
@selected=${this.#onSelected}
@deselected=${this.#onDeselected}
@open=${this.#onOpen}
@keydown=${this.#onKeyDown}>
${this.#renderIcon()} ${this.#renderState()} ${this.#renderActions()}
</umb-figure-card>
`;
}
#renderIcon() {
const icon = this._icon || getItemFallbackIcon();
return html`<umb-icon name=${icon}></umb-icon>`;
}
#renderState() {
if (!this._state) return nothing;
const { color, label } = getDocumentVariantStateTagConfig(this._state, this.localize);
return html`<uui-tag slot="tag" color=${color} look="secondary">${label}</uui-tag>`;
}
#renderActions() {
if (!this._hasActions) return nothing;
return html`<umb-entity-actions-bundle
slot="actions"
.label=${this.localize.string(this._name)}></umb-entity-actions-bundle>`;
}
}
export { UmbDocumentTreeItemCardElement as element };
declare global {
interface HTMLElementTagNameMap {
[elementName]: UmbDocumentTreeItemCardElement;
}
}
@@ -0,0 +1,157 @@
import { UmbDocumentTreeItemContext } from './document-tree-item.context.js';
import type { UmbDocumentTreeItemModel } from '../types.js';
import { UMB_DOCUMENT_ENTITY_TYPE, UMB_DOCUMENT_ROOT_ENTITY_TYPE } from '../../entity.js';
import { UmbDefaultTreeContext, UmbTreeItemOpenEvent } from '@umbraco-cms/backoffice/tree';
import { aTimeout, expect, oneEvent } from '@open-wc/testing';
import { customElement } from '@umbraco-cms/backoffice/external/lit';
import { UmbElementMixin } from '@umbraco-cms/backoffice/element-api';
@customElement('umb-test-document-tree-item-host')
class UmbTestDocumentTreeItemHostElement extends UmbElementMixin(HTMLElement) {}
function createTreeItem(hasCollection: boolean): UmbDocumentTreeItemModel {
return {
unique: 'document-unique-id',
entityType: UMB_DOCUMENT_ENTITY_TYPE,
name: 'Test Document',
hasChildren: true,
isFolder: false,
parent: {
unique: null,
entityType: UMB_DOCUMENT_ROOT_ENTITY_TYPE,
},
ancestors: [],
noAccess: false,
isTrashed: false,
isProtected: false,
documentType: {
unique: 'document-type-unique-id',
icon: 'icon-document',
collection: hasCollection ? { unique: 'collection-unique-id' } : null,
},
createDate: '2024-01-01T00:00:00Z',
variants: [],
flags: [],
};
}
describe('UmbDocumentTreeItemContext', () => {
let host: UmbTestDocumentTreeItemHostElement;
let treeContext: UmbDefaultTreeContext<UmbDocumentTreeItemModel>;
let context: UmbDocumentTreeItemContext;
// Stubs/spies (no sinon in this project).
let pushStateCalls: Array<{ url: string }>;
const originalPushState = history.pushState;
let expandCalls: number;
let collapseCalls: number;
beforeEach(async () => {
host = new UmbTestDocumentTreeItemHostElement();
document.body.appendChild(host);
treeContext = new UmbDefaultTreeContext(host);
expandCalls = 0;
collapseCalls = 0;
treeContext.expansion.expandItem = async () => {
expandCalls++;
};
treeContext.expansion.collapseItem = async () => {
collapseCalls++;
};
pushStateCalls = [];
history.pushState = (_data: unknown, _unused: string, url?: string | URL | null) => {
pushStateCalls.push({ url: String(url) });
};
context = new UmbDocumentTreeItemContext(host);
// Wait for the tree context to be consumed by the item context.
await aTimeout(0);
});
afterEach(() => {
history.pushState = originalPushState;
document.body.removeChild(host);
});
describe('collection item in a menu', () => {
beforeEach(async () => {
context.setIsMenu(true);
context.setTreeItem(createTreeItem(true));
// Let the children manager settle its expansion observer while the tree context is alive.
await aTimeout(0);
});
it('navigates to the Collection view on showChildren instead of expanding', () => {
context.showChildren();
expect(pushStateCalls.length).to.equal(1);
expect(pushStateCalls[0].url).to.contain('openCollection=true');
expect(expandCalls).to.equal(0);
});
it('navigates to the Collection view on hideChildren instead of collapsing', () => {
context.hideChildren();
expect(pushStateCalls.length).to.equal(1);
expect(pushStateCalls[0].url).to.contain('openCollection=true');
expect(collapseCalls).to.equal(0);
});
});
describe('collection item in a picker', () => {
beforeEach(async () => {
// A picker is not a menu.
context.setTreeItem(createTreeItem(true));
await aTimeout(0);
});
it('emits the open event on showChildren instead of expanding', async () => {
const listener = oneEvent(host, UmbTreeItemOpenEvent.TYPE);
context.showChildren();
const event = (await listener) as UmbTreeItemOpenEvent;
expect(event.unique).to.equal('document-unique-id');
expect(event.entityType).to.equal(UMB_DOCUMENT_ENTITY_TYPE);
expect(expandCalls).to.equal(0);
expect(pushStateCalls.length).to.equal(0);
});
it('emits the open event on hideChildren instead of collapsing', async () => {
const listener = oneEvent(host, UmbTreeItemOpenEvent.TYPE);
context.hideChildren();
const event = (await listener) as UmbTreeItemOpenEvent;
expect(event.unique).to.equal('document-unique-id');
expect(event.entityType).to.equal(UMB_DOCUMENT_ENTITY_TYPE);
expect(collapseCalls).to.equal(0);
expect(pushStateCalls.length).to.equal(0);
});
});
describe('non-collection item', () => {
beforeEach(async () => {
context.setTreeItem(createTreeItem(false));
await aTimeout(0);
});
it('expands its children on showChildren', () => {
context.showChildren();
expect(expandCalls).to.equal(1);
expect(pushStateCalls.length).to.equal(0);
});
it('collapses its children on hideChildren', () => {
context.hideChildren();
expect(collapseCalls).to.equal(1);
expect(pushStateCalls.length).to.equal(0);
});
});
});
@@ -35,7 +35,6 @@ export class UmbDocumentTreeItemContext extends UmbDefaultTreeItemContext<
// TODO: Move to API
readonly ancestors = this._treeItem.asObservablePart((item) => item?.ancestors ?? []);
readonly isTrashed = this._treeItem.asObservablePart((item) => item?.isTrashed ?? false);
readonly noAccess = this._treeItem.asObservablePart((item) => item?.noAccess ?? false);
override setIsMenu(isMenu: boolean) {
super.setIsMenu(isMenu);
@@ -89,22 +88,31 @@ export class UmbDocumentTreeItemContext extends UmbDefaultTreeItemContext<
}
public override showChildren() {
if (this.getIsMenu() && this.#item.getHasCollection()) {
// Collections cannot be expanded via a menu, instead we open the Collection for the user.
this.#openCollection();
if (this.#item.getHasCollection()) {
this.#activateCollection();
return;
}
super.showChildren();
}
public override hideChildren() {
if (this.getIsMenu() && this.#item.getHasCollection()) {
// Collections in a menu will collapse when already showing children, and instead we open the Collection for the user.
this.#openCollection();
if (this.#item.getHasCollection()) {
this.#activateCollection();
return;
}
super.hideChildren();
}
// Collections cannot be expanded/collapsed. In a menu we navigate to the Collection view via the path;
// elsewhere (e.g. a picker) we emit the open event so the host can enter the Collection.
#activateCollection() {
if (this.getIsMenu()) {
this.#openCollection();
} else {
this.open();
}
}
#openCollection() {
// open the collection view for this item:
history.pushState(null, '', ensureSlash(this.getPath()) + '?openCollection=true');
@@ -24,9 +24,6 @@ export class UmbDocumentTreeItemElement extends UmbTreeItemElementBase<
});
this.observe(this.#api.icon, (icon) => (this.#icon = icon || ''));
this.observe(this.#api.flags, (flags) => (this._flags = flags || []));
// Observe noAccess from context and update base class property (_noAccess).
// This enables access restriction behavior (click prevention) and styling from the base class.
this.observe(this.#api.noAccess, (noAccess) => (this._noAccess = noAccess));
}
super.api = value;
@@ -58,17 +55,25 @@ export class UmbDocumentTreeItemElement extends UmbTreeItemElementBase<
// eslint-disable-next-line @typescript-eslint/naming-convention
override _renderExpandSymbol = () => {
// If this in the menu and it is a collection, then we will enforce the user to the Collection view instead of expanding.
// When it is a collection, we show a list icon instead of the expand arrow. Activating the caret then
// enters the Collection instead of expanding its children (see `UmbDocumentTreeItemContext`).
// `this._forceShowExpand` is equivalent to hasCollection for this element.
if (this._isMenu && this._forceShowExpand) {
return html`<umb-icon data-mark="open-collection" name="icon-list" style="font-size: 8px;"></umb-icon>`;
} else {
return undefined;
}
if (!this._forceShowExpand) return undefined;
return html`<umb-icon data-mark="open-collection" name="icon-list" style="font-size: 8px;"></umb-icon>`;
};
#handleDblClick(event: MouseEvent) {
if (!this._item?.hasChildren) return;
event.stopPropagation();
this.api?.open();
}
override renderLabel() {
return html`<span id="label" slot="label" class=${classMap({ draft: this._isDraft, noAccess: this._noAccess })}>
return html`<span
id="label"
slot="label"
class=${classMap({ draft: this._isDraft, noAccess: this._noAccess })}
@dblclick=${this.#handleDblClick}>
${this._name}
</span> `;
}
@@ -0,0 +1,36 @@
import { UMB_DOCUMENT_TREE_ALIAS } from '../constants.js';
import { UMB_DOCUMENT_VARIANT_STATE_VALUE_TYPE } from '../../variant-state/value-type/constants.js';
import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension-registry';
export const manifests: Array<UmbExtensionManifest | UmbExtensionManifestKind> = [
{
type: 'treeView',
kind: 'classic',
alias: 'Umb.TreeView.Document.Classic',
name: 'Document Classic Tree View',
forTrees: [UMB_DOCUMENT_TREE_ALIAS],
},
{
type: 'treeView',
kind: 'card',
alias: 'Umb.TreeView.Document.Card',
name: 'Document Card Tree View',
forTrees: [UMB_DOCUMENT_TREE_ALIAS],
},
{
type: 'treeView',
kind: 'table',
alias: 'Umb.TreeView.Document.Table',
name: 'Document Table Tree View',
forTrees: [UMB_DOCUMENT_TREE_ALIAS],
meta: {
columns: [
{
field: 'variants',
label: '#general_status',
valueType: UMB_DOCUMENT_VARIANT_STATE_VALUE_TYPE,
},
],
},
},
];
@@ -0,0 +1 @@
export * from './value-type/constants.js';
@@ -0,0 +1,3 @@
import { manifests as valueSummaryManifests } from './value-summary/manifests.js';
export const manifests: Array<UmbExtensionManifest> = [...valueSummaryManifests];
@@ -0,0 +1,35 @@
import { UmbDocumentVariantState } from '../variant-state.js';
import { fromCamelCase } from '@umbraco-cms/backoffice/utils';
import type { UmbLocalizationController } from '@umbraco-cms/backoffice/localization-api';
import type { UUIInterfaceColor } from '@umbraco-cms/backoffice/external/uui';
export interface UmbDocumentVariantStateTagConfig {
color: UUIInterfaceColor;
label: string;
}
/**
* Maps a document variant state to a tag colour and localized label.
* @param {UmbDocumentVariantState | string | null | undefined} state - The variant state.
* @param {UmbLocalizationController} localize - Localization controller used to resolve labels.
* @returns {UmbDocumentVariantStateTagConfig} The colour and label to render for the state.
*/
export function getDocumentVariantStateTagConfig(
state: UmbDocumentVariantState | string | null | undefined,
localize: UmbLocalizationController,
): UmbDocumentVariantStateTagConfig {
switch (state) {
case UmbDocumentVariantState.PUBLISHED:
return { color: 'positive', label: localize.term('content_published') };
case UmbDocumentVariantState.PUBLISHED_PENDING_CHANGES:
return { color: 'warning', label: localize.term('content_publishedPendingChanges') };
case UmbDocumentVariantState.DRAFT:
return { color: 'default', label: localize.term('content_unpublished') };
case UmbDocumentVariantState.NOT_CREATED:
case null:
case undefined:
return { color: 'danger', label: localize.term('content_notCreated') };
default:
return { color: 'danger', label: fromCamelCase(state) };
}
}
@@ -0,0 +1,43 @@
import type { UmbDocumentVariantStateValueModel } from '../value-type/constants.js';
import { getDocumentVariantStateTagConfig } from '../utils.js';
import type { UmbDocumentVariantState } from '../../variant-state.js';
import { customElement, html, nothing, state, type PropertyValues } from '@umbraco-cms/backoffice/external/lit';
import { UmbValueSummaryElementBase } from '@umbraco-cms/backoffice/value-summary';
import { UmbVariantResolver } from '@umbraco-cms/backoffice/variant';
type UmbDocumentVariantStateValue = Array<UmbDocumentVariantStateValueModel>;
@customElement('umb-document-variant-state-value-summary')
export class UmbDocumentVariantStateValueSummaryElement extends UmbValueSummaryElementBase<UmbDocumentVariantStateValue> {
#variantResolver = new UmbVariantResolver<UmbDocumentVariantStateValueModel>(this);
@state()
private _state?: UmbDocumentVariantState | null;
constructor() {
super();
this.observe(this.#variantResolver.variant, (variant) => (this._state = variant?.state));
}
override willUpdate(changedProperties: PropertyValues) {
super.willUpdate(changedProperties);
if (changedProperties.has('_value')) {
this.#variantResolver.setVariants(this._value);
}
}
override render() {
// No tag until a variant matching the current culture has been resolved.
if (this._state === undefined) return nothing;
const { color, label } = getDocumentVariantStateTagConfig(this._state, this.localize);
return html`<uui-tag color=${color} look="secondary">${label}</uui-tag>`;
}
}
export { UmbDocumentVariantStateValueSummaryElement as element };
declare global {
interface HTMLElementTagNameMap {
['umb-document-variant-state-value-summary']: UmbDocumentVariantStateValueSummaryElement;
}
}
@@ -0,0 +1,12 @@
import { UMB_DOCUMENT_VARIANT_STATE_VALUE_TYPE } from '../value-type/constants.js';
export const manifests: Array<UmbExtensionManifest> = [
{
type: 'valueSummary',
kind: 'default',
alias: 'Umb.ValueSummary.Document.VariantState',
name: 'Document Variant State Value Summary',
forValueType: UMB_DOCUMENT_VARIANT_STATE_VALUE_TYPE,
element: () => import('./document-variant-state-value-summary.element.js'),
},
];
@@ -0,0 +1,15 @@
import type { UmbDocumentVariantState } from '../../variant-state.js';
export interface UmbDocumentVariantStateValueModel {
culture: string | null;
segment?: string | null;
state: UmbDocumentVariantState | null;
}
export const UMB_DOCUMENT_VARIANT_STATE_VALUE_TYPE = 'Umb.ValueType.Document.VariantState' as const;
declare global {
interface UmbValueTypeMap {
[UMB_DOCUMENT_VARIANT_STATE_VALUE_TYPE]: Array<UmbDocumentVariantStateValueModel>;
}
}

Some files were not shown because too many files have changed in this diff Show More