diff --git a/src/Umbraco.Web.UI.Client/examples/collection/collection/repository/collection.repository.ts b/src/Umbraco.Web.UI.Client/examples/collection/collection/repository/collection.repository.ts index a6d7e9879849..80589503a5d5 100644 --- a/src/Umbraco.Web.UI.Client/examples/collection/collection/repository/collection.repository.ts +++ b/src/Umbraco.Web.UI.Client/examples/collection/collection/repository/collection.repository.ts @@ -17,30 +17,40 @@ export class ExampleCollectionRepository entityType: 'example', name: 'Example Item 1', icon: 'icon-newspaper', + description: 'This is a description of Example Item 1', + status: 'Published', }, { unique: 'bc9b6e24-4b11-4dd6-8d4e-7c4f70e59f3c', entityType: 'example', name: 'Example Item 2', icon: 'icon-newspaper', + description: 'This is a description of Example Item 2', + status: 'Draft', }, { unique: '5a2f4e3a-ef7e-470e-8c3c-3d859c02ae0d', entityType: 'example', name: 'Example Item 3', icon: 'icon-newspaper', + description: 'This is a description of Example Item 3', + status: 'Published', }, { unique: 'f4c3d8b8-6d79-4c87-9aa9-56b1d8fda702', entityType: 'example', name: 'Example Item 4', icon: 'icon-newspaper', + description: 'This is a description of Example Item 4', + status: 'Draft', }, { unique: 'c9f0a8a3-1b49-4724-bde3-70e31592eb6e', entityType: 'example', name: 'Example Item 5', icon: 'icon-newspaper', + description: 'This is a description of Example Item 5', + status: 'Published', }, ]; diff --git a/src/Umbraco.Web.UI.Client/examples/collection/collection/repository/types.ts b/src/Umbraco.Web.UI.Client/examples/collection/collection/repository/types.ts index 3ee3f404026d..5d4652a063ec 100644 --- a/src/Umbraco.Web.UI.Client/examples/collection/collection/repository/types.ts +++ b/src/Umbraco.Web.UI.Client/examples/collection/collection/repository/types.ts @@ -1,10 +1,10 @@ -import type { UmbCollectionFilterModel } from '@umbraco-cms/backoffice/collection'; +import type { UmbCollectionFilterModel, UmbCollectionItemModel } from '@umbraco-cms/backoffice/collection'; +import type { UmbWithDescriptionModel } from '@umbraco-cms/backoffice/models'; -export interface ExampleCollectionItemModel { - unique: string; - entityType: string; +export interface ExampleCollectionItemModel extends UmbCollectionItemModel, UmbWithDescriptionModel { name: string; icon: string; + status: string; } export interface ExampleCollectionFilterModel extends UmbCollectionFilterModel {} diff --git a/src/Umbraco.Web.UI.Client/examples/collection/collection/table-view/collection-view.element.ts b/src/Umbraco.Web.UI.Client/examples/collection/collection/table-view/collection-view.element.ts deleted file mode 100644 index 2f466531232e..000000000000 --- a/src/Umbraco.Web.UI.Client/examples/collection/collection/table-view/collection-view.element.ts +++ /dev/null @@ -1,89 +0,0 @@ -import type { ExampleCollectionItemModel } from '../repository/types.js'; -import type { UmbDefaultCollectionContext } from '@umbraco-cms/backoffice/collection'; -import { UMB_COLLECTION_CONTEXT } from '@umbraco-cms/backoffice/collection'; -import type { UmbTableColumn, UmbTableConfig, UmbTableItem } from '@umbraco-cms/backoffice/components'; -import { css, html, customElement, state } from '@umbraco-cms/backoffice/external/lit'; -import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; -import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; - -@customElement('example-table-collection-view') -export class ExampleTableCollectionViewElement extends UmbLitElement { - @state() - private _tableConfig: UmbTableConfig = { - allowSelection: false, - }; - - @state() - private _tableColumns: Array = [ - { - name: 'Name', - alias: 'name', - }, - ]; - - @state() - private _tableItems: Array = []; - - #collectionContext?: UmbDefaultCollectionContext; - - constructor() { - super(); - - this.consumeContext(UMB_COLLECTION_CONTEXT, (instance) => { - this.#collectionContext = instance; - this.#observeCollectionItems(); - }); - } - - #observeCollectionItems() { - this.observe( - this.#collectionContext?.items, - (items) => this.#createTableItems(items), - 'umbCollectionItemsObserver', - ); - } - - #createTableItems(items: Array | undefined) { - if (!items) { - this._tableItems = []; - return; - } - - this._tableItems = items.map((item) => { - return { - id: item.unique, - icon: item.icon, - data: [ - { - columnAlias: 'name', - value: item.name, - }, - ], - }; - }); - } - - override render() { - return html` - - `; - } - - static override styles = [ - UmbTextStyles, - css` - :host { - display: flex; - flex-direction: column; - } - `, - ]; -} - -export { ExampleTableCollectionViewElement as element }; - -declare global { - interface HTMLElementTagNameMap { - 'example-table-collection-view': ExampleTableCollectionViewElement; - } -} diff --git a/src/Umbraco.Web.UI.Client/examples/collection/collection/table-view/manifests.ts b/src/Umbraco.Web.UI.Client/examples/collection/collection/table-view/manifests.ts index 945c25507a11..e515816167c7 100644 --- a/src/Umbraco.Web.UI.Client/examples/collection/collection/table-view/manifests.ts +++ b/src/Umbraco.Web.UI.Client/examples/collection/collection/table-view/manifests.ts @@ -4,14 +4,16 @@ import { UMB_COLLECTION_ALIAS_CONDITION } from '@umbraco-cms/backoffice/collecti export const manifests: Array = [ { type: 'collectionView', + kind: 'table', alias: 'Example.CollectionView.Table', name: 'Example Table Collection View', - js: () => import('./collection-view.element.js'), - weight: 100, meta: { - label: 'Table', - icon: 'icon-table', - pathName: 'table', + columns: [ + { + label: '#general_status', + field: 'status', + }, + ], }, conditions: [ { diff --git a/src/Umbraco.Web.UI.Client/examples/collection/entity-actions/goodbye.action.ts b/src/Umbraco.Web.UI.Client/examples/collection/entity-actions/goodbye.action.ts new file mode 100644 index 000000000000..e40901403104 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/examples/collection/entity-actions/goodbye.action.ts @@ -0,0 +1,9 @@ +import { UmbEntityActionBase } from '@umbraco-cms/backoffice/entity-action'; + +export class ExampleGoodbyeEntityAction extends UmbEntityActionBase { + override async execute() { + alert(`Goodbye from "${this.args.unique}"!`); + } +} + +export { ExampleGoodbyeEntityAction as api }; diff --git a/src/Umbraco.Web.UI.Client/examples/collection/entity-actions/hello.action.ts b/src/Umbraco.Web.UI.Client/examples/collection/entity-actions/hello.action.ts new file mode 100644 index 000000000000..b2dca325445d --- /dev/null +++ b/src/Umbraco.Web.UI.Client/examples/collection/entity-actions/hello.action.ts @@ -0,0 +1,9 @@ +import { UmbEntityActionBase } from '@umbraco-cms/backoffice/entity-action'; + +export class ExampleHelloEntityAction extends UmbEntityActionBase { + override async execute() { + alert(`Hello from "${this.args.unique}"!`); + } +} + +export { ExampleHelloEntityAction as api }; diff --git a/src/Umbraco.Web.UI.Client/examples/collection/entity-actions/manifests.ts b/src/Umbraco.Web.UI.Client/examples/collection/entity-actions/manifests.ts new file mode 100644 index 000000000000..331781cf0e08 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/examples/collection/entity-actions/manifests.ts @@ -0,0 +1,26 @@ +export const manifests: Array = [ + { + type: 'entityAction', + kind: 'default', + alias: 'Example.EntityAction.Hello', + name: 'Example Hello Entity Action', + forEntityTypes: ['example'], + api: () => import('./hello.action.js'), + meta: { + icon: 'icon-handshake', + label: 'Say Hello', + }, + }, + { + type: 'entityAction', + kind: 'default', + alias: 'Example.EntityAction.Goodbye', + name: 'Example Goodbye Entity Action', + forEntityTypes: ['example'], + api: () => import('./goodbye.action.js'), + meta: { + icon: 'icon-chat', + label: 'Say Goodbye', + }, + }, +]; diff --git a/src/Umbraco.Web.UI.Client/examples/collection/index.ts b/src/Umbraco.Web.UI.Client/examples/collection/index.ts index 4d7691f3303e..1810683bc380 100644 --- a/src/Umbraco.Web.UI.Client/examples/collection/index.ts +++ b/src/Umbraco.Web.UI.Client/examples/collection/index.ts @@ -1,9 +1,11 @@ import { manifests as collectionManifests } from './collection/manifests.js'; import { manifests as dashboardManifests } from './dashboard-with-collection/manifests.js'; +import { manifests as entityActionManifests } from './entity-actions/manifests.js'; import { manifests as workspaceViewManifests } from './workspace-view-with-collection/manifests.js'; export const manifests: Array = [ ...collectionManifests, ...dashboardManifests, + ...entityActionManifests, ...workspaceViewManifests, ]; diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/da.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/da.ts index 8c590ff2b2c0..329e5024db54 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/da.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/da.ts @@ -852,6 +852,7 @@ export default { delete: 'Slet', deleted: 'Slettet', deleting: 'Sletter...', + description: 'Beskrivelse', design: 'Design', details: 'Detaljer', dictionary: 'Ordbog', diff --git a/src/Umbraco.Web.UI.Client/src/libs/controller-api/controller-host.mixin.ts b/src/Umbraco.Web.UI.Client/src/libs/controller-api/controller-host.mixin.ts index f7947b564884..17905ef4c945 100644 --- a/src/Umbraco.Web.UI.Client/src/libs/controller-api/controller-host.mixin.ts +++ b/src/Umbraco.Web.UI.Client/src/libs/controller-api/controller-host.mixin.ts @@ -2,7 +2,7 @@ import type { UmbControllerHost } from './controller-host.interface.js'; import type { UmbController } from './controller.interface.js'; import type { ClassConstructor } from '@umbraco-cms/backoffice/extension-api'; -interface UmbControllerHostBaseDeclaration extends Omit { +export interface UmbControllerHostBaseDeclaration extends Omit { hostConnected(): void; hostDisconnected(): void; destroy(): void; diff --git a/src/Umbraco.Web.UI.Client/src/libs/controller-api/element-controller-host.test.ts b/src/Umbraco.Web.UI.Client/src/libs/controller-api/element-controller-host.test.ts new file mode 100644 index 000000000000..8bf65f8960ed --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/libs/controller-api/element-controller-host.test.ts @@ -0,0 +1,181 @@ +import { UmbElementControllerHost } from './element-controller-host.js'; +import { UmbControllerHostElementMixin } from './controller-host-element.mixin.js'; +import { expect, fixture, html } from '@open-wc/testing'; +import { customElement } from '@umbraco-cms/backoffice/external/lit'; +import { UmbContextConsumerController, UmbContextProviderController } from '@umbraco-cms/backoffice/context-api'; +import type { UmbControllerAlias } from './controller-alias.type.js'; +import type { UmbControllerHost } from './controller-host.interface.js'; +import { UmbControllerHostMixin } from './controller-host.mixin.js'; + +class UmbTestContext { + value = 'test-context-value'; + getHostElement() { + return undefined as unknown as Element; + } +} + +class UmbTestController extends UmbControllerHostMixin(class {}) { + testIsConnected = false; + testIsDestroyed = false; + + private _host: UmbControllerHost; + readonly controllerAlias: UmbControllerAlias; + + constructor(host: UmbControllerHost, controllerAlias?: UmbControllerAlias) { + super(); + this._host = host; + this.controllerAlias = controllerAlias ?? Symbol(); + this._host.addUmbController(this); + } + + getHostElement() { + return this._host?.getHostElement(); + } + + override hostConnected(): void { + super.hostConnected(); + this.testIsConnected = true; + } + override hostDisconnected(): void { + super.hostDisconnected(); + this.testIsConnected = false; + } + + public override destroy(): void { + if (this._host) { + this._host.removeUmbController(this); + this._host = undefined as any; + } + super.destroy(); + this.testIsDestroyed = true; + } +} + +@customElement('test-element-controller-host-consumer') +class UmbTestConsumerElement extends UmbControllerHostElementMixin(HTMLElement) { + public contextValue?: string; + constructor() { + super(); + new UmbContextConsumerController(this, 'test-context', (context) => { + this.contextValue = context?.value; + }); + } +} + +describe('UmbElementControllerHost', () => { + describe('getHostElement', () => { + it('returns the element passed in the constructor', () => { + const element = document.createElement('div'); + const host = new UmbElementControllerHost(element); + expect(host.getHostElement()).to.equal(element); + }); + }); + + describe('Controller lifecycle', () => { + let element: HTMLElement; + let host: UmbElementControllerHost; + + beforeEach(() => { + element = document.createElement('div'); + host = new UmbElementControllerHost(element); + }); + + it('controllers are connected when hostConnected is called', async () => { + const ctrl = new UmbTestController(host); + expect(ctrl.testIsConnected).to.be.false; + + host.hostConnected(); + await Promise.resolve(); + + expect(ctrl.testIsConnected).to.be.true; + }); + + it('controllers are disconnected when hostDisconnected is called', async () => { + const ctrl = new UmbTestController(host); + host.hostConnected(); + await Promise.resolve(); + expect(ctrl.testIsConnected).to.be.true; + + host.hostDisconnected(); + + expect(ctrl.testIsConnected).to.be.false; + }); + + it('controllers are destroyed when host is destroyed', async () => { + const ctrl = new UmbTestController(host); + host.hostConnected(); + await Promise.resolve(); + + host.destroy(); + + expect(ctrl.testIsDestroyed).to.be.true; + expect(host.hasUmbController(ctrl)).to.be.false; + }); + + it('sub-controllers are destroyed when host is destroyed', async () => { + const ctrl = new UmbTestController(host); + const subCtrl = new UmbTestController(ctrl); + host.hostConnected(); + await Promise.resolve(); + + host.destroy(); + + expect(ctrl.testIsDestroyed).to.be.true; + expect(subCtrl.testIsDestroyed).to.be.true; + }); + }); + + describe('Context provision', () => { + it('provides context to descendant elements via the backed element', async () => { + const wrapper = await fixture(html` +
+ +
+ `); + + const consumer = wrapper.querySelector( + 'test-element-controller-host-consumer', + ) as UmbTestConsumerElement; + + const host = new UmbElementControllerHost(wrapper); + host.hostConnected(); + new UmbContextProviderController(host, 'test-context', new UmbTestContext()); + + // Wait for async context resolution + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(consumer.contextValue).to.equal('test-context-value'); + }); + + it('does not provide context after host is destroyed', async () => { + const wrapper = await fixture(html` +
+ +
+ `); + + const host = new UmbElementControllerHost(wrapper); + host.hostConnected(); + new UmbContextProviderController(host, 'test-context', new UmbTestContext()); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + const consumer = wrapper.querySelector( + 'test-element-controller-host-consumer', + ) as UmbTestConsumerElement; + expect(consumer.contextValue).to.equal('test-context-value'); + + // Destroy and add a new consumer + host.destroy(); + + const newConsumer = document.createElement( + 'test-element-controller-host-consumer', + ) as UmbTestConsumerElement; + wrapper.appendChild(newConsumer); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(newConsumer.contextValue).to.be.undefined; + }); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/libs/controller-api/element-controller-host.ts b/src/Umbraco.Web.UI.Client/src/libs/controller-api/element-controller-host.ts new file mode 100644 index 000000000000..8ec60bc958a2 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/libs/controller-api/element-controller-host.ts @@ -0,0 +1,24 @@ +import { UmbControllerHostMixin } from './controller-host.mixin.js'; + +/** + * A controller host backed by a DOM element. + * Enables providing contexts and hosting controllers on elements that are not UmbLitElements. + * @class UmbElementControllerHost + */ +export class UmbElementControllerHost extends UmbControllerHostMixin(class {}) { + #element?: Element; + + constructor(element: Element) { + super(); + this.#element = element; + } + + getHostElement(): Element { + return this.#element!; + } + + override destroy(): void { + super.destroy(); + this.#element = undefined; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/libs/controller-api/index.ts b/src/Umbraco.Web.UI.Client/src/libs/controller-api/index.ts index 1f0f8099b6da..b271cd8ef8be 100644 --- a/src/Umbraco.Web.UI.Client/src/libs/controller-api/index.ts +++ b/src/Umbraco.Web.UI.Client/src/libs/controller-api/index.ts @@ -2,6 +2,7 @@ export * from './controller-host.mixin.js'; export type * from './controller-host.interface.js'; export type * from './controller-host-element.interface.js'; export * from './controller-host-element.mixin.js'; +export * from './element-controller-host.js'; export type * from './controller.interface.js'; export type * from './controller-alias.type.js'; export * from './controller.event.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/collection-view.manager.ts b/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/collection-view.manager.ts index b6b7a0159783..12daec279de3 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/collection-view.manager.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/collection-view.manager.ts @@ -1,5 +1,6 @@ import type { UmbCollectionLayoutConfiguration } from '../types.js'; import type { ManifestCollectionView } from './collection-view.extension.js'; +import type { UmbCollectionViewElementBase } from './umb-collection-view-element-base.js'; import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; import { UmbExtensionsManifestInitializer, createExtensionElement } from '@umbraco-cms/backoffice/extension-api'; import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; @@ -101,7 +102,8 @@ export class UmbCollectionViewManager extends UmbControllerBase { return { path: `${view.meta.pathName}`, component: () => createExtensionElement(view), - setup: () => { + setup: (component) => { + (component as UmbCollectionViewElementBase).manifest = view; this.setCurrentView(view); }, }; @@ -112,7 +114,8 @@ export class UmbCollectionViewManager extends UmbControllerBase { unique: fallbackView.alias, path: '', component: () => createExtensionElement(fallbackView), - setup: () => { + setup: (component) => { + (component as UmbCollectionViewElementBase).manifest = fallbackView; this.setCurrentView(fallbackView); }, }); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/manifests.ts index fe4f150180e6..3c8b7cd7edb4 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/manifests.ts @@ -1,5 +1,10 @@ import { manifests as cardManifests } from './card/manifests.js'; import { manifests as refManifests } from './ref/manifests.js'; +import { manifests as tableManifests } from './table/manifests.js'; import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension-registry'; -export const manifests: Array = [...cardManifests, ...refManifests]; +export const manifests: Array = [ + ...cardManifests, + ...refManifests, + ...tableManifests, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/table/entity-name-table-column-layout.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/table/entity-name-table-column-layout.element.ts new file mode 100644 index 000000000000..b7ca61eab8ed --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/table/entity-name-table-column-layout.element.ts @@ -0,0 +1,40 @@ +import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; +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'; + +interface UmbEntityNameTableColumnValue { + name: string; + href?: string; +} + +/** + * Table column layout element that renders an entity name, optionally as a link. + * @element umb-entity-name-table-column-layout + */ +@customElement('umb-entity-name-table-column-layout') +export class UmbEntityNameTableColumnLayoutElement extends UmbLitElement implements UmbTableColumnLayoutElement { + column!: UmbTableColumn; + item!: UmbTableItem; + + @property({ attribute: false }) + value!: UmbEntityNameTableColumnValue; + + override render() { + if (!this.value) return nothing; + + if (this.value.href) { + return html`${this.value.name}`; + } + + return html`${this.value.name}`; + } + + static override styles = [UmbTextStyles]; +} + +declare global { + interface HTMLElementTagNameMap { + 'umb-entity-name-table-column-layout': UmbEntityNameTableColumnLayoutElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/table/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/table/manifests.ts new file mode 100644 index 000000000000..9b051834fef1 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/table/manifests.ts @@ -0,0 +1,21 @@ +import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension-registry'; + +export const manifests: Array = [ + { + type: 'kind', + alias: 'Umb.Kind.CollectionView.Table', + matchKind: 'table', + matchType: 'collectionView', + manifest: { + type: 'collectionView', + kind: 'table', + element: () => import('./table-collection-view.element.js'), + weight: 1000, + meta: { + label: 'Table', + icon: 'icon-table', + pathName: 'table', + }, + }, + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/table/table-collection-view.element.test.ts b/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/table/table-collection-view.element.test.ts new file mode 100644 index 000000000000..b2cd22c21ef9 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/table/table-collection-view.element.test.ts @@ -0,0 +1,233 @@ +import './table-collection-view.element.js'; +import type { UmbTableCollectionViewElement } from './table-collection-view.element.js'; +import type { ManifestCollectionViewTableKind } from './types.js'; +import { UmbDefaultCollectionContext } from '../../default/collection-default.context.js'; +import type { UmbCollectionItemModel } from '../../types.js'; +import type { UmbTableElement } from '@umbraco-cms/backoffice/components'; +import { expect, fixture, html, aTimeout } from '@open-wc/testing'; +import { customElement } from '@umbraco-cms/backoffice/external/lit'; +import { UmbElementMixin } from '@umbraco-cms/backoffice/element-api'; +import { UMB_ENTITY_CONTEXT, type UmbEntityContext } from '@umbraco-cms/backoffice/entity'; +import { UmbContextConsumerController } from '@umbraco-cms/backoffice/context-api'; +import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api'; + +// Host element that provides a UmbDefaultCollectionContext so the view can consume it. +@customElement('test-collection-host') +class UmbTestCollectionHostElement extends UmbElementMixin(HTMLElement) { + collectionContext = new UmbDefaultCollectionContext(this, ''); + + constructor() { + super(); + // Configure selection so _selectable is true in the view + this.collectionContext.selection.setSelectable(true); + this.collectionContext.selection.setMultiple(true); + } +} + +// Consumer element that consumes UMB_ENTITY_CONTEXT from its ancestor. +@customElement('test-entity-context-consumer') +class UmbTestEntityContextConsumerElement extends UmbControllerHostElementMixin(HTMLElement) { + public entityType?: string; + public unique?: string | null; + + constructor() { + super(); + new UmbContextConsumerController(this, UMB_ENTITY_CONTEXT, (context) => { + this.entityType = context?.getEntityType(); + this.unique = context?.getUnique(); + }); + } +} + +interface TestCollectionItemModel extends UmbCollectionItemModel { + name: string; + icon: string; + status?: string; +} + +function makeItem(unique: string, overrides: Partial = {}): TestCollectionItemModel { + return { + unique, + entityType: 'test-entity', + name: `Item ${unique}`, + icon: 'icon-test', + ...overrides, + }; +} + +function makeManifest(columns: ManifestCollectionViewTableKind['meta']['columns'] = []): ManifestCollectionViewTableKind { + return { + type: 'collectionView', + kind: 'table', + alias: 'Test.CollectionView.Table', + name: 'Test Table Collection View', + meta: { + columns, + }, + }; +} + +describe('UmbTableCollectionViewElement', () => { + let hostElement: UmbTestCollectionHostElement; + let element: UmbTableCollectionViewElement; + + beforeEach(async () => { + hostElement = await fixture(html` + + + + `); + element = hostElement.querySelector('umb-table-collection-view')!; + await aTimeout(0); + }); + + /** + * Sets items directly on the collection context's internal state. + * NOTE: This is not ideal as it bypasses the public API of the context. + * This is a temporary workaround until we better support mocked data in tests. + */ + function setCollectionItems(items: Array) { + hostElement.collectionContext['_items'].setValue(items); + } + + function getTable(): UmbTableElement { + return element.shadowRoot!.querySelector('umb-table')!; + } + + describe('column construction from manifest', () => { + it('renders name and entity actions columns by default', async () => { + element.manifest = makeManifest(); + setCollectionItems([makeItem('1')]); + await aTimeout(0); + + const table = getTable(); + expect(table).to.not.be.null; + + const aliases = table.columns.map((c) => c.alias); + expect(aliases).to.include('name'); + expect(aliases).to.include('entityActions'); + }); + + it('includes manifest-defined columns', async () => { + element.manifest = makeManifest([ + { label: 'Status', field: 'status' }, + ]); + setCollectionItems([makeItem('1', { status: 'Published' })]); + await aTimeout(0); + + const columns = getTable().columns; + expect(columns.map((c) => c.alias)).to.include('status'); + }); + + it('maps manifest column values to table row data', async () => { + element.manifest = makeManifest([ + { label: 'Status', field: 'status' }, + ]); + setCollectionItems([makeItem('1', { status: 'Draft' })]); + await aTimeout(0); + + const items = getTable().items; + const statusCell = items[0].data.find((d) => d.columnAlias === 'status'); + expect(statusCell?.value).to.equal('Draft'); + }); + }); + + describe('dynamic description column', () => { + it('does not include description column when no items have descriptions', async () => { + element.manifest = makeManifest(); + setCollectionItems([makeItem('1'), makeItem('2')]); + await aTimeout(0); + + const columns = getTable().columns; + expect(columns.map((c) => c.alias)).to.not.include('description'); + }); + + it('includes description column when at least one item has a description', async () => { + element.manifest = makeManifest(); + const items = [ + makeItem('1'), + { ...makeItem('2'), description: 'Has a description' }, + ]; + setCollectionItems(items as any); + await aTimeout(0); + + const columns = getTable().columns; + expect(columns.map((c) => c.alias)).to.include('description'); + }); + }); + + describe('entity context per row', () => { + it('provides UMB_ENTITY_CONTEXT with correct entity type and unique per row element', async () => { + element.manifest = makeManifest(); + setCollectionItems([makeItem('item-1', { entityType: 'doc' })]); + await aTimeout(0); + + // The collection view binds an onRowRendered callback to the table. + // Invoke it directly with a test element to verify it provides entity context, + // avoiding queries into umb-table's shadow DOM internals. + const onRowRendered = getTable().onRowRendered; + expect(onRowRendered).to.not.be.undefined; + + const testRow = document.createElement('div'); + document.body.appendChild(testRow); + onRowRendered!(testRow, { id: 'test-item', entityType: 'media', data: [] }); + await aTimeout(0); + + const consumer = new UmbTestEntityContextConsumerElement(); + testRow.appendChild(consumer); + await aTimeout(0); + + expect(consumer.entityType).to.equal('media'); + expect(consumer.unique).to.equal('test-item'); + + consumer.remove(); + testRow.remove(); + }); + + it('cleans up row contexts for removed items', async () => { + element.manifest = makeManifest(); + setCollectionItems([makeItem('1'), makeItem('2'), makeItem('3')]); + await aTimeout(0); + + // Reduce items to just one + setCollectionItems([makeItem('2')]); + await aTimeout(0); + + const items = getTable().items; + expect(items.length).to.equal(1); + expect(items[0].id).to.equal('2'); + }); + }); + + describe('selection delegation', () => { + it('marks items as selectable when collection context allows selection', async () => { + element.manifest = makeManifest(); + hostElement.collectionContext.selection.setSelectable(true); + setCollectionItems([makeItem('1')]); + await aTimeout(0); + + const config = getTable().config; + expect(config.allowSelection).to.be.true; + }); + + it('sets table items as selectable based on context', async () => { + element.manifest = makeManifest(); + hostElement.collectionContext.selection.setSelectable(true); + setCollectionItems([makeItem('1')]); + await aTimeout(0); + + const items = getTable().items; + expect(items[0].selectable).to.be.true; + }); + + it('passes selection array to the table', async () => { + element.manifest = makeManifest(); + hostElement.collectionContext.selection.setSelectable(true); + setCollectionItems([makeItem('1'), makeItem('2')]); + hostElement.collectionContext.selection.select('1'); + await aTimeout(0); + + expect(getTable().selection).to.include('1'); + }); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/table/table-collection-view.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/table/table-collection-view.element.ts new file mode 100644 index 000000000000..9a752846dfc3 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/table/table-collection-view.element.ts @@ -0,0 +1,226 @@ +import { UmbCollectionViewElementBase } from '../umb-collection-view-element-base.js'; +import type { UmbCollectionItemModel } from '../../types.js'; +import type { ManifestCollectionViewTableKind, MetaCollectionViewTableKindColumn } from './types.js'; +import { css, customElement, html, nothing, state, type PropertyValues } from '@umbraco-cms/backoffice/external/lit'; +import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; +import type { + UmbTableSelectedEvent, + UmbTableElement, + UmbTableDeselectedEvent, + UmbTableItem, + UmbTableColumn, +} from '@umbraco-cms/backoffice/components'; +import type { UmbWithOptionalDescriptionModel } from '@umbraco-cms/backoffice/models'; +import { UmbElementControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbEntityContext } from '@umbraco-cms/backoffice/entity'; + +import './entity-name-table-column-layout.element.js'; + +/** + * A table-based collection view element provided by the `table` kind. + * Renders collection items as table rows with manifest-driven columns, entity context per row, and entity actions. + * @element umb-table-collection-view + */ +@customElement('umb-table-collection-view') +export class UmbTableCollectionViewElement extends UmbCollectionViewElementBase< + UmbCollectionItemModel, + ManifestCollectionViewTableKind +> { + @state() + private _tableColumns: Array = []; + + @state() + private _tableRows: Array = []; + + get #manifestColumns(): Array { + return this.manifest?.meta?.columns ?? []; + } + + #hasDescriptions = false; + + #rowContexts = new Map(); + + #onRowRendered = (element: HTMLElement | undefined, item: UmbTableItem) => { + if (!element) { + const existing = this.#rowContexts.get(item.id); + if (existing) { + 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); + return; + } + + const host = new UmbElementControllerHost(element); + host.hostConnected(); + const entityContext = new UmbEntityContext(host); + entityContext.setEntityType(item.entityType); + entityContext.setUnique(item.id); + + this.#rowContexts.set(item.id, { host, entityContext }); + }; + + #buildTableColumns() { + const nameColumn: UmbTableColumn = { + name: this.localize.term('general_name'), + alias: 'name', + elementName: 'umb-entity-name-table-column-layout', + }; + + const manifestColumns: Array = 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', + }; + + const descriptionColumn: UmbTableColumn = { + name: this.localize.term('general_description'), + alias: 'description', + }; + + this._tableColumns = [ + nameColumn, + ...(this.#hasDescriptions ? [descriptionColumn] : []), + ...manifestColumns, + entityActionsColumn, + ]; + } + + override updated(changedProperties: PropertyValues) { + if (changedProperties.has('_items') || changedProperties.has('_itemHrefs') || changedProperties.has('manifest')) { + this.#createTableRows(); + } + } + + #createTableRows() { + this.#hasDescriptions = this._items.some( + (item) => ((item as unknown as UmbWithOptionalDescriptionModel).description ?? '').length > 0, + ); + this.#buildTableColumns(); + + this._tableRows = this._items.map((item) => { + const href = item.unique ? this._itemHrefs.get(item.unique) : undefined; + + const manifestColumnData = this.#manifestColumns.map((col) => ({ + columnAlias: col.field, + value: (item as unknown as Record)[col.field], + })); + + return { + id: item.unique, + icon: item.icon, + entityType: item.entityType, + selectable: this._isSelectableItem(item), + data: [ + { + columnAlias: 'name', + value: { name: item.name, href }, + }, + ...(this.#hasDescriptions + ? [ + { + columnAlias: 'description', + value: (item as unknown as UmbWithOptionalDescriptionModel).description ?? '', + }, + ] + : []), + ...manifestColumnData, + { + columnAlias: 'entityActions', + value: { name: item.name }, + }, + ], + }; + }); + + // Clean up row contexts for items that no longer exist + const currentIds = new Set(this._tableRows.map((row) => row.id)); + for (const [id, ctx] of this.#rowContexts) { + if (!currentIds.has(id)) { + ctx.host.destroy(); + this.#rowContexts.delete(id); + } + } + } + + #onSelected(event: UmbTableSelectedEvent) { + event.stopPropagation(); + const itemId = event.getItemId(); + + // We get the same event for both single and multiple selection. + if (itemId) { + this._selectItem(itemId); + } else { + const target = event.target as UmbTableElement; + this._setSelection(target.selection); + } + } + + #onDeselected(event: UmbTableDeselectedEvent) { + event.stopPropagation(); + const itemId = event.getItemId(); + + // We get the same event for both single and multiple deselection. + if (itemId) { + this._deselectItem(itemId); + } else { + const target = event.target as UmbTableElement; + this._setSelection(target.selection); + } + } + + override disconnectedCallback() { + super.disconnectedCallback(); + for (const [, ctx] of this.#rowContexts) { + ctx.host.destroy(); + } + this.#rowContexts.clear(); + } + + override render() { + if (this._loading) return nothing; + return html` + + `; + } + + static override styles = [ + UmbTextStyles, + css` + :host { + display: block; + } + `, + ]; +} + +export { UmbTableCollectionViewElement as element }; + +declare global { + interface HTMLElementTagNameMap { + 'umb-table-collection-view': UmbTableCollectionViewElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/table/types.ts b/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/table/types.ts new file mode 100644 index 000000000000..f8b86fec4796 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/table/types.ts @@ -0,0 +1,35 @@ +import type { ManifestCollectionView, MetaCollectionView } from '../collection-view.extension.js'; + +/** + * Manifest type for the `table` kind of `collectionView`. + * Allows registering a table collection view with custom columns via manifest meta. + */ +export interface ManifestCollectionViewTableKind extends Omit { + type: 'collectionView'; + kind: 'table'; + meta: MetaCollectionViewTableKind; +} + +/** + * Configuration for a single column in a table collection view kind. + */ +export interface MetaCollectionViewTableKindColumn { + /** The property name on the collection item model to display in this column. */ + field: string; + /** The column header label. Supports localization strings (e.g. `#general_status`). */ + label: string; +} + +/** + * Meta configuration for the `table` kind of `collectionView`. + */ +export interface MetaCollectionViewTableKind extends Partial { + /** Additional columns to render between the name and entity actions columns. */ + columns?: Array; +} + +declare global { + interface UmbExtensionManifestMap { + umbCollectionViewTableKind: ManifestCollectionViewTableKind; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/types.ts b/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/types.ts index a508a7ab213e..bb452d50bb71 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/types.ts @@ -1 +1,2 @@ export type * from './collection-view.extension.js'; +export type * from './table/types.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/umb-collection-view-element-base.ts b/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/umb-collection-view-element-base.ts index b9849f5295cf..0d2704e43468 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/umb-collection-view-element-base.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/umb-collection-view-element-base.ts @@ -1,6 +1,6 @@ import { UMB_COLLECTION_CONTEXT } from '../default/index.js'; -import type { UmbCollectionItemModel } from '../types.js'; -import { state } from '@umbraco-cms/backoffice/external/lit'; +import type { ManifestCollectionView, UmbCollectionItemModel } from '../types.js'; +import { property, state } from '@umbraco-cms/backoffice/external/lit'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; /** @@ -9,7 +9,11 @@ import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; */ export abstract class UmbCollectionViewElementBase< CollectionItemType extends UmbCollectionItemModel = UmbCollectionItemModel, + ManifestType extends Omit = ManifestCollectionView, > extends UmbLitElement { + @property({ attribute: false }) + manifest?: ManifestType | undefined; + @state() protected _items: Array = []; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/components/table/table.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/components/table/table.element.ts index 3ed8af1daced..c0e10d8cb867 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/components/table/table.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/components/table/table.element.ts @@ -5,6 +5,7 @@ import { html, ifDefined, property, + ref, repeat, state, when, @@ -148,6 +149,9 @@ export class UmbTableElement extends UmbLitElement { @property({ type: Array, attribute: false }) public selection: Array = []; + @property({ attribute: false }) + public onRowRendered?: (element: HTMLElement | undefined, item: UmbTableItem) => void; + @property({ type: String, attribute: false }) public orderingColumn = ''; @@ -334,6 +338,9 @@ export class UmbTableElement extends UmbLitElement { const isItemSelectable = this.#isSelectableItem(item); return html` { + this.onRowRendered?.(el as HTMLElement | undefined, item); + })} data-sortable-id=${item.id} ?selectable=${this.config.allowSelection && !this._sortable && isItemSelectable} ?select-only=${this._selectionMode || this.config.selectOnly} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/global-components/entity-actions-table-column-view/entity-actions-table-column-view.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/global-components/entity-actions-table-column-view/entity-actions-table-column-view.element.ts index 925ccd4ca98d..47a97356af44 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/global-components/entity-actions-table-column-view/entity-actions-table-column-view.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/global-components/entity-actions-table-column-view/entity-actions-table-column-view.element.ts @@ -1,20 +1,46 @@ import type { UmbEntityModel, UmbNamedEntityModel } from '@umbraco-cms/backoffice/entity'; -import { html, nothing, customElement, property } from '@umbraco-cms/backoffice/external/lit'; +import { html, customElement, property } from '@umbraco-cms/backoffice/external/lit'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { UmbDeprecation } from '@umbraco-cms/backoffice/utils'; +import type { UmbTableColumn, UmbTableColumnLayoutElement, UmbTableItem } from '@umbraco-cms/backoffice/components'; + +type UmbEntityActionsTableColumnValue = UmbEntityModel | UmbNamedEntityModel | { name?: string }; + +const deprecation = new UmbDeprecation({ + deprecated: 'Passing `entityType` and `unique` via the `value` property on ``.', + removeInVersion: '19', + solution: 'Provide the entity type and unique via the UMB_ENTITY_CONTEXT context instead.', +}); @customElement('umb-entity-actions-table-column-view') -export class UmbEntityActionsTableColumnViewElement extends UmbLitElement { +export class UmbEntityActionsTableColumnViewElement extends UmbLitElement implements UmbTableColumnLayoutElement { + column!: UmbTableColumn; + item!: UmbTableItem; + @property({ attribute: false }) - value?: UmbEntityModel | UmbNamedEntityModel; + get value(): UmbEntityActionsTableColumnValue { + return this.#value; + } + set value(newValue: UmbEntityActionsTableColumnValue) { + const oldValue = this.#value; + this.#value = newValue ?? {}; + if ('entityType' in this.#value || 'unique' in this.#value) { + deprecation.warn(); + } + this.requestUpdate('value', oldValue); + } + #value: UmbEntityActionsTableColumnValue = {}; override render() { - if (!this.value) return nothing; + // TODO (v19): Remove deprecated property forwarding when entityType/unique on value is removed. + const entityType = 'entityType' in this.value ? this.value.entityType : undefined; + const unique = 'unique' in this.value ? this.value.unique : undefined; return html` + .entityType=${entityType} + .unique=${unique} + .label=${this.localize.string((this.value as UmbNamedEntityModel)?.name)}> `; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/views/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/views/manifests.ts index 1ec568bb59a0..6c22fcb0787f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/views/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/views/manifests.ts @@ -1,3 +1,4 @@ +import { UMB_USER_COLLECTION_ALIAS } from '../constants.js'; import { UMB_COLLECTION_VIEW_USER_GRID, UMB_COLLECTION_VIEW_USER_TABLE } from './constants.js'; import { UMB_COLLECTION_ALIAS_CONDITION } from '@umbraco-cms/backoffice/collection'; @@ -15,7 +16,7 @@ export const manifests: Array = [ conditions: [ { alias: UMB_COLLECTION_ALIAS_CONDITION, - match: 'Umb.Collection.User', + match: UMB_USER_COLLECTION_ALIAS, }, ], }, @@ -27,7 +28,7 @@ export const manifests: Array = [ conditions: [ { alias: UMB_COLLECTION_ALIAS_CONDITION, - match: 'Umb.Collection.User', + match: UMB_USER_COLLECTION_ALIAS, }, ], },