Collection View Layouts

This commit is contained in:
leekelleher
2024-04-02 09:10:51 +01:00
parent 6867be412c
commit e3c3db46ad
10 changed files with 242 additions and 105 deletions

View File

@@ -15,7 +15,7 @@ export class UmbCollectionBulkActionPermissionCondition
super(host, args);
this.consumeContext(UMB_DEFAULT_COLLECTION_CONTEXT, (context) => {
const allowedActions = context.getConfig().allowedEntityBulkActions;
const allowedActions = context.getConfig()?.allowedEntityBulkActions;
this.permitted = allowedActions ? this.config.match(allowedActions) : false;
});
}

View File

@@ -43,7 +43,8 @@ describe('UmbCollectionViewManager', () => {
beforeEach(() => {
const hostElement = new UmbTestControllerHostElement();
manager = new UmbCollectionViewManager(hostElement, config);
manager = new UmbCollectionViewManager(hostElement);
manager.setConfig(config);
});
describe('Public API', () => {
@@ -60,8 +61,8 @@ describe('UmbCollectionViewManager', () => {
expect(manager).to.have.property('routes').to.be.an.instanceOf(Observable);
});
it('has a rootPathname property', () => {
expect(manager).to.have.property('rootPathname').to.be.an.instanceOf(Observable);
it('has a rootPathName property', () => {
expect(manager).to.have.property('rootPathName').to.be.an.instanceOf(Observable);
});
});

View File

@@ -8,6 +8,7 @@ import type { UmbRoute } from '@umbraco-cms/backoffice/router';
export interface UmbCollectionViewManagerConfig {
defaultViewAlias?: string;
manifestFilter?: (manifest: ManifestCollectionView) => boolean
}
export class UmbCollectionViewManager extends UmbControllerBase {
@@ -20,24 +21,26 @@ export class UmbCollectionViewManager extends UmbControllerBase {
#routes = new UmbArrayState<UmbRoute>([], (x) => x.path);
public readonly routes = this.#routes.asObservable();
#rootPathname = new UmbStringState('');
public readonly rootPathname = this.#rootPathname.asObservable();
#rootPathName = new UmbStringState('');
public readonly rootPathName = this.#rootPathName.asObservable();
#defaultViewAlias?: string;
constructor(host: UmbControllerHost, config: UmbCollectionViewManagerConfig) {
constructor(host: UmbControllerHost) {
super(host);
this.#defaultViewAlias = config.defaultViewAlias;
this.#observeViews();
// TODO: hack - we need to figure out how to get the "parent path" from the router
setTimeout(() => {
const currentUrl = new URL(window.location.href);
this.#rootPathname.setValue(currentUrl.pathname.substring(0, currentUrl.pathname.lastIndexOf('/')));
this.#rootPathName.setValue(currentUrl.pathname.substring(0, currentUrl.pathname.lastIndexOf('/')));
}, 100);
}
public setConfig(config: UmbCollectionViewManagerConfig) {
this.#defaultViewAlias = config.defaultViewAlias;
this.#observeViews(config.manifestFilter);
}
// Views
/**
* Sets the current view.
@@ -57,12 +60,18 @@ export class UmbCollectionViewManager extends UmbControllerBase {
return this.#currentView.getValue();
}
#observeViews() {
return new UmbExtensionsManifestInitializer(this, umbExtensionsRegistry, 'collectionView', null, (views) => {
const manifests = views.map((view) => view.manifest);
this.#views.setValue(manifests);
this.#createRoutes(manifests);
});
#observeViews(filter?: (manifest: ManifestCollectionView) => boolean) {
return new UmbExtensionsManifestInitializer(
this,
umbExtensionsRegistry,
'collectionView',
filter ?? null,
(views) => {
const manifests = views.map((view) => view.manifest);
this.#views.setValue(manifests);
this.#createRoutes(manifests);
},
);
}
#createRoutes(views: ManifestCollectionView[] | null) {

View File

@@ -8,6 +8,7 @@ import type { ManifestCollection } from '@umbraco-cms/backoffice/extension-regis
@customElement('umb-collection')
export class UmbCollectionElement extends UmbLitElement {
#alias?: string;
@property({ type: String, reflect: true })
set alias(newVal) {
this.#alias = newVal;
@@ -17,7 +18,8 @@ export class UmbCollectionElement extends UmbLitElement {
return this.#alias;
}
#config?: UmbCollectionConfiguration = { pageSize: 50 };
#config?: UmbCollectionConfiguration;
@property({ type: Object, attribute: false })
set config(newVal: UmbCollectionConfiguration | undefined) {
this.#config = newVal;

View File

@@ -1,20 +1,34 @@
import type { UmbDefaultCollectionContext } from '../default/collection-default.context.js';
import { UMB_DEFAULT_COLLECTION_CONTEXT } from '../default/collection-default.context.js';
import type { ManifestCollectionView } from '../../extension-registry/models/collection-view.model.js';
import { css, html, customElement, state, nothing } from '@umbraco-cms/backoffice/external/lit';
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
import type { UmbCollectionLayoutConfiguration } from '../types.js';
import { css, html, customElement, state, nothing, repeat, query } from '@umbraco-cms/backoffice/external/lit';
import { observeMultiple } from '@umbraco-cms/backoffice/observable-api';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
import { UMB_ENTITY_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/workspace';
import type { ManifestCollectionView } from '@umbraco-cms/backoffice/extension-registry';
import type { UUIPopoverContainerElement } from '@umbraco-cms/backoffice/external/uui';
interface UmbCollectionViewLayout {
alias: string;
label: string;
icon: string;
pathName: string;
}
@customElement('umb-collection-view-bundle')
export class UmbCollectionViewBundleElement extends UmbLitElement {
@state()
_views: Array<ManifestCollectionView> = [];
_views: Array<UmbCollectionViewLayout> = [];
@state()
_currentView?: ManifestCollectionView;
_currentView?: UmbCollectionViewLayout;
@state()
private _collectionRootPathname?: string;
private _collectionRootPathName?: string;
@state()
private _entityUnique?: string;
#collectionContext?: UmbDefaultCollectionContext<any, any>;
@@ -23,43 +37,86 @@ export class UmbCollectionViewBundleElement extends UmbLitElement {
this.consumeContext(UMB_DEFAULT_COLLECTION_CONTEXT, (context) => {
this.#collectionContext = context;
if (!this.#collectionContext) return;
this.#observeRootPathname();
this.#observeViews();
this.#observeCurrentView();
this.#observeCollection();
});
this.consumeContext(UMB_ENTITY_WORKSPACE_CONTEXT, (context) => {
this._entityUnique = context.getUnique() ?? '';
});
}
#observeRootPathname() {
this.observe(
this.#collectionContext!.view.rootPathname,
(rootPathname) => {
this._collectionRootPathname = rootPathname;
},
'umbCollectionRootPathnameObserver',
);
}
#observeCollection() {
if (!this.#collectionContext) return;
#observeCurrentView() {
this.observe(
this.#collectionContext!.view.currentView,
(view) => {
//TODO: This is not called when the view is changed
this._currentView = view;
this.#collectionContext.view.rootPathName,
(rootPathName) => {
this._collectionRootPathName = rootPathName;
},
'umbCollectionRootPathNameObserver',
);
this.observe(
this.#collectionContext.view.currentView,
(currentView) => {
if (!currentView) return;
this._currentView = this._views.find((view) => view.alias === currentView.alias);
},
'umbCurrentCollectionViewObserver',
);
}
#observeViews() {
this.observe(
this.#collectionContext!.view.views,
(views) => {
this._views = views;
observeMultiple([this.#collectionContext.view.views, this.#collectionContext.viewLayouts]),
([manifests, viewLayouts]) => {
if (!manifests?.length && !viewLayouts?.length) return;
this._views = this.#mapManifestToViewLayout(manifests, viewLayouts);
},
'umbCollectionViewsObserver',
'umbCollectionViewsAndLayoutsObserver',
);
}
@query('#collection-view-bundle-popover')
private _popover?: UUIPopoverContainerElement;
#mapManifestToViewLayout(
manifests: Array<ManifestCollectionView>,
viewLayouts: Array<UmbCollectionLayoutConfiguration>,
): typeof this._views {
if (viewLayouts.length > 0) {
const layouts: typeof this._views = [];
viewLayouts.forEach((viewLayout) => {
const viewManifest = manifests.find((manifest) => manifest.alias === viewLayout.collectionView);
if (!viewManifest) return;
layouts.push({
alias: viewManifest.alias,
label: viewLayout.name ?? viewManifest.meta.label,
icon: viewLayout.icon ?? viewManifest.meta.icon,
pathName: viewManifest.meta.pathName,
});
});
return layouts;
}
// fallback on the 'collectionView' manifests
return manifests.map((manifest) => ({
alias: manifest.alias,
label: manifest.meta.label,
icon: manifest.meta.icon,
pathName: manifest.meta.pathName,
}));
}
#onClick(view: UmbCollectionViewLayout) {
this.#collectionContext?.setLastSelectedView(this._entityUnique, view.alias);
// TODO: This ignorer is just neede for JSON SCHEMA TO WORK, As its not updated with latest TS jet.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
this._popover?.hidePopover();
}
render() {
if (!this._currentView) return nothing;
if (this._views.length <= 1) return nothing;
@@ -70,22 +127,29 @@ export class UmbCollectionViewBundleElement extends UmbLitElement {
</uui-button>
<uui-popover-container id="collection-view-bundle-popover" placement="bottom-end">
<umb-popover-layout>
<div class="filter-dropdown">${this._views.map((view) => this.#renderItem(view))}</div>
<div class="filter-dropdown">
${repeat(
this._views,
(view) => view.alias,
(view) => this.#renderItem(view),
)}
</div>
</umb-popover-layout>
</uui-popover-container>
`;
}
#renderItem(view: ManifestCollectionView) {
#renderItem(view: UmbCollectionViewLayout) {
return html`
<uui-button compact href="${this._collectionRootPathname}/${view.meta.pathName}">
${this.#renderItemDisplay(view)} <span class="label">${view.meta.label}</span>
<uui-button compact href="${this._collectionRootPathName}/${view.pathName}" @click=${() => this.#onClick(view)}>
${this.#renderItemDisplay(view)}
<span class="label">${view.label}</span>
</uui-button>
`;
}
#renderItemDisplay(view: ManifestCollectionView) {
return html`<umb-icon name=${view.meta.icon}></umb-icon>`;
#renderItemDisplay(view: UmbCollectionViewLayout) {
return html`<umb-icon name=${view.icon}></umb-icon>`;
}
static styles = [
@@ -99,9 +163,12 @@ export class UmbCollectionViewBundleElement extends UmbLitElement {
}
.filter-dropdown {
display: flex;
gap: var(--uui-size-space-3);
gap: var(--uui-size-space-1);
flex-direction: column;
}
umb-icon {
display: inline-block;
}
`,
];
}

View File

@@ -1,17 +1,25 @@
import type { UmbCollectionColumnConfiguration, UmbCollectionConfiguration, UmbCollectionContext } from '../types.js';
import { UmbCollectionViewManager } from '../collection-view.manager.js';
import type { UmbCollectionViewManagerConfig } from '../collection-view.manager.js';
import type {
UmbCollectionColumnConfiguration,
UmbCollectionConfiguration,
UmbCollectionContext,
UmbCollectionLayoutConfiguration,
} from '../types.js';
import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';
import { UmbArrayState, UmbNumberState, UmbObjectState } from '@umbraco-cms/backoffice/observable-api';
import { UmbChangeEvent } from '@umbraco-cms/backoffice/event';
import { UmbContextBase } from '@umbraco-cms/backoffice/class-api';
import { UmbContextToken } from '@umbraco-cms/backoffice/context-api';
import { UmbArrayState, UmbNumberState, UmbObjectState } from '@umbraco-cms/backoffice/observable-api';
import { UmbExtensionApiInitializer } from '@umbraco-cms/backoffice/extension-api';
import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';
import { UmbSelectionManager, UmbPaginationManager } from '@umbraco-cms/backoffice/utils';
import { UmbChangeEvent } from '@umbraco-cms/backoffice/event';
import type { ManifestCollection, ManifestRepository } from '@umbraco-cms/backoffice/extension-registry';
import type { UmbApi } from '@umbraco-cms/backoffice/extension-api';
import type { UmbCollectionFilterModel, UmbCollectionRepository } from '@umbraco-cms/backoffice/collection';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
const LOCAL_STORAGE_KEY = 'umb-collection-view';
export class UmbDefaultCollectionContext<
CollectionItemType = any,
FilterModelType extends UmbCollectionFilterModel = any,
@@ -19,7 +27,9 @@ export class UmbDefaultCollectionContext<
extends UmbContextBase<UmbDefaultCollectionContext>
implements UmbCollectionContext, UmbApi
{
#config?: UmbCollectionConfiguration = { pageSize: 50 };
#manifest?: ManifestCollection;
#repository?: UmbCollectionRepository;
#items = new UmbArrayState<CollectionItemType>([], (x) => x);
public readonly items = this.#items.asObservable();
@@ -30,10 +40,17 @@ export class UmbDefaultCollectionContext<
#filter = new UmbObjectState<FilterModelType | object>({});
public readonly filter = this.#filter.asObservable();
#userDefinedProperties = new UmbArrayState<UmbCollectionColumnConfiguration>([], (x) => x);
#userDefinedProperties = new UmbArrayState<UmbCollectionColumnConfiguration>([], (x) => x.alias);
public readonly userDefinedProperties = this.#userDefinedProperties.asObservable();
repository?: UmbCollectionRepository;
#viewLayouts = new UmbArrayState<UmbCollectionLayoutConfiguration>([], (x) => x.collectionView);
public readonly viewLayouts = this.#viewLayouts.asObservable();
public readonly pagination = new UmbPaginationManager();
public readonly selection = new UmbSelectionManager(this);
public readonly view = new UmbCollectionViewManager(this);
#defaultViewAlias: string;
#initResolver?: () => void;
#initialized = false;
@@ -42,28 +59,72 @@ export class UmbDefaultCollectionContext<
this.#initialized ? resolve() : (this.#initResolver = resolve);
});
public readonly pagination = new UmbPaginationManager();
public readonly selection = new UmbSelectionManager(this);
public readonly view;
constructor(host: UmbControllerHost, defaultViewAlias: string) {
super(host, UMB_DEFAULT_COLLECTION_CONTEXT);
// listen for page changes on the pagination manager
this.pagination.addEventListener(UmbChangeEvent.TYPE, this.#onPageChange);
this.#defaultViewAlias = defaultViewAlias;
this.view = new UmbCollectionViewManager(this, { defaultViewAlias: defaultViewAlias });
this.pagination.addEventListener(UmbChangeEvent.TYPE, this.#onPageChange);
}
#configured = false;
#configure() {
if (!this.#config) return;
this.selection.setMultiple(true);
if (this.#config.pageSize) {
this.pagination.setPageSize(this.#config.pageSize);
}
this.#filter.setValue({
...this.#config,
...this.#filter.getValue(),
skip: 0,
take: this.#config.pageSize,
});
this.#userDefinedProperties.setValue(this.#config?.userDefinedProperties ?? []);
const viewManagerConfig: UmbCollectionViewManagerConfig = { defaultViewAlias: this.#defaultViewAlias };
if (this.#config.layouts && this.#config.layouts.length > 0) {
this.#viewLayouts.setValue(this.#config.layouts);
const aliases = this.#config.layouts.map((layout) => layout.collectionView);
viewManagerConfig.manifestFilter = (manifest) => aliases.includes(manifest.alias);
}
this.view.setConfig(viewManagerConfig);
this.#configured = true;
}
// TODO: find a generic way to do this
#checkIfInitialized() {
if (this.repository) {
if (this.#repository) {
this.#initialized = true;
this.#initResolver?.();
}
}
#config: UmbCollectionConfiguration = { pageSize: 50 };
#observeRepository(repositoryAlias: string) {
new UmbExtensionApiInitializer<ManifestRepository<UmbCollectionRepository>>(
this,
umbExtensionsRegistry,
repositoryAlias,
[this._host],
(permitted, ctrl) => {
this.#repository = permitted ? ctrl.api : undefined;
this.#checkIfInitialized();
},
);
}
#onPageChange = (event: UmbChangeEvent) => {
const target = event.target as UmbPaginationManager;
const skipFilter = { skip: target.getSkip() } as Partial<FilterModelType>;
this.setFilter(skipFilter);
};
/**
* Sets the configuration for the collection.
@@ -72,7 +133,6 @@ export class UmbDefaultCollectionContext<
*/
public setConfig(config: UmbCollectionConfiguration) {
this.#config = config;
this.#configure();
}
public getConfig() {
@@ -108,10 +168,13 @@ export class UmbDefaultCollectionContext<
*/
public async requestCollection() {
await this.#init;
if (!this.repository) throw new Error(`Missing repository for ${this.#manifest}`);
if (!this.#configured) this.#configure();
if (!this.#repository) throw new Error(`Missing repository for ${this.#manifest}`);
const filter = this.#filter.getValue();
const { data } = await this.repository.requestCollection(filter);
const { data } = await this.#repository.requestCollection(filter);
if (data) {
this.#items.setValue(data.items);
@@ -130,35 +193,24 @@ export class UmbDefaultCollectionContext<
this.requestCollection();
}
#configure() {
this.selection.setMultiple(true);
this.pagination.setPageSize(this.#config.pageSize!);
this.#filter.setValue({
...this.#config,
...this.#filter.getValue(),
skip: 0,
take: this.#config.pageSize,
});
this.#userDefinedProperties.setValue(this.#config.userDefinedProperties ?? []);
public getLastSelectedView(unique: string | undefined): string | undefined {
if (!unique) return;
const layouts = JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY) ?? '{}') ?? {};
if (!layouts) return;
return layouts[unique];
}
#onPageChange = (event: UmbChangeEvent) => {
const target = event.target as UmbPaginationManager;
const skipFilter = { skip: target.getSkip() } as Partial<FilterModelType>;
this.setFilter(skipFilter);
};
public setLastSelectedView(unique: string | undefined, viewAlias: string) {
if (!unique) return;
#observeRepository(repositoryAlias: string) {
new UmbExtensionApiInitializer<ManifestRepository<UmbCollectionRepository>>(
this,
umbExtensionsRegistry,
repositoryAlias,
[this._host],
(permitted, ctrl) => {
this.repository = permitted ? ctrl.api : undefined;
this.#checkIfInitialized();
},
);
const layouts = JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY) ?? '{}') ?? {};
if (!layouts) return;
layouts[unique] = viewAlias;
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(layouts));
}
}

View File

@@ -34,7 +34,13 @@ export class UmbIconElement extends UmbLitElement {
@property({ type: String })
public set name(value: string | undefined) {
const [icon, alias] = value ? value.split(' ') : [];
if (alias) this.#setColorStyle(alias);
if (alias) {
this.#setColorStyle(alias);
} else {
this._color = undefined;
}
this._icon = icon;
}
public get name(): string | undefined {

View File

@@ -56,6 +56,7 @@ export class UmbPropertyEditorUICollectionViewElement extends UmbLitElement impl
): UmbCollectionConfiguration {
return {
allowedEntityBulkActions: config?.getValueByAlias<UmbCollectionBulkActionPermissions>('bulkActionPermissions'),
layouts: config?.getValueByAlias('layouts'),
orderBy: config?.getValueByAlias('orderBy') ?? 'updateDate',
orderDirection: config?.getValueByAlias('orderDirection') ?? 'asc',
pageSize: Number(config?.getValueByAlias('pageSize')) ?? 50,

View File

@@ -1,11 +1,11 @@
import type { UmbDocumentCollectionContext } from './document-collection.context.js';
import { css, html, customElement } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { UMB_DEFAULT_COLLECTION_CONTEXT } from '@umbraco-cms/backoffice/collection';
import type { UmbDefaultCollectionContext } from '@umbraco-cms/backoffice/collection';
@customElement('umb-document-collection-toolbar')
export class UmbDocumentCollectionToolbarElement extends UmbLitElement {
#collectionContext?: UmbDocumentCollectionContext;
#collectionContext?: UmbDefaultCollectionContext;
#inputTimer?: NodeJS.Timeout;
#inputTimerAmount = 500;
@@ -14,7 +14,7 @@ export class UmbDocumentCollectionToolbarElement extends UmbLitElement {
super();
this.consumeContext(UMB_DEFAULT_COLLECTION_CONTEXT, (instance) => {
this.#collectionContext = instance as UmbDocumentCollectionContext;
this.#collectionContext = instance;
});
}

View File

@@ -9,6 +9,5 @@ export class UmbDocumentCollectionContext extends UmbDefaultCollectionContext<
> {
constructor(host: UmbControllerHost) {
super(host, UMB_DOCUMENT_TABLE_COLLECTION_VIEW_ALIAS);
}
}