Compare commits
103
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1103a2f821 | ||
|
|
4738c53def | ||
|
|
783b60354e | ||
|
|
e2f1410fa9 | ||
|
|
93a90d1ee1 | ||
|
|
6d31f803da | ||
|
|
d7fde5d74c | ||
|
|
ab4ba41462 | ||
|
|
a77e18eb4e | ||
|
|
8f34149b19 | ||
|
|
a2ed0fd6fc | ||
|
|
4c6736b18d | ||
|
|
227c228aab | ||
|
|
e3b08349c8 | ||
|
|
ac74d102fb | ||
|
|
ca12e6d79c | ||
|
|
267882f8d8 | ||
|
|
83bcc8c3ab | ||
|
|
266bd6295f | ||
|
|
8eff03664b | ||
|
|
17d7a3b0c6 | ||
|
|
0705aacf38 | ||
|
|
cb19024005 | ||
|
|
129a9731a7 | ||
|
|
f7af28bb77 | ||
|
|
6592c9d641 | ||
|
|
f2dd4e2dfd | ||
|
|
0715cdc913 | ||
|
|
6c0acb662f | ||
|
|
67ee65e8ac | ||
|
|
2780527d3a | ||
|
|
f73ec5ffa2 | ||
|
|
70a3ec8281 | ||
|
|
389a59d05b | ||
|
|
aac5db9198 | ||
|
|
52752e2a92 | ||
|
|
9c31815adb | ||
|
|
964a427bce | ||
|
|
05b4137493 | ||
|
|
2e512add88 | ||
|
|
c1af34b03c | ||
|
|
dad7092574 | ||
|
|
9beb0d7bc6 | ||
|
|
541b262548 | ||
|
|
d5631691bb | ||
|
|
278c78293e | ||
|
|
6b1a79fd73 | ||
|
|
c1a5386c0d | ||
|
|
0ba1565db3 | ||
|
|
4c52b2d4f0 | ||
|
|
c109310b8b | ||
|
|
2c5d3f8dbf | ||
|
|
f1fa764b6d | ||
|
|
e082cbee6b | ||
|
|
58ca45e2dd | ||
|
|
1dd37f6190 | ||
|
|
5abfd7b35b | ||
|
|
6b23d5922d | ||
|
|
d11a681856 | ||
|
|
d62dbc27d4 | ||
|
|
054410e29d | ||
|
|
a9f133c3ac | ||
|
|
365215a072 | ||
|
|
3a1c6940cb | ||
|
|
9241bfdaa9 | ||
|
|
3f848081ec | ||
|
|
7a2e54430d | ||
|
|
7254bce9ae | ||
|
|
f0375288e4 | ||
|
|
88e5852a89 | ||
|
|
a365771fd9 | ||
|
|
3632564bbc | ||
|
|
5f56d4d38b | ||
|
|
2a661530b1 | ||
|
|
dc02cc1e22 | ||
|
|
c581d03174 | ||
|
|
e655232756 | ||
|
|
35be50f57d | ||
|
|
aadfd9bd5f | ||
|
|
1df05f399e | ||
|
|
2182d91504 | ||
|
|
cbd0e3562e | ||
|
|
d64f79d2d8 | ||
|
|
3100c85904 | ||
|
|
4e9924d7d6 | ||
|
|
a2a65d91a7 | ||
|
|
111f494a44 | ||
|
|
ba2eb4de6d | ||
|
|
a1de5b3902 | ||
|
|
8a847ffd00 | ||
|
|
09d01a240d | ||
|
|
760fb7164b | ||
|
|
846001393b | ||
|
|
57514815d1 | ||
|
|
26941c5413 | ||
|
|
4c4054e4fe | ||
|
|
b39dfc9526 | ||
|
|
392c7f8dec | ||
|
|
d9358463c1 | ||
|
|
6ba81b0302 | ||
|
|
3ad2ea3327 | ||
|
|
026acdf64c | ||
|
|
5790763eb3 |
+18
-4
@@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
+83
@@ -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 };
|
||||
+120
@@ -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';
|
||||
|
||||
+23
@@ -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;
|
||||
}
|
||||
}
|
||||
+71
@@ -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;
|
||||
}
|
||||
}
|
||||
+114
@@ -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;
|
||||
|
||||
+8
-11
@@ -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>
|
||||
`;
|
||||
|
||||
+1
@@ -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,
|
||||
];
|
||||
|
||||
+8
@@ -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 {}
|
||||
+121
@@ -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];
|
||||
+15
@@ -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];
|
||||
+92
@@ -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;
|
||||
}
|
||||
}
|
||||
+13
@@ -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';
|
||||
+18
@@ -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';
|
||||
|
||||
+345
@@ -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();
|
||||
}
|
||||
}
|
||||
+31
-278
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+30
-32
@@ -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() {
|
||||
|
||||
+197
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+35
-17
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+4
-20
@@ -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;
|
||||
}
|
||||
|
||||
+21
-1
@@ -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,
|
||||
);
|
||||
|
||||
+1
@@ -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,
|
||||
|
||||
+256
-20
@@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
+164
@@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
+387
@@ -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;
|
||||
}
|
||||
}
|
||||
+48
@@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
+99
@@ -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,
|
||||
];
|
||||
|
||||
+4
@@ -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';
|
||||
|
||||
+3
@@ -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';
|
||||
|
||||
+9
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -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()
|
||||
|
||||
+36
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
+4
-20
@@ -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() {
|
||||
|
||||
+3
-20
@@ -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';
|
||||
|
||||
+160
-36
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+36
-101
@@ -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,
|
||||
];
|
||||
|
||||
+154
@@ -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;
|
||||
}
|
||||
}
|
||||
+157
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+15
-7
@@ -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');
|
||||
|
||||
+15
-10
@@ -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) };
|
||||
}
|
||||
}
|
||||
+43
@@ -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;
|
||||
}
|
||||
}
|
||||
+12
@@ -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'),
|
||||
},
|
||||
];
|
||||
+15
@@ -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
Reference in New Issue
Block a user