diff --git a/src/Umbraco.Web.UI.Client/eslint.config.js b/src/Umbraco.Web.UI.Client/eslint.config.js index d4cd670cc84a..302b8bcf9078 100644 --- a/src/Umbraco.Web.UI.Client/eslint.config.js +++ b/src/Umbraco.Web.UI.Client/eslint.config.js @@ -86,10 +86,9 @@ export default [ ...importPlugin.flatConfigs.typescript, rules: { 'no-unused-vars': 'off', //Let '@typescript-eslint/no-unused-vars' catch the errors to allow unused function parameters (ex: in interfaces) - '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], '@typescript-eslint/no-non-null-assertion': 'off', '@typescript-eslint/no-explicit-any': 'warn', - '@typescript-eslint/no-unused-vars': 'error', '@typescript-eslint/consistent-type-exports': 'error', '@typescript-eslint/consistent-type-imports': 'error', '@typescript-eslint/no-import-type-side-effects': 'warn', @@ -154,7 +153,7 @@ export default [ selector: 'typeLike', modifiers: ['exported'], format: ['PascalCase'], - prefix: ['Umb', 'Ufm', 'Manifest', 'Meta', 'Example'] + prefix: ['Umb', 'Ufm', 'Manifest', 'Meta', 'Example'], }, // All exported string constants should be UPPER_CASE with leading 'UMB_' // Example: UMB_EXAMPLE_CONSTANT @@ -167,8 +166,8 @@ export default [ }, // Allow destructured variables to be named as they are in the object { - selector: "variable", - modifiers: ["destructured"], + selector: 'variable', + modifiers: ['destructured'], format: null, }, ], diff --git a/src/Umbraco.Web.UI.Client/examples/collection/collection/card-view/collection-view.element.ts b/src/Umbraco.Web.UI.Client/examples/collection/collection/card-view/collection-view.element.ts deleted file mode 100644 index 64a49f207366..000000000000 --- a/src/Umbraco.Web.UI.Client/examples/collection/collection/card-view/collection-view.element.ts +++ /dev/null @@ -1,82 +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 { css, html, customElement, state, repeat } from '@umbraco-cms/backoffice/external/lit'; -import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; -import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; - -@customElement('example-card-collection-view') -export class ExampleCardCollectionViewElement extends UmbLitElement { - @state() - private _items: Array = []; - - #collectionContext?: UmbDefaultCollectionContext; - - constructor() { - super(); - - this.consumeContext(UMB_COLLECTION_CONTEXT, (instance) => { - this.#collectionContext = instance; - this.#observeCollectionItems(); - }); - } - - #observeCollectionItems() { - this.observe(this.#collectionContext?.items, (items) => (this._items = items || []), 'umbCollectionItemsObserver'); - } - - override render() { - return html` -
- ${repeat( - this._items, - (item) => item.unique, - (item) => - html` - -
${item.name}
-
`, - )} -
- `; - } - - static override styles = [ - UmbTextStyles, - css` - :host { - display: flex; - flex-direction: column; - } - - #card-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - grid-auto-rows: 200px; - gap: var(--uui-size-space-5); - } - - uui-card { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - text-align: center; - height: 100%; - - uui-icon { - font-size: 2em; - margin-bottom: var(--uui-size-space-4); - } - } - `, - ]; -} - -export { ExampleCardCollectionViewElement as element }; - -declare global { - interface HTMLElementTagNameMap { - 'example-card-collection-view': ExampleCardCollectionViewElement; - } -} diff --git a/src/Umbraco.Web.UI.Client/examples/collection/collection/card-view/manifests.ts b/src/Umbraco.Web.UI.Client/examples/collection/collection/card-view/manifests.ts index bf7299c5308d..036f43689601 100644 --- a/src/Umbraco.Web.UI.Client/examples/collection/collection/card-view/manifests.ts +++ b/src/Umbraco.Web.UI.Client/examples/collection/collection/card-view/manifests.ts @@ -4,15 +4,9 @@ import { UMB_COLLECTION_ALIAS_CONDITION } from '@umbraco-cms/backoffice/collecti export const manifests: Array = [ { type: 'collectionView', + kind: 'card', alias: 'Example.CollectionView.Card', name: 'Example Card Collection View', - js: () => import('./collection-view.element.js'), - weight: 50, - meta: { - label: 'Card', - icon: 'icon-grid', - pathName: 'card', - }, conditions: [ { alias: UMB_COLLECTION_ALIAS_CONDITION, diff --git a/src/Umbraco.Web.UI.Client/examples/collection/collection/manifests.ts b/src/Umbraco.Web.UI.Client/examples/collection/collection/manifests.ts index eb8b44d061e8..bd1161dbe4ba 100644 --- a/src/Umbraco.Web.UI.Client/examples/collection/collection/manifests.ts +++ b/src/Umbraco.Web.UI.Client/examples/collection/collection/manifests.ts @@ -1,6 +1,7 @@ import { EXAMPLE_COLLECTION_ALIAS } from './constants.js'; import { EXAMPLE_COLLECTION_REPOSITORY_ALIAS } from './repository/constants.js'; import { manifests as cardViewManifests } from './card-view/manifests.js'; +import { manifests as refViewManifests } from './ref-view/manifests.js'; import { manifests as repositoryManifests } from './repository/manifests.js'; import { manifests as tableViewManifests } from './table-view/manifests.js'; @@ -15,6 +16,7 @@ export const manifests: Array = [ }, }, ...cardViewManifests, + ...refViewManifests, ...repositoryManifests, ...tableViewManifests, ]; diff --git a/src/Umbraco.Web.UI.Client/examples/collection/collection/ref-view/manifests.ts b/src/Umbraco.Web.UI.Client/examples/collection/collection/ref-view/manifests.ts new file mode 100644 index 000000000000..f8886aa3e64b --- /dev/null +++ b/src/Umbraco.Web.UI.Client/examples/collection/collection/ref-view/manifests.ts @@ -0,0 +1,17 @@ +import { EXAMPLE_COLLECTION_ALIAS } from '../constants.js'; +import { UMB_COLLECTION_ALIAS_CONDITION } from '@umbraco-cms/backoffice/collection'; + +export const manifests: Array = [ + { + type: 'collectionView', + kind: 'ref', + alias: 'Example.CollectionView.Ref', + name: 'Example Ref Collection View', + conditions: [ + { + alias: UMB_COLLECTION_ALIAS_CONDITION, + match: EXAMPLE_COLLECTION_ALIAS, + }, + ], + }, +]; 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 ecd8a1495cf7..a6d7e9879849 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 @@ -16,26 +16,31 @@ export class ExampleCollectionRepository unique: '3e31e9c5-7d66-4c99-a9e5-d9f2b1e2b22f', entityType: 'example', name: 'Example Item 1', + icon: 'icon-newspaper', }, { unique: 'bc9b6e24-4b11-4dd6-8d4e-7c4f70e59f3c', entityType: 'example', name: 'Example Item 2', + icon: 'icon-newspaper', }, { unique: '5a2f4e3a-ef7e-470e-8c3c-3d859c02ae0d', entityType: 'example', name: 'Example Item 3', + icon: 'icon-newspaper', }, { unique: 'f4c3d8b8-6d79-4c87-9aa9-56b1d8fda702', entityType: 'example', name: 'Example Item 4', + icon: 'icon-newspaper', }, { unique: 'c9f0a8a3-1b49-4724-bde3-70e31592eb6e', entityType: 'example', name: 'Example Item 5', + icon: 'icon-newspaper', }, ]; 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 4430638c1697..3ee3f404026d 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 @@ -4,6 +4,7 @@ export interface ExampleCollectionItemModel { unique: string; entityType: string; name: string; + icon: 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 index 530ee846ddbc..2f466531232e 100644 --- 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 @@ -52,7 +52,7 @@ export class ExampleTableCollectionViewElement extends UmbLitElement { this._tableItems = items.map((item) => { return { id: item.unique, - icon: 'icon-newspaper', + icon: item.icon, data: [ { columnAlias: 'name', diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/collection/bulk-action/collection-bulk-action.manager.test.ts b/src/Umbraco.Web.UI.Client/src/packages/core/collection/bulk-action/collection-bulk-action.manager.test.ts new file mode 100644 index 000000000000..bed586483d65 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/bulk-action/collection-bulk-action.manager.test.ts @@ -0,0 +1,72 @@ +import { UmbCollectionBulkActionManager } from './collection-bulk-action.manager.js'; +import { umbExtensionsRegistry } from '../../extension-registry/index.js'; +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('test-bulk-action-controller-host') +class UmbTestControllerHostElement extends UmbControllerHostElementMixin(HTMLElement) {} + +const bulkActionManifests: Array = [ + { + type: 'entityBulkAction', + alias: 'Umb.Test.EntityBulkAction.1', + name: 'Test Entity Bulk Action 1', + forEntityTypes: ['test-entity'], + }, +]; + +describe('UmbCollectionBulkActionManager', () => { + let hostElement: UmbTestControllerHostElement; + let manager: UmbCollectionBulkActionManager; + + beforeEach(() => { + hostElement = new UmbTestControllerHostElement(); + manager = new UmbCollectionBulkActionManager(hostElement); + }); + + afterEach(() => { + manager.destroy(); + hostElement.destroy(); + }); + + describe('Public API', () => { + describe('properties', () => { + it('has a hasBulkActions property', () => { + expect(manager).to.have.property('hasBulkActions').to.be.an.instanceOf(Observable); + }); + }); + }); + + describe('hasBulkActions', () => { + afterEach(() => { + umbExtensionsRegistry.clear(); + }); + + it('it emits false if there are no actions', (done) => { + manager.hasBulkActions.subscribe((value) => { + expect(value).to.equal(false); + done(); + }); + }); + + it('it emits true if there are actions', (done) => { + let isFirstValue = true; + + // First, we need to add bulk action manifests to the registry + umbExtensionsRegistry.registerMany(bulkActionManifests); + + manager.hasBulkActions.subscribe((value) => { + if (isFirstValue) { + // Skip the first emission which is false + isFirstValue = false; + return; + } + + expect(value).to.equal(true); + done(); + }); + }); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/collection/bulk-action/collection-bulk-action.manager.ts b/src/Umbraco.Web.UI.Client/src/packages/core/collection/bulk-action/collection-bulk-action.manager.ts new file mode 100644 index 000000000000..b7b3282ec4bd --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/bulk-action/collection-bulk-action.manager.ts @@ -0,0 +1,39 @@ +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 { UmbBooleanState } from '@umbraco-cms/backoffice/observable-api'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; + +/** + * Manager responsible for tracking the availability of bulk actions in a collection. + */ +export class UmbCollectionBulkActionManager extends UmbControllerBase { + #hasBulkActions = new UmbBooleanState(undefined); + + /** + * Observable that emits `true` if bulk actions are available, `false` if none are registered, + * or `undefined` if the state has not yet been determined. + */ + public readonly hasBulkActions = this.#hasBulkActions.asObservable(); + + /** + * Creates a new instance of the bulk action manager. + * @param {UmbControllerHost} host - The controller host that owns this manager. + */ + constructor(host: UmbControllerHost) { + super(host); + this.#observeBulkActions(); + } + + #observeBulkActions() { + new UmbExtensionsManifestInitializer( + this, + umbExtensionsRegistry, + 'entityBulkAction', + null, + (bulkActionControllers) => { + this.#hasBulkActions.setValue(bulkActionControllers.length > 0); + }, + ); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/collection/components/collection-view-bundle.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/collection/components/collection-view-bundle.element.ts index c9e115dc9302..fc61f6cb8c92 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/collection/components/collection-view-bundle.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/components/collection-view-bundle.element.ts @@ -1,5 +1,5 @@ import { UMB_COLLECTION_CONTEXT } from '../default/index.js'; -import type { ManifestCollectionView } from '../extensions/types.js'; +import type { ManifestCollectionView } from '../view/types.js'; import type { UmbCollectionLayoutConfiguration } from '../types.js'; import { css, customElement, html, nothing, query, repeat, state } from '@umbraco-cms/backoffice/external/lit'; import { observeMultiple } from '@umbraco-cms/backoffice/observable-api'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/collection/default/collection-default.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/collection/default/collection-default.context.ts index cb514a6e5075..58fbbab84147 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/collection/default/collection-default.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/default/collection-default.context.ts @@ -1,5 +1,5 @@ -import { UmbCollectionViewManager } from '../collection-view.manager.js'; -import type { UmbCollectionViewManagerConfig } from '../collection-view.manager.js'; +import { UmbCollectionViewManager } from '../view/collection-view.manager.js'; +import type { UmbCollectionViewManagerConfig } from '../view/collection-view.manager.js'; import type { UmbCollectionColumnConfiguration, UmbCollectionConfiguration, @@ -9,6 +9,7 @@ import type { import type { UmbCollectionFilterModel } from '../collection-filter-model.interface.js'; import type { UmbCollectionRepository } from '../repository/collection-repository.interface.js'; import type { ManifestCollection } from '../extensions/types.js'; +import { UmbCollectionBulkActionManager } from '../bulk-action/collection-bulk-action.manager.js'; import { UMB_COLLECTION_CONTEXT } from './collection-default.context-token.js'; import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; import { UmbArrayState, UmbBasicState, UmbNumberState, UmbObjectState } from '@umbraco-cms/backoffice/observable-api'; @@ -67,6 +68,7 @@ export class UmbDefaultCollectionContext< public readonly pagination = new UmbPaginationManager(); public readonly selection = new UmbSelectionManager(this); public readonly view = new UmbCollectionViewManager(this); + public readonly bulkAction = new UmbCollectionBulkActionManager(this); #defaultViewAlias: string; #defaultFilter: Partial; @@ -163,7 +165,7 @@ export class UmbDefaultCollectionContext< protected _configure() { if (!this.#config) return; - this.selection.setMultiple(true); + this.#configureSelection(); if (this.#config.pageSize) { this.pagination.setPageSize(this.#config.pageSize); @@ -194,6 +196,26 @@ export class UmbDefaultCollectionContext< this._configured = true; } + #configureSelection() { + // TODO: We need support a collection selection configuration here so ex. Pickers can turn on single and multi select and set a selection. + this.selection.setSelectable(false); + this.selection.setMultiple(false); + + // Observe bulk actions to enable selection when bulk actions are available + // Bulk Actions are an integrated part of a Collection so we handle it here instead of a configuration + this.observe( + this.bulkAction.hasBulkActions, + (hasBulkActions) => { + // Allow selection if there are bulk actions available + if (hasBulkActions) { + this.selection.setSelectable(true); + this.selection.setMultiple(true); + } + }, + 'umbCollectionHasBulkActionsObserver', + ); + } + #checkIfInitialized() { if (this._repository) { this.#initialized = true; @@ -358,7 +380,7 @@ export class UmbDefaultCollectionContext< /** * Sets the manifest for the collection. - * @param {ManifestCollection} manifest + * @param {ManifestCollection} manifest - The manifest for the collection. * @memberof UmbCollectionContext * @deprecated Use set the `.manifest` property instead. */ @@ -372,11 +394,11 @@ export class UmbDefaultCollectionContext< /** * Returns the manifest for the collection. - * @returns {ManifestCollection} + * @returns {ManifestCollection} - The manifest for the collection. * @memberof UmbCollectionContext * @deprecated Use the `.manifest` property instead. */ - public getManifest() { + public getManifest(): ManifestCollection | undefined { new UmbDeprecation({ removeInVersion: '18.0.0', deprecated: 'getManifest', @@ -389,7 +411,17 @@ export class UmbDefaultCollectionContext< * Returns the items in the collection. * @returns {Array} - The items in the collection. */ - public getItems() { + public getItems(): Array { return this._items.getValue(); } + + /** + * Returns the href for a specific collection item. + * Override this method in specialized collection contexts to provide item-specific hrefs. + * @param {CollectionItemType} _item - The collection item to get the href for. + * @returns {Promise} - Undefined. The collection item does not link to anything by default. + */ + public async requestItemHref(_item: CollectionItemType): Promise { + return undefined; + } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/collection/extensions/types.ts b/src/Umbraco.Web.UI.Client/src/packages/core/collection/extensions/types.ts index 4f8fa8382f83..f8326911a22a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/collection/extensions/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/extensions/types.ts @@ -1,3 +1,2 @@ export type * from './collection-action.extension.js'; -export type * from './collection-view.extension.js'; export type * from './collection.extension.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/collection/item/entity-collection-item-card/default-collection-item-card.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/collection/item/entity-collection-item-card/default-collection-item-card.element.ts index d4a1f4bd755e..f1d0ca9b2fc6 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/collection/item/entity-collection-item-card/default-collection-item-card.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/item/entity-collection-item-card/default-collection-item-card.element.ts @@ -49,7 +49,8 @@ export class UmbDefaultCollectionItemCardElement extends UmbLitElement implement ?selected=${this.selected} ?disabled=${this.disabled} @selected=${this.#onSelected} - @deselected=${this.#onDeselected}> + @deselected=${this.#onDeselected} + ?readonly=${!this.href}> ${this.#renderIcon(this.item)} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/collection/item/entity-collection-item-ref/default-collection-item-ref.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/collection/item/entity-collection-item-ref/default-collection-item-ref.element.ts index 1ec6db16279e..e529e9fd2fc1 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/collection/item/entity-collection-item-ref/default-collection-item-ref.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/item/entity-collection-item-ref/default-collection-item-ref.element.ts @@ -40,7 +40,7 @@ export class UmbDefaultCollectionItemRefElement extends UmbLitElement implements override render() { if (!this.item) return nothing; - return html` + href=${ifDefined(this.href)} + ?readonly=${!this.href}> ${this.#renderIcon(this.item)} `; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/collection/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/core/collection/manifests.ts index ee82008f7621..62f625fa4414 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/collection/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/manifests.ts @@ -3,6 +3,7 @@ import { manifests as actionManifests } from './action/manifests.js'; import { manifests as conditionManifests } from './conditions/manifests.js'; import { manifests as menuManifests } from './menu/manifests.js'; import { manifests as pickerModalManifests } from './collection-item-picker-modal/manifests.js'; +import { manifests as viewManifests } from './view/manifests.js'; import { manifests as workspaceViewManifests } from './workspace-view/manifests.js'; export const manifests: Array = [ @@ -10,5 +11,6 @@ export const manifests: Array = ...conditionManifests, ...menuManifests, ...pickerModalManifests, + ...viewManifests, ...workspaceViewManifests, ]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/collection/types.ts b/src/Umbraco.Web.UI.Client/src/packages/core/collection/types.ts index 67fb479e88cd..39ad26a61f7e 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/collection/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/types.ts @@ -1,15 +1,17 @@ import type { ManifestCollection } from './extensions/types.js'; +import type { UmbCollectionItemModel } from './item/types.js'; import type { Observable } from '@umbraco-cms/backoffice/external/rxjs'; import type { UmbEntityUnique } from '@umbraco-cms/backoffice/entity'; import type { UmbPaginationManager } from '@umbraco-cms/backoffice/utils'; export type * from './action/create/types.js'; +export type * from './collection-item-picker-modal/types.js'; export type * from './conditions/types.js'; export type * from './extensions/types.js'; export type * from './item/types.js'; export type * from './menu/types.js'; +export type * from './view/types.js'; export type * from './workspace-view/types.js'; -export type * from './collection-item-picker-modal/types.js'; export interface UmbCollectionConfiguration { unique?: UmbEntityUnique; @@ -48,6 +50,7 @@ export interface UmbCollectionContext { setManifest(manifest: ManifestCollection): void; getManifest(): ManifestCollection | undefined; requestCollection(): Promise; + requestItemHref?(item: UmbCollectionItemModel): Promise; pagination: UmbPaginationManager; items: Observable; totalItems: Observable; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/card/card-collection-view.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/card/card-collection-view.element.ts new file mode 100644 index 000000000000..4b3309a74bc2 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/card/card-collection-view.element.ts @@ -0,0 +1,61 @@ +import { UmbCollectionViewElementBase } from '../umb-collection-view-element-base.js'; +import type { UmbCollectionItemModel } from '../../types.js'; +import { css, customElement, html, nothing, repeat } from '@umbraco-cms/backoffice/external/lit'; +import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; + +@customElement('umb-card-collection-view') +export class UmbCardCollectionViewElement extends UmbCollectionViewElementBase { + override render() { + if (this._loading) return nothing; + return html` +
+ ${repeat( + this._items, + (item) => item.unique, + (item) => this.#renderItem(item), + )} +
+ `; + } + + #renderItem(item: UmbCollectionItemModel) { + const href = item.unique ? this._itemHrefs.get(item.unique) : undefined; + return html` 0} + ?selected=${this._isSelectedItem(item.unique)} + @selected=${() => this._selectItem(item.unique)} + @deselected=${() => this._deselectItem(item.unique)}> + + `; + } + + static override styles = [ + UmbTextStyles, + css` + :host { + display: flex; + flex-direction: column; + } + + #card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: var(--uui-size-space-4); + } + `, + ]; +} + +export { UmbCardCollectionViewElement as element }; + +declare global { + interface HTMLElementTagNameMap { + 'umb-card-collection-view': UmbCardCollectionViewElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/card/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/card/manifests.ts new file mode 100644 index 000000000000..b3b5e67bd9d7 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/card/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.Card', + matchKind: 'card', + matchType: 'collectionView', + manifest: { + type: 'collectionView', + kind: 'card', + element: () => import('./card-collection-view.element.js'), + weight: 800, + meta: { + label: 'Cards', + icon: 'icon-grid', + pathName: 'cards', + }, + }, + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/collection/extensions/collection-view.extension.ts b/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/collection-view.extension.ts similarity index 100% rename from src/Umbraco.Web.UI.Client/src/packages/core/collection/extensions/collection-view.extension.ts rename to src/Umbraco.Web.UI.Client/src/packages/core/collection/view/collection-view.extension.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/collection/collection-view.manager.test.ts b/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/collection-view.manager.test.ts similarity index 96% rename from src/Umbraco.Web.UI.Client/src/packages/core/collection/collection-view.manager.test.ts rename to src/Umbraco.Web.UI.Client/src/packages/core/collection/view/collection-view.manager.test.ts index 793b9d034393..507e2bc48a23 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/collection/collection-view.manager.test.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/collection-view.manager.test.ts @@ -1,5 +1,5 @@ -import type { ManifestCollectionView } from './extensions/types.js'; -import { umbExtensionsRegistry } from '../extension-registry/index.js'; +import type { ManifestCollectionView } from './collection-view.extension.js'; +import { umbExtensionsRegistry } from '../../extension-registry/index.js'; import { UmbCollectionViewManager } from './collection-view.manager.js'; import { expect } from '@open-wc/testing'; import { Observable } from '@umbraco-cms/backoffice/external/rxjs'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/collection/collection-view.manager.ts b/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/collection-view.manager.ts similarity index 97% rename from src/Umbraco.Web.UI.Client/src/packages/core/collection/collection-view.manager.ts rename to src/Umbraco.Web.UI.Client/src/packages/core/collection/view/collection-view.manager.ts index bb0134824bde..6b06efa44667 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/collection/collection-view.manager.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/collection-view.manager.ts @@ -1,4 +1,4 @@ -import type { ManifestCollectionView } from './extensions/types.js'; +import type { ManifestCollectionView } from './collection-view.extension.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'; 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 new file mode 100644 index 000000000000..fe4f150180e6 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/manifests.ts @@ -0,0 +1,5 @@ +import { manifests as cardManifests } from './card/manifests.js'; +import { manifests as refManifests } from './ref/manifests.js'; +import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension-registry'; + +export const manifests: Array = [...cardManifests, ...refManifests]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/ref/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/ref/manifests.ts new file mode 100644 index 000000000000..f7b2c0e4516d --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/ref/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.Ref', + matchKind: 'ref', + matchType: 'collectionView', + manifest: { + type: 'collectionView', + kind: 'ref', + element: () => import('./ref-collection-view.element.js'), + weight: 800, + meta: { + label: 'List', + icon: 'icon-list', + pathName: 'refs', + }, + }, + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/ref/ref-collection-view.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/ref/ref-collection-view.element.ts new file mode 100644 index 000000000000..adb5ccbf0311 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/ref/ref-collection-view.element.ts @@ -0,0 +1,55 @@ +import { UmbCollectionViewElementBase } from '../umb-collection-view-element-base.js'; +import type { UmbCollectionItemModel } from '../../types.js'; +import { css, customElement, html, nothing, repeat } from '@umbraco-cms/backoffice/external/lit'; +import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; + +@customElement('umb-ref-collection-view') +export class UmbRefCollectionViewElement extends UmbCollectionViewElementBase { + override render() { + if (this._loading) return nothing; + return html` + + ${repeat( + this._items, + (item) => item.unique, + (item) => this.#renderItem(item), + )} + + `; + } + + #renderItem(item: UmbCollectionItemModel) { + const href = item.unique ? this._itemHrefs.get(item.unique) : undefined; + return html` 0} + ?selected=${this._isSelectedItem(item.unique)} + @selected=${() => this._selectItem(item.unique)} + @deselected=${() => this._deselectItem(item.unique)}> + + `; + } + + static override styles = [ + UmbTextStyles, + css` + :host { + display: flex; + flex-direction: column; + } + `, + ]; +} + +export { UmbRefCollectionViewElement as element }; + +declare global { + interface HTMLElementTagNameMap { + 'umb-ref-collection-view': UmbRefCollectionViewElement; + } +} 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 new file mode 100644 index 000000000000..a508a7ab213e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/types.ts @@ -0,0 +1 @@ +export type * from './collection-view.extension.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 new file mode 100644 index 000000000000..e3540cb8922c --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/view/umb-collection-view-element-base.ts @@ -0,0 +1,91 @@ +import { UMB_COLLECTION_CONTEXT } from '../default/index.js'; +import type { UmbCollectionItemModel } from '../types.js'; +import { state } from '@umbraco-cms/backoffice/external/lit'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; + +/** + * Abstract base class for collection view elements. + * Provides shared state management, selection handling, and context consumption for collection views. + */ +export abstract class UmbCollectionViewElementBase extends UmbLitElement { + @state() + protected _items: Array = []; + + @state() + protected _selectable = false; + + @state() + protected _selection: Array = []; + + @state() + protected _loading = false; + + @state() + protected _itemHrefs: Map = new Map(); + + #collectionContext?: typeof UMB_COLLECTION_CONTEXT.TYPE; + + constructor() { + super(); + + this.consumeContext(UMB_COLLECTION_CONTEXT, (instance) => { + this.#collectionContext = instance; + + this.observe( + this.#collectionContext?.selection.selectable, + (selectable) => (this._selectable = selectable ?? false), + 'umbCollectionSelectableObserver', + ); + + this.observe( + this.#collectionContext?.selection.selection, + (selection) => (this._selection = selection ?? []), + 'umbCollectionSelectionObserver', + ); + + this.observe( + this.#collectionContext?.items, + async (items) => { + this._items = items ?? []; + await this.#updateItemHrefs(); + }, + 'umbCollectionItemsObserver', + ); + }); + } + + /** + * Selects an item in the collection. + * @param {string} unique - The unique identifier of the item to select. + */ + protected _selectItem(unique: UmbCollectionItemModel['unique']) { + this.#collectionContext?.selection.select(unique); + } + + /** + * Deselects an item in the collection. + * @param {string} unique - The unique identifier of the item to deselect. + */ + protected _deselectItem(unique: UmbCollectionItemModel['unique']) { + this.#collectionContext?.selection.deselect(unique); + } + + /** + * Checks if an item is currently selected. + * @param {string} unique - The unique identifier of the item to check. + * @returns {boolean} True if the item is selected, false otherwise. + */ + protected _isSelectedItem(unique: UmbCollectionItemModel['unique']): boolean { + return this.#collectionContext?.selection.isSelected(unique) ?? false; + } + + async #updateItemHrefs() { + const entries = await Promise.all( + this._items.map(async (item) => { + const href = await this.#collectionContext?.requestItemHref?.(item); + return item.unique && href ? ([item.unique, href] as const) : null; + }), + ); + this._itemHrefs = new Map(entries.filter((entry): entry is [string, string] => entry !== null)); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/language/collection/views/table/language-table-collection-view.element.ts b/src/Umbraco.Web.UI.Client/src/packages/language/collection/views/table/language-table-collection-view.element.ts index b347db3e8196..00e6170f9ecb 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/language/collection/views/table/language-table-collection-view.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/language/collection/views/table/language-table-collection-view.element.ts @@ -57,12 +57,29 @@ export class UmbLanguageTableCollectionViewElement extends UmbLitElement { this.consumeContext(UMB_COLLECTION_CONTEXT, (instance) => { this.#collectionContext = instance; this.#observeCollectionItems(); + this.#observeIsSelectable(); }); } #observeCollectionItems() { - if (!this.#collectionContext) return; - this.observe(this.#collectionContext.items, (items) => this.#createTableItems(items), 'umbCollectionItemsObserver'); + this.observe( + this.#collectionContext?.items, + (items) => this.#createTableItems(items || []), + 'umbCollectionItemsObserver', + ); + } + + #observeIsSelectable() { + this.observe( + this.#collectionContext?.selection.selectable, + (isSelectable) => { + this._tableConfig = { + ...this._tableConfig, + allowSelection: isSelectable ?? false, + }; + }, + 'umbCollectionIsSelectableObserver', + ); } #createTableItems(languages: Array) { diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/user-collection.context.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/user-collection.context.ts index 1c0e58775561..f517c9248c7a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/user-collection.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/user-collection.context.ts @@ -1,4 +1,5 @@ import type { UmbUserDetailModel } from '../types.js'; +import { UMB_EDIT_USER_WORKSPACE_PATH_PATTERN } from '../paths.js'; import { UMB_COLLECTION_VIEW_USER_GRID } from './views/index.js'; import type { UmbUserCollectionFilterModel, UmbUserOrderByOption } from './types.js'; import type { UmbUserOrderByType, UmbUserStateFilterType } from './utils/index.js'; @@ -120,6 +121,15 @@ export class UmbUserCollectionContext extends UmbDefaultCollectionContext< setOrderDirectionFilter(orderDirection: UmbDirectionType) { this.setFilter({ orderDirection }); } + + /** + * Returns the href for a specific user collection item. + * @param {UmbUserDetailModel} item - The user item to get the href for. + * @returns {Promise} - The edit workspace href for the user. + */ + override async requestItemHref(item: UmbUserDetailModel): Promise { + return `${UMB_EDIT_USER_WORKSPACE_PATH_PATTERN.generateAbsolute({ unique: item.unique })}`; + } } export { UmbUserCollectionContext as api }; 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 75e00536cd8d..1ec568bb59a0 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 @@ -21,15 +21,9 @@ export const manifests: Array = [ }, { type: 'collectionView', + kind: 'card', alias: UMB_COLLECTION_VIEW_USER_GRID, name: 'User Grid Collection View', - element: () => import('./grid/user-grid-collection-view.element.js'), - weight: 200, - meta: { - label: 'Grid', - icon: 'icon-grid', - pathName: 'grid', - }, conditions: [ { alias: UMB_COLLECTION_ALIAS_CONDITION, diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/views/table/column-layouts/name/user-table-name-column-layout.element.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/views/table/column-layouts/name/user-table-name-column-layout.element.ts index 15292958c7b0..7e8683be692d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/views/table/column-layouts/name/user-table-name-column-layout.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/views/table/column-layouts/name/user-table-name-column-layout.element.ts @@ -1,6 +1,5 @@ -import { UMB_USER_WORKSPACE_PATH } from '../../../../../paths.js'; import { html, LitElement, customElement, property } from '@umbraco-cms/backoffice/external/lit'; -import type { UmbTableColumn, UmbTableItem } from '@umbraco-cms/backoffice/components'; +import type { UmbTableColumn } from '@umbraco-cms/backoffice/components'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; @customElement('umb-user-table-name-column-layout') @@ -8,22 +7,26 @@ export class UmbUserTableNameColumnLayoutElement extends LitElement { @property({ type: Object, attribute: false }) column!: UmbTableColumn; - @property({ type: Object, attribute: false }) - item!: UmbTableItem; - @property({ attribute: false }) - value!: any; + value!: { + name: string; + unique: string; + kind: string; + avatarUrls: Record; + href?: string; + }; override render() { - const href = UMB_USER_WORKSPACE_PATH + '/edit/' + this.value.unique; - return html`
- ${this.value.name} + + ${this.value.href + ? html`${this.value.name}` + : html` ${this.value.name}`}
`; } static override styles = [UmbTextStyles]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/views/table/user-table-collection-view.element.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/views/table/user-table-collection-view.element.ts index 3c1915636e67..123636a81ee0 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/views/table/user-table-collection-view.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/views/table/user-table-collection-view.element.ts @@ -69,6 +69,9 @@ export class UmbUserTableCollectionViewElement extends UmbLitElement { @state() private _selection: Array = []; + @state() + private _itemHrefs: Map = new Map(); + #collectionContext?: UmbUserCollectionContext; constructor() { @@ -83,8 +86,9 @@ export class UmbUserTableCollectionViewElement extends UmbLitElement { ); this.observe( this.#collectionContext?.items, - (items) => { + async (items) => { this._users = items ?? []; + await this.#updateItemHrefs(); this.#observeUserGroups(); }, 'umbCollectionItemsObserver', @@ -116,6 +120,17 @@ export class UmbUserTableCollectionViewElement extends UmbLitElement { .join(', '); } + async #updateItemHrefs() { + const hrefs = new Map(); + for (const user of this._users) { + const href = await this.#collectionContext?.requestItemHref?.(user); + if (href && user.unique) { + hrefs.set(user.unique, href); + } + } + this._itemHrefs = hrefs; + } + #createTableItems() { this._tableItems = this._users.map((user) => { return { @@ -129,6 +144,7 @@ export class UmbUserTableCollectionViewElement extends UmbLitElement { name: user.name, avatarUrls: user.avatarUrls, kind: user.kind, + href: user.unique ? this._itemHrefs.get(user.unique) : undefined, }, }, { diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user/paths.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user/paths.ts index bc01820db67c..b42a977d6649 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user/paths.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user/paths.ts @@ -13,4 +13,7 @@ export const UMB_USER_ROOT_WORKSPACE_PATH = UMB_WORKSPACE_PATH_PATTERN.generateA entityType: UMB_USER_ROOT_ENTITY_TYPE, }); -export const UMB_EDIT_USER_WORKSPACE_PATH_PATTERN = new UmbPathPattern<{ unique: string }>('edit/:unique'); +export const UMB_EDIT_USER_WORKSPACE_PATH_PATTERN = new UmbPathPattern<{ unique: string }>( + 'edit/:unique', + UMB_USER_WORKSPACE_PATH, +);