Merge branch 'main' into feature/setup-stylesheet-workspace

This commit is contained in:
Julia Gru
2023-08-04 12:33:36 +02:00
254 changed files with 3817 additions and 1402 deletions

View File

@@ -122,9 +122,12 @@ To declare the Published Cache Status Dashboard as a new manifest, we need to ad
label: 'Published Status',
pathname: 'published-status',
},
conditions: {
sections: ['Umb.Section.Settings'],
},
conditions: [
{
alias: 'Umb.Condition.SectionAlias',
match: 'Umb.Section.Settings',
},
],
},
```

View File

@@ -200,7 +200,7 @@ module.exports = {
},
},
/** @type {import('eslint').RuleModule}*/
/** @type {import('eslint').Rule.RuleModule}*/
'prefer-static-styles-last': {
meta: {
type: 'suggestion',
@@ -247,7 +247,7 @@ module.exports = {
},
},
/** @type {import('eslint').RuleModule}*/
/** @type {import('eslint').Rule.RuleModule}*/
'ensure-relative-import-use-js-extension': {
meta: {
type: 'problem',
@@ -308,6 +308,33 @@ module.exports = {
});
}
},
ExportAllDeclaration: (node) => {
const { source } = node;
const { value } = source;
const fixedSource = correctImport(value);
if (fixedSource) {
context.report({
node: source,
message: 'Relative exports should use the ".js" file extension.',
fix: (fixer) => fixer.replaceText(source, `'${fixedSource}'`),
});
}
},
ExportNamedDeclaration: (node) => {
const { source } = node;
if (!source) return;
const { value } = source;
const fixedSource = correctImport(value);
if (fixedSource) {
context.report({
node: source,
message: 'Relative exports should use the ".js" file extension.',
fix: (fixer) => fixer.replaceText(source, `'${fixedSource}'`),
});
}
}
};
},
},

View File

@@ -1,11 +1,22 @@
import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';
import { UmbContextToken } from '@umbraco-cms/backoffice/context-api';
import { UmbStringState } from '@umbraco-cms/backoffice/observable-api';
import { UmbBasicState, UmbStringState } from '@umbraco-cms/backoffice/observable-api';
import { UmbExtensionManifestController, UmbExtensionsManifestController } from '@umbraco-cms/backoffice/extension-api';
import { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import { ManifestSection, umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';
export class UmbBackofficeContext {
#activeSectionAlias = new UmbStringState(undefined);
public readonly activeSectionAlias = this.#activeSectionAlias.asObservable();
public readonly allowedSections = umbExtensionsRegistry.extensionsOfType('section');
// TODO: We need a class array state:
#allowedSections = new UmbBasicState<Array<UmbExtensionManifestController<ManifestSection>>>([]);
public readonly allowedSections = this.#allowedSections.asObservable();
constructor(host: UmbControllerHost) {
new UmbExtensionsManifestController(host, umbExtensionsRegistry, 'section', null, (sections) => {
this.#allowedSections.next([...sections]);
});
}
public setActiveSectionAlias(alias: string) {
this.#activeSectionAlias.next(alias);

View File

@@ -1,5 +1,5 @@
import { UmbBackofficeContext, UMB_BACKOFFICE_CONTEXT_TOKEN } from './backoffice.context.js';
import { UmbExtensionInitializer } from './extension.controller.js';
import { UmbExtensionInitializer } from './extension-initializer.controller.js';
import { css, html, customElement } from '@umbraco-cms/backoffice/external/lit';
import { UUITextStyles } from '@umbraco-cms/backoffice/external/uui';
import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';
@@ -31,7 +31,7 @@ const CORE_PACKAGES = [
export class UmbBackofficeElement extends UmbLitElement {
constructor() {
super();
this.provideContext(UMB_BACKOFFICE_CONTEXT_TOKEN, new UmbBackofficeContext());
this.provideContext(UMB_BACKOFFICE_CONTEXT_TOKEN, new UmbBackofficeContext(this));
new UmbBundleExtensionInitializer(this, umbExtensionsRegistry);
new UmbEntryPointExtensionInitializer(this, umbExtensionsRegistry);

View File

@@ -1,8 +1,9 @@
import { UMB_BACKOFFICE_CONTEXT_TOKEN } from '../backoffice.context.js';
import type { UmbBackofficeContext } from '../backoffice.context.js';
import { css, CSSResultGroup, html, when, customElement, state } from '@umbraco-cms/backoffice/external/lit';
import { css, CSSResultGroup, html, when, customElement, state, repeat } from '@umbraco-cms/backoffice/external/lit';
import type { ManifestSection } from '@umbraco-cms/backoffice/extension-registry';
import { UmbLitElement } from '@umbraco-cms/internal/lit-element';
import { UmbExtensionManifestController } from '@umbraco-cms/backoffice/extension-api';
@customElement('umb-backoffice-header-sections')
export class UmbBackofficeHeaderSectionsElement extends UmbLitElement {
@@ -10,10 +11,7 @@ export class UmbBackofficeHeaderSectionsElement extends UmbLitElement {
private _open = false;
@state()
private _sections: Array<ManifestSection> = [];
@state()
private _visibleSections: Array<ManifestSection> = [];
private _sections: Array<UmbExtensionManifestController<ManifestSection>> = [];
@state()
private _extraSections: Array<ManifestSection> = [];
@@ -38,10 +36,6 @@ export class UmbBackofficeHeaderSectionsElement extends UmbLitElement {
this._open = !this._open;
}
private _handleSectionTabClick(alias: string) {
this._backofficeContext?.setActiveSectionAlias(alias);
}
private _handleLabelClick() {
const moreTab = this.shadowRoot?.getElementById('moreTab');
moreTab?.setAttribute('active', 'true');
@@ -53,8 +47,9 @@ export class UmbBackofficeHeaderSectionsElement extends UmbLitElement {
if (!this._backofficeContext) return;
this.observe(this._backofficeContext.allowedSections, (allowedSections) => {
const oldValue = this._sections;
this._sections = allowedSections;
this._visibleSections = this._sections;
this.requestUpdate('_sections', oldValue);
});
}
@@ -69,13 +64,14 @@ export class UmbBackofficeHeaderSectionsElement extends UmbLitElement {
private _renderSections() {
return html`
<uui-tab-group id="tabs">
${this._visibleSections.map(
(section: ManifestSection) => html`
${repeat(
this._sections,
(section) => section.alias,
(section) => html`
<uui-tab
@click="${() => this._handleSectionTabClick(section.alias)}"
?active="${this._currentSectionAlias === section.alias}"
href="${`section/${section.meta.pathname}`}"
label="${section.meta.label || section.name}"></uui-tab>
href="${`section/${section.manifest?.meta.pathname}`}"
label="${section.manifest?.meta.label ?? section.manifest?.name ?? ''}"></uui-tab>
`
)}
${this._renderExtraSections()}

View File

@@ -3,7 +3,10 @@ import { css, html, customElement, state } from '@umbraco-cms/backoffice/externa
import { UmbSectionContext, UMB_SECTION_CONTEXT_TOKEN } from '@umbraco-cms/backoffice/section';
import type { UmbRoute, UmbRouterSlotChangeEvent } from '@umbraco-cms/backoffice/router';
import type { ManifestSection, UmbSectionExtensionElement } from '@umbraco-cms/backoffice/extension-registry';
import { createExtensionElementOrFallback } from '@umbraco-cms/backoffice/extension-api';
import {
UmbExtensionManifestController,
createExtensionElementOrFallback,
} from '@umbraco-cms/backoffice/extension-api';
import { UmbLitElement } from '@umbraco-cms/internal/lit-element';
@customElement('umb-backoffice-main')
@@ -12,7 +15,7 @@ export class UmbBackofficeMainElement extends UmbLitElement {
private _routes: Array<UmbRoute & { alias: string }> = [];
@state()
private _sections: Array<ManifestSection> = [];
private _sections: Array<UmbExtensionManifestController<ManifestSection>> = [];
private _routePrefix = 'section/';
private _backofficeContext?: UmbBackofficeContext;
@@ -42,6 +45,7 @@ export class UmbBackofficeMainElement extends UmbLitElement {
private _createRoutes() {
if (!this._sections) return;
const oldValue = this._routes;
// TODO: Refactor this for re-use across the app where the routes are re-generated at any time.
// TODO: remove section-routes that does not exist anymore.
@@ -52,10 +56,10 @@ export class UmbBackofficeMainElement extends UmbLitElement {
} else {
return {
alias: section.alias,
path: this._routePrefix + section.meta.pathname,
component: () => createExtensionElementOrFallback(section, 'umb-section-default'),
path: this._routePrefix + (section.manifest as any).meta.pathname,
component: () => createExtensionElementOrFallback(section.manifest, 'umb-section-default'),
setup: (component) => {
(component as UmbSectionExtensionElement).manifest = section;
(component as UmbSectionExtensionElement).manifest = section.manifest as any;
},
};
}
@@ -68,22 +72,24 @@ export class UmbBackofficeMainElement extends UmbLitElement {
redirectTo: 'section/content',
});
}
this.requestUpdate('_routes', oldValue);
}
private _onRouteChange = (event: UmbRouterSlotChangeEvent) => {
const currentPath = event.target.localActiveViewPath || '';
const section = this._sections.find((s) => this._routePrefix + s.meta.pathname === currentPath);
const section = this._sections.find((s) => this._routePrefix + (s.manifest as any).meta.pathname === currentPath);
if (!section) return;
this._backofficeContext?.setActiveSectionAlias(section.alias);
this._provideSectionContext(section);
this._provideSectionContext(section.manifest as any);
};
private _provideSectionContext(section: ManifestSection) {
private _provideSectionContext(sectionManifest: ManifestSection) {
if (!this._sectionContext) {
this._sectionContext = new UmbSectionContext(section);
this._sectionContext = new UmbSectionContext(sectionManifest);
this.provideContext(UMB_SECTION_CONTEXT_TOKEN, this._sectionContext);
} else {
this._sectionContext.setManifest(section);
this._sectionContext.setManifest(sectionManifest);
}
}

View File

@@ -5,6 +5,8 @@ import { UmbBackofficeExtensionRegistry } from '@umbraco-cms/backoffice/extensio
import { tryExecuteAndNotify } from '@umbraco-cms/backoffice/resources';
import { ManifestBase, isManifestJSType } from '@umbraco-cms/backoffice/extension-api';
// TODO: consider if this can be replaced by the new extension controllers.
// TODO: move local part out of this, and name something with server.
export class UmbExtensionInitializer extends UmbBaseController {
#extensionRegistry: UmbBackofficeExtensionRegistry;
#unobserve = new Subject<void>();

View File

@@ -14,4 +14,5 @@ export {
of,
lastValueFrom,
firstValueFrom,
switchMap,
} from 'rxjs';

View File

@@ -71,17 +71,8 @@ export const UmbClassMixin = <T extends ClassConstructor>(superClass: T) => {
* @return {UmbObserverController} Reference to a Observer Controller instance
* @memberof UmbElementMixin
*/
observe<T>(
source: Observable<T> | { asObservable: () => Observable<T> },
callback: (_value: T) => void,
controllerAlias?: UmbControllerAlias
) {
return new UmbObserverController<T>(
this,
(source as any).asObservable ? (source as any).asObservable() : source,
callback,
controllerAlias
);
observe<T>(source: Observable<T>, callback: (_value: T) => void, controllerAlias?: UmbControllerAlias) {
return new UmbObserverController<T>(this, source, callback, controllerAlias);
}
/**

View File

@@ -19,8 +19,6 @@ export class UmbContextConsumerController<T = unknown> extends UmbContextConsume
public destroy() {
super.destroy();
if (this.#host) {
this.#host.removeController(this);
}
this.#host.removeController(this);
}
}

View File

@@ -48,13 +48,45 @@ describe('UmbContextConsumer', () => {
const localConsumer = new UmbContextConsumer(
element,
testContextAlias,
(_instance: UmbTestContextConsumerClass) => {
expect(_instance.prop).to.eq('value from provider');
done();
(_instance: UmbTestContextConsumerClass | undefined) => {
if (_instance) {
expect(_instance.prop).to.eq('value from provider');
done();
localConsumer.hostDisconnected();
provider.hostDisconnected();
}
}
);
localConsumer.hostConnected();
provider.hostDisconnected();
});
/*
Unprovided feature is out commented currently. I'm not sure there is a use case. So lets leave the code around until we know for sure.
it('acts to Context API disconnected', (done) => {
const provider = new UmbContextProvider(document.body, testContextAlias, new UmbTestContextConsumerClass());
provider.hostConnected();
const element = document.createElement('div');
document.body.appendChild(element);
let callbackNum = 0;
const localConsumer = new UmbContextConsumer(
element,
testContextAlias,
(_instance: UmbTestContextConsumerClass | undefined) => {
callbackNum++;
if (callbackNum === 1) {
expect(_instance?.prop).to.eq('value from provider');
// unregister.
provider.hostDisconnected();
} else if (callbackNum === 2) {
expect(_instance?.prop).to.be.undefined;
done();
}
}
);
localConsumer.hostConnected();
});
*/
});

View File

@@ -1,5 +1,10 @@
import { UmbContextToken } from '../token/context-token.js';
import { isUmbContextProvideEventType, umbContextProvideEventType } from '../provide/context-provide.event.js';
import {
isUmbContextProvideEventType,
//isUmbContextUnprovidedEventType,
umbContextProvideEventType,
//umbContextUnprovidedEventType,
} from '../provide/context-provide.event.js';
import { UmbContextRequestEventImplementation, UmbContextCallback } from './context-request.event.js';
/**
@@ -34,18 +39,22 @@ export class UmbContextConsumer<T = unknown> {
this.#callback = callback;
}
/* Idea: Niels: If we need to filter for specific contexts, we could make the response method return true/false. If false, the event should then then not be stopped. Alternatively parse the event it self on to the response-callback. This will enable the event to continue to bubble up finding a context that matches. The reason for such would be to have some who are more specific than others. For example, some might just need the current workspace-context, others might need the closest handling a certain entityType. As I'm writting this is not relevant, but I wanted to keep the idea as we have had some circumstance that might be solved with this approach.*/
protected _onResponse = (instance: T) => {
if (this.#instance === instance) {
return;
}
this.#instance = instance;
this.#callback?.(instance);
this.#promiseResolver?.(instance);
if (instance !== undefined) {
this.#promiseResolver?.(instance);
this.#promise = undefined;
}
};
public asPromise() {
return (
this.#promise ||
this.#promise ??
(this.#promise = new Promise<T>((resolve) => {
this.#instance ? resolve(this.#instance) : (this.#promiseResolver = resolve);
}))
@@ -62,16 +71,19 @@ export class UmbContextConsumer<T = unknown> {
public hostConnected() {
// TODO: We need to use closets application element. We need this in order to have separate Backoffice running within or next to each other.
window.addEventListener(umbContextProvideEventType, this._handleNewProvider);
window.addEventListener(umbContextProvideEventType, this.#handleNewProvider);
//window.addEventListener(umbContextUnprovidedEventType, this.#handleRemovedProvider);
this.request();
}
public hostDisconnected() {
// TODO: We need to use closets application element. We need this in order to have separate Backoffice running within or next to each other.
window.removeEventListener(umbContextProvideEventType, this._handleNewProvider);
window.removeEventListener(umbContextProvideEventType, this.#handleNewProvider);
//window.removeEventListener(umbContextUnprovidedEventType, this.#handleRemovedProvider);
}
private _handleNewProvider = (event: Event) => {
#handleNewProvider = (event: Event) => {
// Does seem a bit unnecessary, we could just assume the type via type casting...
if (!isUmbContextProvideEventType(event)) return;
if (this.#contextAlias === event.contextAlias) {
@@ -79,6 +91,25 @@ export class UmbContextConsumer<T = unknown> {
}
};
//Niels: I'm keeping this code around as it might be relevant, but I wanted to try to see if leaving this feature out for now could work for us.
/*
#handleRemovedProvider = (event: Event) => {
// Does seem a bit unnecessary, we could just assume the type via type casting...
if (!isUmbContextUnprovidedEventType(event)) return;
if (this.#contextAlias === event.contextAlias && event.instance === this.#instance) {
this.#unProvide();
}
};
#unProvide() {
if (this.#instance !== undefined) {
this.#instance = undefined;
this.#callback?.(undefined);
}
}
*/
// TODO: Test destroy scenarios:
public destroy() {
this.hostDisconnected();

View File

@@ -25,3 +25,30 @@ export class UmbContextProvideEventImplementation extends Event implements UmbCo
export const isUmbContextProvideEventType = (event: Event): event is UmbContextProvideEventImplementation => {
return event.type === umbContextProvideEventType;
};
export const umbContextUnprovidedEventType = 'umb:context-unprovided';
/**
* @export
* @interface UmbContextProvideEvent
*/
export interface UmbContextUnprovidedEvent extends Event {
readonly contextAlias: string | UmbContextToken;
readonly instance: unknown;
}
/**
* @export
* @class UmbContextUnprovidedEventImplementation
* @extends {Event}
* @implements {UmbContextUnprovidedEvent}
*/
export class UmbContextUnprovidedEventImplementation extends Event implements UmbContextUnprovidedEvent {
public constructor(public readonly contextAlias: string | UmbContextToken, public readonly instance: unknown) {
super(umbContextUnprovidedEventType, { bubbles: true, composed: true });
}
}
export const isUmbContextUnprovidedEventType = (event: Event): event is UmbContextUnprovidedEventImplementation => {
return event.type === umbContextUnprovidedEventType;
};

View File

@@ -42,8 +42,8 @@ describe('UmbContextProviderController', () => {
const localConsumer = new UmbContextConsumer(
element,
'my-test-context',
(_instance: UmbTestContextProviderControllerClass) => {
expect(_instance.prop).to.eq('value from provider');
(_instance: UmbTestContextProviderControllerClass | undefined) => {
expect(_instance?.prop).to.eq('value from provider');
done();
localConsumer.hostDisconnected();
}

View File

@@ -29,9 +29,9 @@ export class UmbContextProviderController<T = unknown> extends UmbContextProvide
}
public destroy() {
super.destroy();
if (this.#host) {
this.#host.removeController(this);
}
super.destroy();
}
}

View File

@@ -17,7 +17,7 @@ export class UmbTestContextElement extends UmbControllerHostElementMixin(HTMLEle
}
describe('UmbContextProvider', () => {
let element: UmbContextProviderElement;
let element: HTMLElement;
let consumer: UmbTestContextElement;
const contextValue = 'test-value';
@@ -27,7 +27,7 @@ describe('UmbContextProvider', () => {
<umb-test-context></umb-test-context>
</umb-context-provider>`
);
consumer = element.getElementsByTagName('umb-test-context')[0] as UmbTestContextElement;
consumer = element.getElementsByTagName('umb-test-context')[0] as unknown as UmbTestContextElement;
});
it('is defined with its own instance', () => {

View File

@@ -58,8 +58,8 @@ describe('UmbContextProvider', () => {
const localConsumer = new UmbContextConsumer(
element,
'my-test-context',
(_instance: UmbTestContextProviderClass) => {
expect(_instance.prop).to.eq('value from provider');
(_instance: UmbTestContextProviderClass | undefined) => {
expect(_instance?.prop).to.eq('value from provider');
done();
localConsumer.hostDisconnected();
}

View File

@@ -4,7 +4,10 @@ import {
umbDebugContextEventType,
} from '../consume/context-request.event.js';
import { UmbContextToken } from '../token/context-token.js';
import { UmbContextProvideEventImplementation } from './context-provide.event.js';
import {
UmbContextProvideEventImplementation,
//UmbContextUnprovidedEventImplementation,
} from './context-provide.event.js';
/**
* @export
@@ -38,11 +41,24 @@ export class UmbContextProvider {
this.#instance = instance;
}
/**
* @private
* @param {UmbContextRequestEvent} event
* @memberof UmbContextProvider
*/
#handleContextRequest = (event: Event) => {
if (!isUmbContextRequestEvent(event)) return;
if (event.contextAlias !== this._contextAlias) return;
event.stopPropagation();
event.callback(this.#instance);
};
/**
* @memberof UmbContextProvider
*/
public hostConnected() {
this.hostElement.addEventListener(umbContextRequestEventType, this._handleContextRequest);
this.hostElement.addEventListener(umbContextRequestEventType, this.#handleContextRequest);
this.hostElement.dispatchEvent(new UmbContextProvideEventImplementation(this._contextAlias));
// Listen to our debug event 'umb:debug-contexts'
@@ -53,23 +69,14 @@ export class UmbContextProvider {
* @memberof UmbContextProvider
*/
public hostDisconnected() {
this.hostElement.removeEventListener(umbContextRequestEventType, this._handleContextRequest);
// TODO: fire unprovided event.
this.hostElement.removeEventListener(umbContextRequestEventType, this.#handleContextRequest);
// Out-commented for now, but kept if we like to reintroduce this:
//window.dispatchEvent(new UmbContextUnprovidedEventImplementation(this._contextAlias, this.#instance));
// Stop listen to our debug event 'umb:debug-contexts'
this.hostElement.removeEventListener(umbDebugContextEventType, this._handleDebugContextRequest);
}
/**
* @private
* @param {UmbContextRequestEvent} event
* @memberof UmbContextProvider
*/
private _handleContextRequest = (event: Event) => {
if (!isUmbContextRequestEvent(event)) return;
if (event.contextAlias !== this._contextAlias) return;
event.stopPropagation();
event.callback(this.#instance);
};
private _handleDebugContextRequest = (event: any) => {
// If the event doesn't have an instances property, create it.
if (!event.instances) {
@@ -85,7 +92,8 @@ export class UmbContextProvider {
};
destroy(): void {
// I want to make sure to call this, but for now it was too overwhelming to require the destroy method on context instances.
// We want to call a destroy method on the instance, if it has one.
(this.#instance as any)?.destroy?.();
this.#instance = undefined;
}
}

View File

@@ -9,9 +9,9 @@ class UmbTestContextTokenClass {
prop = 'value from provider';
}
describe('ContextAlias', () => {
const contextAlias = new UmbContextToken<UmbTestContextTokenClass>(testContextAlias);
const typedProvider = new UmbContextProvider(document.body, contextAlias, new UmbTestContextTokenClass());
describe('UmbContextToken', () => {
const contextToken = new UmbContextToken<UmbTestContextTokenClass>(testContextAlias);
const typedProvider = new UmbContextProvider(document.body, contextToken, new UmbTestContextTokenClass());
typedProvider.hostConnected();
after(() => {
@@ -19,31 +19,41 @@ describe('ContextAlias', () => {
});
it('toString returns the alias', () => {
expect(contextAlias.toString()).to.eq(testContextAlias);
expect(contextToken.toString()).to.eq(testContextAlias);
});
it('can be consumed directly', (done) => {
it('can be used to consume a context API', (done) => {
const element = document.createElement('div');
document.body.appendChild(element);
const localConsumer = new UmbContextConsumer(element, contextAlias, (_instance) => {
expect(_instance).to.be.instanceOf(UmbTestContextTokenClass);
expect(_instance.prop).to.eq('value from provider');
done();
});
const localConsumer = new UmbContextConsumer(
element,
contextToken,
(_instance: UmbTestContextTokenClass | undefined) => {
expect(_instance).to.be.instanceOf(UmbTestContextTokenClass);
expect(_instance?.prop).to.eq('value from provider');
done();
localConsumer.destroy(); // We do not want to react to when the provider is disconnected.
}
);
localConsumer.hostConnected();
});
it('can be consumed using the inner string alias', (done) => {
it('gives the same result when using the string alias', (done) => {
const element = document.createElement('div');
document.body.appendChild(element);
const localConsumer = new UmbContextConsumer(element, testContextAlias, (_instance: UmbTestContextTokenClass) => {
expect(_instance).to.be.instanceOf(UmbTestContextTokenClass);
expect(_instance.prop).to.eq('value from provider');
done();
});
const localConsumer = new UmbContextConsumer(
element,
testContextAlias,
(_instance: UmbTestContextTokenClass | undefined) => {
expect(_instance).to.be.instanceOf(UmbTestContextTokenClass);
expect(_instance?.prop).to.eq('value from provider');
done();
localConsumer.destroy(); // We do not want to react to when the provider is disconnected.
}
);
localConsumer.hostConnected();
});

View File

@@ -107,6 +107,7 @@ export const UmbControllerHostBaseMixin = <T extends ClassConstructor<any>>(supe
destroy() {
this.#controllers.forEach((ctrl: UmbController) => ctrl.destroy());
this.#controllers.length = 0;
}
}

View File

@@ -1,9 +1,9 @@
import type { UmbLocalizeController } from '@umbraco-cms/backoffice/localization-api';
import type { HTMLElementConstructor } from '../extension-api/types.js';
import type { UmbControllerAlias } from './controller-alias.type.js';
import { UmbControllerHostBaseMixin } from './controller-host-base.mixin.js';
import type { UmbControllerHost } from './controller-host.interface.js';
import type { UmbController } from './controller.interface.js';
import type { UmbLocalizeController } from '@umbraco-cms/backoffice/localization-api';
export declare class UmbControllerHostElement extends HTMLElement implements UmbControllerHost {
/**

View File

@@ -1,3 +1,4 @@
import { UmbLocalizeController } from '@umbraco-cms/backoffice/localization-api';
import type { Observable } from '@umbraco-cms/backoffice/external/rxjs';
import type { HTMLElementConstructor } from '@umbraco-cms/backoffice/extension-api';
import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api';
@@ -9,7 +10,6 @@ import {
UmbContextProviderController,
} from '@umbraco-cms/backoffice/context-api';
import { ObserverCallback, UmbObserverController } from '@umbraco-cms/backoffice/observable-api';
import { UmbLocalizeController } from '@umbraco-cms/backoffice/localization-api';
import { property } from '@umbraco-cms/backoffice/external/lit';
export declare class UmbElement extends UmbControllerHostElement {
@@ -47,17 +47,8 @@ export const UmbElementMixin = <T extends HTMLElementConstructor>(superClass: T)
* @return {UmbObserverController} Reference to a Observer Controller instance
* @memberof UmbElementMixin
*/
observe<T>(
source: Observable<T> | { asObservable: () => Observable<T> },
callback: ObserverCallback<T>,
unique?: string
) {
return new UmbObserverController<T>(
this,
(source as any).asObservable ? (source as any).asObservable() : source,
callback,
unique
);
observe<T>(source: Observable<T>, callback: ObserverCallback<T>, unique?: string) {
return new UmbObserverController<T>(this, source, callback, unique);
}
/**

View File

@@ -0,0 +1,6 @@
import type { UmbConditionConfigBase } from '../types.js';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
export type UmbConditionControllerArguments<
ConditionConfigType extends UmbConditionConfigBase = UmbConditionConfigBase
> = { host: UmbControllerHost; config: ConditionConfigType; onChange: () => void };

View File

@@ -0,0 +1,7 @@
import type { UmbConditionConfigBase } from '../types.js';
import { UmbController } from '@umbraco-cms/backoffice/controller-api';
export interface UmbExtensionCondition extends UmbController {
readonly permitted: boolean;
readonly config: UmbConditionConfigBase;
}

View File

@@ -0,0 +1,2 @@
export * from './extension-condition.interface.js';
export * from './condition-controller-arguments.type.js';

View File

@@ -0,0 +1,608 @@
import { expect, fixture } from '@open-wc/testing';
import type {
ManifestCondition,
ManifestKind,
ManifestWithDynamicConditions,
UmbConditionConfigBase,
} from '../types.js';
import { UmbExtensionRegistry } from '../registry/extension.registry.js';
import type { UmbExtensionCondition } from '../condition/extension-condition.interface.js';
import {
UmbControllerHostElement,
UmbControllerHostElementMixin,
} from '../../controller-api/controller-host-element.mixin.js';
import { UmbBaseController } from '../../controller-api/controller.class.js';
import { UmbControllerHost } from '../../controller-api/controller-host.interface.js';
import { UmbBaseExtensionController } from './index.js';
import { customElement, html } from '@umbraco-cms/backoffice/external/lit';
import { UmbSwitchCondition } from '@umbraco-cms/backoffice/extension-registry';
@customElement('umb-test-controller-host')
export class UmbTestControllerHostElement extends UmbControllerHostElementMixin(HTMLElement) {}
class UmbTestExtensionController extends UmbBaseExtensionController {
constructor(
host: UmbControllerHostElement,
extensionRegistry: UmbExtensionRegistry<ManifestWithDynamicConditions>,
alias: string,
onPermissionChanged: (isPermitted: boolean) => void
) {
super(host, extensionRegistry, alias, onPermissionChanged);
this._init();
}
protected async _conditionsAreGood() {
return true;
}
protected async _conditionsAreBad() {
// Destroy the element/class.
}
}
class UmbTestConditionAlwaysValid extends UmbBaseController implements UmbExtensionCondition {
config: UmbConditionConfigBase;
constructor(args: { host: UmbControllerHost; config: UmbConditionConfigBase }) {
super(args.host);
this.config = args.config;
}
permitted = true;
}
class UmbTestConditionAlwaysInvalid extends UmbBaseController implements UmbExtensionCondition {
config: UmbConditionConfigBase;
constructor(args: { host: UmbControllerHost; config: UmbConditionConfigBase }) {
super(args.host);
this.config = args.config;
}
permitted = false;
}
describe('UmbBaseExtensionController', () => {
describe('Manifest without conditions', () => {
let hostElement: UmbControllerHostElement;
let extensionRegistry: UmbExtensionRegistry<ManifestWithDynamicConditions>;
let manifest: ManifestWithDynamicConditions;
beforeEach(async () => {
hostElement = await fixture(html`<umb-test-controller-host></umb-test-controller-host>`);
extensionRegistry = new UmbExtensionRegistry();
manifest = {
type: 'section',
name: 'test-section-1',
alias: 'Umb.Test.Section.1',
};
extensionRegistry.register(manifest);
});
it('permits when there is no conditions', (done) => {
const extensionController = new UmbTestExtensionController(
hostElement,
extensionRegistry,
'Umb.Test.Section.1',
() => {
expect(extensionController.permitted).to.be.true;
if (extensionController.permitted) {
expect(extensionController?.manifest?.alias).to.eq('Umb.Test.Section.1');
// Also verifying that the promise gets resolved.
extensionController.asPromise().then(() => {
done();
});
}
}
);
});
});
describe('Manifest with empty conditions', () => {
let hostElement: UmbControllerHostElement;
let extensionRegistry: UmbExtensionRegistry<ManifestWithDynamicConditions>;
let manifest: ManifestWithDynamicConditions;
beforeEach(async () => {
hostElement = await fixture(html`<umb-test-controller-host></umb-test-controller-host>`);
extensionRegistry = new UmbExtensionRegistry();
manifest = {
type: 'section',
name: 'test-section-1',
alias: 'Umb.Test.Section.1',
conditions: [],
};
extensionRegistry.register(manifest);
});
it('permits when there is no conditions', (done) => {
const extensionController = new UmbTestExtensionController(
hostElement,
extensionRegistry,
'Umb.Test.Section.1',
() => {
expect(extensionController.permitted).to.be.true;
if (extensionController.permitted) {
expect(extensionController?.manifest?.alias).to.eq('Umb.Test.Section.1');
// Also verifying that the promise gets resolved.
extensionController.asPromise().then(() => {
done();
});
}
}
);
});
});
describe('Manifest with valid conditions', () => {
let hostElement: UmbControllerHostElement;
let extensionRegistry: UmbExtensionRegistry<ManifestWithDynamicConditions>;
let manifest: ManifestWithDynamicConditions;
let conditionManifest: ManifestCondition;
beforeEach(async () => {
hostElement = await fixture(html`<umb-test-controller-host></umb-test-controller-host>`);
extensionRegistry = new UmbExtensionRegistry();
manifest = {
type: 'section',
name: 'test-section-1',
alias: 'Umb.Test.Section.1',
conditions: [
{
alias: 'Umb.Test.Condition.Valid',
},
],
};
conditionManifest = {
type: 'condition',
name: 'test-condition-valid',
alias: 'Umb.Test.Condition.Valid',
class: UmbTestConditionAlwaysValid,
};
});
it('does permit when having a valid condition', async () => {
const extensionController = new UmbTestExtensionController(
hostElement,
extensionRegistry,
'Umb.Test.Section.1',
(isPermitted) => {
// No relevant for this test.
expect(isPermitted).to.be.true;
}
);
extensionRegistry.register(manifest);
Promise.resolve().then(() => {
extensionRegistry.register(conditionManifest);
});
await extensionController.asPromise();
expect(extensionController?.manifest?.alias).to.eq('Umb.Test.Section.1');
expect(extensionController?.permitted).to.be.true;
});
it('does not resolve promise when conditions does not exist.', () => {
const extensionController = new UmbTestExtensionController(
hostElement,
extensionRegistry,
'Umb.Test.Section.1',
() => {
expect.fail('Callback should not be called when never permitted');
}
);
extensionController.asPromise().then(() => {
expect.fail('Promise should not resolve');
});
extensionRegistry.register(manifest);
});
it('works with extension manifest with conditions begin changed during usage.', (done) => {
let count = 0;
let initialPromiseResolved = false;
const extensionWithConditions = {
type: 'section',
name: 'test-section-1',
alias: 'Umb.Test.Section.1',
weight: 2,
conditions: [
{
alias: 'Umb.Test.Condition.Valid',
},
],
};
const extensionController = new UmbTestExtensionController(
hostElement,
extensionRegistry,
'Umb.Test.Section.1',
() => {
count++;
if (count === 1) {
// First time render, there is no conditions.
expect(extensionController.manifest?.weight).to.be.equal(2);
expect(extensionController.manifest?.conditions?.length).to.be.equal(1);
} else if (count === 2) {
// Second time render, there is conditions and weight is 22.
expect(extensionController.manifest?.weight).to.be.equal(22);
expect(extensionController.manifest?.conditions?.length).to.be.equal(1);
// Check that the promise has been resolved for the first render to ensure timing is right.
expect(initialPromiseResolved).to.be.true;
done();
extensionController.destroy();
}
}
);
extensionController.asPromise().then(() => {
initialPromiseResolved = true;
extensionRegistry.unregister(extensionWithConditions.alias);
Promise.resolve().then(() => {
extensionRegistry.register({ ...extensionWithConditions, weight: 22 });
});
});
extensionRegistry.register(extensionWithConditions);
extensionRegistry.register(conditionManifest);
});
it('works with extension manifest without conditions begin changed during usage.', (done) => {
let count = 0;
let initialPromiseResolved = false;
const extensionWithNoConditions = {
type: 'section',
name: 'test-section-1',
alias: 'Umb.Test.Section.1',
weight: 3,
conditions: [],
};
const extensionController = new UmbTestExtensionController(
hostElement,
extensionRegistry,
'Umb.Test.Section.1',
() => {
count++;
if (count === 1) {
// First time render, there is no conditions.
expect(extensionController.manifest?.weight).to.be.equal(3);
expect(extensionController.manifest?.conditions?.length).to.be.equal(0);
} else if (count === 2) {
// Second time render, there is conditions and weight is 33.
expect(extensionController.manifest?.weight).to.be.equal(33);
expect(extensionController.manifest?.conditions?.length).to.be.equal(0);
// Check that the promise has been resolved for the first render to ensure timing is right.
expect(initialPromiseResolved).to.be.true;
done();
extensionController.destroy();
}
}
);
extensionController.asPromise().then(() => {
initialPromiseResolved = true;
extensionRegistry.unregister(extensionWithNoConditions.alias);
Promise.resolve().then(() => {
extensionRegistry.register({ ...extensionWithNoConditions, weight: 33 });
});
});
extensionRegistry.register(extensionWithNoConditions);
extensionRegistry.register(conditionManifest);
});
it('works with extension manifest without conditions begin changed to have conditions during usage.', (done) => {
let count = 0;
let initialPromiseResolved = false;
const extensionWithNoConditions = {
type: 'section',
name: 'test-section-1',
alias: 'Umb.Test.Section.1',
weight: 4,
conditions: [],
};
const extensionController = new UmbTestExtensionController(
hostElement,
extensionRegistry,
'Umb.Test.Section.1',
() => {
count++;
if (count === 1) {
// First time render, there is no conditions.
expect(extensionController.manifest?.weight).to.be.equal(4);
expect(extensionController.manifest?.conditions?.length).to.be.equal(0);
} else if (count === 2) {
// Second time render, there is conditions and weight is 33.
expect(extensionController.manifest?.weight).to.be.equal(44);
expect(extensionController.manifest?.conditions?.length).to.be.equal(1);
// Check that the promise has been resolved for the first render to ensure timing is right.
expect(initialPromiseResolved).to.be.true;
done();
extensionController.destroy();
}
}
);
extensionController.asPromise().then(() => {
initialPromiseResolved = true;
extensionRegistry.unregister(extensionWithNoConditions.alias);
Promise.resolve().then(() => {
extensionRegistry.register({ ...manifest, weight: 44 });
});
});
extensionRegistry.register(extensionWithNoConditions);
extensionRegistry.register(conditionManifest);
});
it('works with extension manifest without conditions to pair with a late coming kind.', (done) => {
let count = 0;
let initialPromiseResolved = false;
const extensionWithNoConditions = {
type: 'section',
name: 'test-section-1',
alias: 'Umb.Test.Section.1',
kind: 'test-kind',
conditions: [],
};
const lateComingKind: ManifestKind<ManifestWithDynamicConditions> = {
type: 'kind',
alias: 'Umb.Test.Kind',
matchType: 'section',
matchKind: 'test-kind',
manifest: {
type: 'section',
weight: 123,
},
};
const extensionController = new UmbTestExtensionController(
hostElement,
extensionRegistry,
'Umb.Test.Section.1',
() => {
count++;
if (count === 1) {
// First time render, there is no conditions.
expect(extensionController.manifest?.weight).to.be.undefined;
expect(extensionController.manifest?.conditions?.length).to.be.equal(0);
} else if (count === 2) {
// Second time render, there is a matching kind and then weight is 123.
expect(extensionController.manifest?.weight).to.be.equal(123);
expect(extensionController.manifest?.conditions?.length).to.be.equal(0);
// Check that the promise has been resolved for the first render to ensure timing is right.
expect(initialPromiseResolved).to.be.true;
done();
extensionController.destroy();
}
}
);
extensionController.asPromise().then(() => {
initialPromiseResolved = true;
Promise.resolve().then(() => {
extensionRegistry.register(lateComingKind);
});
});
extensionRegistry.register(extensionWithNoConditions);
extensionRegistry.register(conditionManifest);
});
});
describe('Manifest with invalid conditions', () => {
let hostElement: UmbControllerHostElement;
let extensionRegistry: UmbExtensionRegistry<ManifestWithDynamicConditions>;
let manifest: ManifestWithDynamicConditions;
let conditionManifest: ManifestCondition;
beforeEach(async () => {
hostElement = await fixture(html`<umb-test-controller-host></umb-test-controller-host>`);
extensionRegistry = new UmbExtensionRegistry();
manifest = {
type: 'section',
name: 'test-section-1',
alias: 'Umb.Test.Section.1',
conditions: [
{
alias: 'Umb.Test.Condition.Invalid',
},
],
};
conditionManifest = {
type: 'condition',
name: 'test-condition-invalid',
alias: 'Umb.Test.Condition.Invalid',
class: UmbTestConditionAlwaysInvalid,
};
});
it('does permit when having a valid condition', (done) => {
extensionRegistry.register(manifest);
extensionRegistry.register(conditionManifest);
const extensionController = new UmbTestExtensionController(
hostElement,
extensionRegistry,
'Umb.Test.Section.1',
() => {
// This should not be called.
expect(true).to.be.false;
}
);
Promise.resolve().then(() => {
expect(extensionController?.manifest?.alias).to.eq('Umb.Test.Section.1');
expect(extensionController?.permitted).to.be.false;
done();
extensionController.destroy();
});
});
it('does permit when having a late coming extension', (done) => {
const extensionController = new UmbTestExtensionController(
hostElement,
extensionRegistry,
'Umb.Test.Section.1',
() => {
// This should not be called.
expect(true).to.be.false;
}
);
extensionRegistry.register(manifest);
Promise.resolve().then(() => {
extensionRegistry.register(conditionManifest);
expect(extensionController.manifest?.conditions?.length).to.be.equal(1);
expect(extensionController?.manifest?.alias).to.eq('Umb.Test.Section.1');
expect(extensionController?.permitted).to.be.false;
done();
extensionController.destroy();
});
});
it('provides a Promise that resolved ones it has its manifest', (done) => {
const extensionController = new UmbTestExtensionController(
hostElement,
extensionRegistry,
'Umb.Test.Section.1',
() => {
// Empty callback.
}
);
extensionController.hasConditions().then((hasConditions) => {
expect(hasConditions).to.be.true;
done();
});
extensionRegistry.register(manifest);
Promise.resolve().then(() => {
extensionRegistry.register(conditionManifest);
});
});
});
describe('Manifest with one condition that changes over time', () => {
let hostElement: UmbControllerHostElement;
let extensionRegistry: UmbExtensionRegistry<ManifestWithDynamicConditions>;
let manifest: ManifestWithDynamicConditions<UmbConditionConfigBase & { value: string }>;
beforeEach(async () => {
hostElement = await fixture(html`<umb-test-controller-host></umb-test-controller-host>`);
extensionRegistry = new UmbExtensionRegistry();
manifest = {
type: 'section',
name: 'test-section-1',
alias: 'Umb.Test.Section.1',
conditions: [
{
alias: 'Umb.Test.Condition.Delay',
value: '100',
},
],
};
// A ASCII timeline for the condition, when allowed and then not allowed:
// Condition 0ms 100ms 200ms 300ms
// condition: - + - +
const conditionManifest = {
type: 'condition',
name: 'test-condition-delay',
alias: 'Umb.Test.Condition.Delay',
class: UmbSwitchCondition,
};
extensionRegistry.register(manifest);
extensionRegistry.register(conditionManifest);
});
it('does change permission as condition change', (done) => {
let count = 0;
const extensionController = new UmbTestExtensionController(
hostElement,
extensionRegistry,
'Umb.Test.Section.1',
async () => {
count++;
// We want the controller callback to first fire when conditions are initialized.
expect(extensionController.manifest?.conditions?.length).to.be.equal(1);
expect(extensionController?.manifest?.alias).to.eq('Umb.Test.Section.1');
if (count === 1) {
expect(extensionController?.permitted).to.be.true;
} else if (count === 2) {
expect(extensionController?.permitted).to.be.false;
extensionController.destroy(); // need to destroy the conditions.
done();
}
}
);
});
});
describe('Manifest with multiple conditions that changes over time', () => {
let hostElement: UmbControllerHostElement;
let extensionRegistry: UmbExtensionRegistry<ManifestWithDynamicConditions>;
let manifest: ManifestWithDynamicConditions<UmbConditionConfigBase & { value: string }>;
beforeEach(async () => {
hostElement = await fixture(html`<umb-test-controller-host></umb-test-controller-host>`);
extensionRegistry = new UmbExtensionRegistry();
manifest = {
type: 'section',
name: 'test-section-1',
alias: 'Umb.Test.Section.1',
conditions: [
{
alias: 'Umb.Test.Condition.Delay',
value: '100',
},
{
alias: 'Umb.Test.Condition.Delay',
value: '200',
},
],
};
// A ASCII timeline for the conditions, when allowed and then not allowed:
// Condition 0ms 100ms 200ms 300ms 400ms 500ms
// First condition: - + - + - +
// Second condition: - - + + - -
// Sum: - - - + - -
const conditionManifest = {
type: 'condition',
name: 'test-condition-delay',
alias: 'Umb.Test.Condition.Delay',
class: UmbSwitchCondition,
};
extensionRegistry.register(manifest);
extensionRegistry.register(conditionManifest);
});
it('does change permission as conditions change', (done) => {
let count = 0;
const extensionController = new UmbTestExtensionController(
hostElement,
extensionRegistry,
'Umb.Test.Section.1',
async () => {
count++;
// We want the controller callback to first fire when conditions are initialized.
expect(extensionController.manifest?.conditions?.length).to.be.equal(2);
expect(extensionController?.manifest?.alias).to.eq('Umb.Test.Section.1');
if (count === 1) {
expect(extensionController?.permitted).to.be.true;
// Hack to double check that its two conditions that make up the state:
expect(extensionController.getControllers((controller) => (controller as any).permitted).length).to.equal(
2
);
} else if (count === 2) {
expect(extensionController?.permitted).to.be.false;
// Hack to double check that its two conditions that make up the state, in this case its one, cause we already got the callback when one of the conditions changed. meaning in this split second one is still good:
expect(extensionController.getControllers((controller) => (controller as any).permitted).length).to.equal(
1
);
extensionController.destroy(); // need to destroy the conditions.
done();
}
}
);
});
});
});

View File

@@ -0,0 +1,227 @@
import type { UmbExtensionCondition } from '../condition/extension-condition.interface.js';
import { UmbBaseController, type UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import {
type ManifestCondition,
type ManifestWithDynamicConditions,
type UmbExtensionRegistry,
createExtensionClass,
} from '@umbraco-cms/backoffice/extension-api';
import { UmbObserverController } from '@umbraco-cms/backoffice/observable-api';
export abstract class UmbBaseExtensionController<
ManifestType extends ManifestWithDynamicConditions = ManifestWithDynamicConditions,
SubClassType = never
> extends UmbBaseController {
#promiseResolvers: Array<() => void> = [];
#manifestObserver!: UmbObserverController<ManifestType | undefined>;
#extensionRegistry: UmbExtensionRegistry<ManifestCondition>;
#alias: string;
#overwrites: Array<string> = [];
#manifest?: ManifestType;
#conditionControllers: Array<UmbExtensionCondition> = [];
#onPermissionChanged: (isPermitted: boolean, controller: SubClassType) => void;
protected _positive?: boolean;
#isPermitted?: boolean;
get weight() {
return this.#manifest?.weight ?? 0;
}
get permitted() {
return this.#isPermitted ?? false;
}
get manifest() {
return this.#manifest;
}
get alias() {
return this.#alias;
}
get overwrites() {
return this.#overwrites;
}
hasConditions = async () => {
await this.#manifestObserver.asPromise();
return (this.#manifest?.conditions ?? []).length > 0;
};
constructor(
host: UmbControllerHost,
extensionRegistry: UmbExtensionRegistry<ManifestCondition>,
alias: string,
onPermissionChanged: (isPermitted: boolean, controller: SubClassType) => void
) {
super(host, alias);
this.#extensionRegistry = extensionRegistry;
this.#alias = alias;
this.#onPermissionChanged = onPermissionChanged;
}
protected _init() {
this.#manifestObserver = this.observe(
this.#extensionRegistry.getByAlias<ManifestType>(this.#alias),
async (extensionManifest) => {
this.#isPermitted = undefined;
this.#manifest = extensionManifest;
if (extensionManifest) {
if (extensionManifest.overwrites) {
if (typeof extensionManifest.overwrites === 'string') {
this.#overwrites = [extensionManifest.overwrites];
} else if (Array.isArray(extensionManifest.overwrites)) {
this.#overwrites = extensionManifest.overwrites;
}
}
this.#gotConditions();
} else {
this.#overwrites = [];
this.#cleanConditions();
}
}
);
}
asPromise(): Promise<void> {
return new Promise((resolve) => {
this.#isPermitted === true ? resolve() : this.#promiseResolvers.push(resolve);
});
}
#cleanConditions() {
this.#conditionControllers.forEach((controller) => controller.destroy());
this.#conditionControllers = [];
this.removeControllerByAlias('_observeConditions');
}
#gotConditions() {
const conditionConfigs = this.#manifest?.conditions ?? [];
// As conditionConfigs might have been configured as something else than an array, then we ignorer them.
if (conditionConfigs.length === undefined || conditionConfigs.length === 0) {
this.#cleanConditions();
this.#onConditionsChangedCallback();
return;
}
const conditionAliases = conditionConfigs
.map((condition) => condition.alias)
.filter((value, index, array) => array.indexOf(value) === index);
const oldAmountOfControllers = this.#conditionControllers.length;
// Clean up conditions controllers based on keepers:
this.#conditionControllers = this.#conditionControllers.filter((current) => {
const continueExistence = conditionConfigs.find((config) => config === current.config);
if (!continueExistence) {
// Destroy condition that is no longer needed.
current.destroy();
}
return continueExistence;
});
// Check if there was no change in conditions:
// First check if any got removed(old amount equal controllers after clean-up)
// && check if any new is about to be added(old equal new amount):
const noChangeInConditions =
oldAmountOfControllers === this.#conditionControllers.length &&
oldAmountOfControllers === conditionConfigs.length;
if (conditionConfigs.length > 0) {
// Observes the conditions and initialize as they come in.
this.observe(
this.#extensionRegistry.getByTypeAndAliases('condition', conditionAliases),
async (manifests) => {
// New comers:
manifests.forEach((conditionManifest) => {
const configsOfThisType = conditionConfigs.filter(
(conditionConfig) => conditionConfig.alias === conditionManifest.alias
);
// Spin up conditions, based of condition configs:
configsOfThisType.forEach(async (conditionConfig) => {
// Check if we already have a controller for this config:
const existing = this.#conditionControllers.find((controller) => controller.config === conditionConfig);
if (!existing) {
const conditionController = await createExtensionClass<UmbExtensionCondition>(conditionManifest, [
{
host: this,
manifest: conditionManifest,
config: conditionConfig,
onChange: this.#onConditionsChangedCallback,
},
]);
if (conditionController) {
// Some how listen to it? callback/event/onChange something.
// then call this one: this.#onConditionsChanged();
this.#conditionControllers.push(conditionController);
this.#onConditionsChangedCallback();
}
}
});
});
},
'_observeConditions'
);
} else {
this.removeControllerByAlias('_observeConditions');
}
if (noChangeInConditions) {
// There was not change in the amount of conditions, but the manifest was changed, this means this.#isPermitted is set to undefined and this will always fire the callback:
this.#onConditionsChangedCallback();
}
}
#conditionsAreInitialized() {
// Not good if we don't have a manifest.
// Only good if conditions of manifest is equal to the amount of condition controllers (one for each condition).
return (
this.#manifest !== undefined && this.#conditionControllers.length === (this.#manifest.conditions ?? []).length
);
}
#onConditionsChangedCallback = async () => {
const oldValue = this.#isPermitted ?? false;
// Find a condition that is not permitted (Notice how no conditions, means that this extension is permitted)
const isPositive =
this.#conditionsAreInitialized() &&
this.#conditionControllers.find((condition) => condition.permitted === false) === undefined;
this._positive = isPositive;
if (isPositive) {
if (this.#isPermitted !== true) {
this.#isPermitted = await this._conditionsAreGood();
}
} else if (this.#isPermitted !== false) {
await this._conditionsAreBad();
this.#isPermitted = false;
}
if (oldValue !== this.#isPermitted) {
if (this.#isPermitted) {
this.#promiseResolvers.forEach((x) => x());
this.#promiseResolvers = [];
}
this.#onPermissionChanged(this.#isPermitted, this as any);
}
};
protected abstract _conditionsAreGood(): Promise<boolean>;
protected abstract _conditionsAreBad(): Promise<void>;
public equal(otherClass: UmbBaseExtensionController | undefined): boolean {
return otherClass?.manifest === this.manifest;
}
public destroy(): void {
this.#promiseResolvers = [];
if (this.#isPermitted === true) {
this.#isPermitted = undefined;
this._conditionsAreBad();
this.#onPermissionChanged(false, this as any);
}
super.destroy();
// Destroy the conditions controllers, are begin destroyed cause they are controllers.
}
}

View File

@@ -0,0 +1,358 @@
import { expect, fixture } from '@open-wc/testing';
import { UmbExtensionRegistry } from '../registry/extension.registry.js';
import { ManifestCondition, ManifestWithDynamicConditions, UmbConditionConfigBase } from '../types.js';
import { UmbExtensionCondition } from '../condition/extension-condition.interface.js';
import { PermittedControllerType, UmbBaseExtensionController, UmbBaseExtensionsController } from './index.js';
import {
UmbBaseController,
UmbControllerHost,
UmbControllerHostElementMixin,
} from '@umbraco-cms/backoffice/controller-api';
import { customElement, html } from '@umbraco-cms/backoffice/external/lit';
@customElement('umb-test-controller-host')
class UmbTestControllerHostElement extends UmbControllerHostElementMixin(HTMLElement) {}
class UmbTestExtensionController extends UmbBaseExtensionController {
constructor(
host: UmbControllerHost,
extensionRegistry: UmbExtensionRegistry<ManifestWithDynamicConditions>,
alias: string,
onPermissionChanged: (isPermitted: boolean, controller: UmbTestExtensionController) => void
) {
super(host, extensionRegistry, alias, onPermissionChanged);
this._init();
}
protected async _conditionsAreGood() {
return true;
}
protected async _conditionsAreBad() {
// Destroy the element/class.
}
}
type myTestManifests = ManifestWithDynamicConditions | ManifestCondition;
const testExtensionRegistry = new UmbExtensionRegistry<myTestManifests>();
type myTestManifestTypesUnion = 'extension-type-extra' | 'extension-type';
type myTestManifestTypes = myTestManifestTypesUnion | myTestManifestTypesUnion[];
class UmbTestExtensionsController<
MyPermittedControllerType extends UmbTestExtensionController = PermittedControllerType<UmbTestExtensionController>
> extends UmbBaseExtensionsController<
myTestManifests,
myTestManifestTypesUnion,
ManifestWithDynamicConditions,
UmbTestExtensionController,
MyPermittedControllerType
> {
#host: UmbControllerHost;
constructor(
host: UmbControllerHost,
extensionRegistry: UmbExtensionRegistry<ManifestWithDynamicConditions>,
type: myTestManifestTypes,
filter: null | ((manifest: ManifestWithDynamicConditions) => boolean),
onChange: (permittedManifests: Array<MyPermittedControllerType>, controller: MyPermittedControllerType) => void
) {
super(host, extensionRegistry, type, filter, onChange);
this.#host = host;
this._init();
}
protected _createController(manifest: ManifestWithDynamicConditions) {
return new UmbTestExtensionController(this.#host, testExtensionRegistry, manifest.alias, this._extensionChanged);
}
}
class UmbTestConditionAlwaysValid extends UmbBaseController implements UmbExtensionCondition {
config: UmbConditionConfigBase;
constructor(args: { host: UmbControllerHost; config: UmbConditionConfigBase }) {
super(args.host);
this.config = args.config;
}
permitted = true;
}
class UmbTestConditionAlwaysInvalid extends UmbBaseController implements UmbExtensionCondition {
config: UmbConditionConfigBase;
constructor(args: { host: UmbControllerHost; config: UmbConditionConfigBase }) {
super(args.host);
this.config = args.config;
}
permitted = false;
}
describe('UmbBaseExtensionsController', () => {
describe('Manifests without conditions', () => {
let hostElement: UmbTestControllerHostElement;
beforeEach(async () => {
hostElement = await fixture(html`<umb-test-controller-host></umb-test-controller-host>`);
const manifestA = {
type: 'extension-type',
name: 'test-extension-a',
alias: 'Umb.Test.Extension.A',
};
const manifestB = {
type: 'extension-type',
name: 'test-extension-b',
alias: 'Umb.Test.Extension.B',
};
testExtensionRegistry.register(manifestA);
testExtensionRegistry.register(manifestB);
});
afterEach(() => {
testExtensionRegistry.unregisterMany(['Umb.Test.Extension.A', 'Umb.Test.Extension.B']);
});
it('exposes both manifests', (done) => {
let count = 0;
const extensionController = new UmbTestExtensionsController(
hostElement,
testExtensionRegistry,
'extension-type',
null,
(permitted) => {
count++;
if (count === 1) {
// First callback gives just one. We need to make a feature to gather changes to only reply after a computation cycle if we like to avoid this.
expect(permitted.length).to.eq(1);
}
if (count === 2) {
expect(permitted.length).to.eq(2);
done();
extensionController.destroy();
}
}
);
});
it('consumed multiple types', (done) => {
const manifestExtra = {
type: 'extension-type-extra',
name: 'test-extension-extra',
alias: 'Umb.Test.Extension.Extra',
};
testExtensionRegistry.register(manifestExtra);
let count = 0;
const extensionController = new UmbTestExtensionsController(
hostElement,
testExtensionRegistry,
['extension-type', 'extension-type-extra'],
null,
(permitted) => {
count++;
if (count === 1) {
// First callback gives just one. We need to make a feature to gather changes to only reply after a computation cycle if we like to avoid this.
expect(permitted.length).to.eq(1);
}
if (count === 2) {
expect(permitted.length).to.eq(2);
}
if (count === 3) {
expect(permitted.length).to.eq(3);
expect(permitted[0].alias).to.eq('Umb.Test.Extension.A');
expect(permitted[1].alias).to.eq('Umb.Test.Extension.B');
expect(permitted[2].alias).to.eq('Umb.Test.Extension.Extra');
done();
extensionController.destroy();
}
}
);
});
});
describe('Manifests without conditions overwrites another', () => {
let hostElement: UmbTestControllerHostElement;
beforeEach(async () => {
hostElement = await fixture(html`<umb-test-controller-host></umb-test-controller-host>`);
const manifestA = {
type: 'extension-type',
name: 'test-extension-a',
alias: 'Umb.Test.Extension.A',
};
const manifestB = {
type: 'extension-type',
name: 'test-extension-b',
alias: 'Umb.Test.Extension.B',
overwrites: ['Umb.Test.Extension.A'],
};
testExtensionRegistry.register(manifestA);
testExtensionRegistry.register(manifestB);
});
afterEach(() => {
testExtensionRegistry.unregisterMany(['Umb.Test.Extension.A', 'Umb.Test.Extension.B']);
});
it('exposes just one manifests', (done) => {
let count = 0;
const extensionController = new UmbTestExtensionsController(
hostElement,
testExtensionRegistry,
'extension-type',
null,
(permitted) => {
count++;
if (count === 1) {
// First callback gives just one. We need to make a feature to gather changes to only reply after a computation cycle if we like to avoid this.
expect(permitted.length).to.eq(1);
expect(permitted[0].alias).to.eq('Umb.Test.Extension.A');
}
if (count === 2) {
// Still just equal one, as the second one overwrites the first one.
expect(permitted.length).to.eq(1);
expect(permitted[0].alias).to.eq('Umb.Test.Extension.B');
// lets remove the overwriting extension to see the original coming back.
testExtensionRegistry.unregister('Umb.Test.Extension.B');
} else if (count === 3) {
expect(permitted.length).to.eq(1);
expect(permitted[0].alias).to.eq('Umb.Test.Extension.A');
done();
extensionController.destroy();
}
}
);
});
});
describe('Manifest with valid conditions overwrites another', () => {
let hostElement: UmbTestControllerHostElement;
beforeEach(async () => {
hostElement = await fixture(html`<umb-test-controller-host></umb-test-controller-host>`);
const manifestA = {
type: 'extension-type',
name: 'test-extension-a',
alias: 'Umb.Test.Extension.A',
};
const manifestB = {
type: 'extension-type',
name: 'test-extension-b',
alias: 'Umb.Test.Extension.B',
overwrites: ['Umb.Test.Extension.A'],
conditions: [
{
alias: 'Umb.Test.Condition.Valid',
},
],
};
testExtensionRegistry.register(manifestA);
testExtensionRegistry.register(manifestB);
testExtensionRegistry.register({
type: 'condition',
name: 'test-condition-valid',
alias: 'Umb.Test.Condition.Valid',
class: UmbTestConditionAlwaysValid,
});
});
afterEach(() => {
testExtensionRegistry.unregisterMany([
'Umb.Test.Extension.A',
'Umb.Test.Extension.B',
'Umb.Test.Condition.Valid',
]);
});
it('exposes only the overwriting manifest', (done) => {
let count = 0;
const extensionController = new UmbTestExtensionsController(
hostElement,
testExtensionRegistry,
'extension-type',
null,
(permitted) => {
count++;
if (count === 1) {
// First callback gives just one. We need to make a feature to gather changes to only reply after a computation cycle if we like to avoid this.
expect(permitted.length).to.eq(1);
expect(permitted[0].alias).to.eq('Umb.Test.Extension.A');
}
if (count === 2) {
// Still just equal one, as the second one overwrites the first one.
expect(permitted.length).to.eq(1);
expect(permitted[0].alias).to.eq('Umb.Test.Extension.B');
// lets remove the overwriting extension to see the original coming back.
testExtensionRegistry.unregister('Umb.Test.Extension.B');
} else if (count === 3) {
expect(permitted.length).to.eq(1);
expect(permitted[0].alias).to.eq('Umb.Test.Extension.A');
done();
extensionController.destroy();
}
}
);
});
});
describe('Manifest with invalid conditions does not overwrite another', () => {
let hostElement: UmbTestControllerHostElement;
beforeEach(async () => {
hostElement = await fixture(html`<umb-test-controller-host></umb-test-controller-host>`);
const manifestA = {
type: 'extension-type',
name: 'test-extension-a',
alias: 'Umb.Test.Extension.A',
};
const manifestB = {
type: 'extension-type',
name: 'test-extension-b',
alias: 'Umb.Test.Extension.B',
overwrites: ['Umb.Test.Extension.A'],
conditions: [
{
alias: 'Umb.Test.Condition.Invalid',
},
],
};
// Register opposite order, to ensure B is there when A comes around. A fix to be able to test this. Cause a late registration of B would not cause a change that is test able.
testExtensionRegistry.register(manifestB);
testExtensionRegistry.register(manifestA);
testExtensionRegistry.register({
type: 'condition',
name: 'test-condition-invalid',
alias: 'Umb.Test.Condition.Invalid',
class: UmbTestConditionAlwaysInvalid,
});
});
afterEach(() => {
testExtensionRegistry.unregisterMany([
'Umb.Test.Extension.A',
'Umb.Test.Extension.B',
'Umb.Test.Condition.Invalid',
]);
});
it('exposes only the original manifest', (done) => {
let count = 0;
const extensionController = new UmbTestExtensionsController(
hostElement,
testExtensionRegistry,
'extension-type',
null,
(permitted) => {
count++;
if (count === 1) {
// First callback gives just one. We need to make a feature to gather changes to only reply after a computation cycle if we like to avoid this.
expect(permitted.length).to.eq(1);
expect(permitted[0].alias).to.eq('Umb.Test.Extension.A');
done();
extensionController.destroy();
}
}
);
});
});
// TODO: Test for late coming kinds.
});

View File

@@ -0,0 +1,148 @@
import { map } from '@umbraco-cms/backoffice/external/rxjs';
import type {
ManifestBase,
ManifestTypeMap,
SpecificManifestTypeOrManifestBase,
UmbBaseExtensionController,
UmbExtensionRegistry,
} from '@umbraco-cms/backoffice/extension-api';
import { UmbBaseController, UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
export type PermittedControllerType<ControllerType extends { manifest: any }> = ControllerType & {
manifest: Required<Pick<ControllerType, 'manifest'>>;
};
/**
*/
export abstract class UmbBaseExtensionsController<
ManifestTypes extends ManifestBase,
ManifestTypeName extends keyof ManifestTypeMap<ManifestTypes> | string,
ManifestType extends ManifestBase = SpecificManifestTypeOrManifestBase<ManifestTypes, ManifestTypeName>,
ControllerType extends UmbBaseExtensionController<ManifestType> = UmbBaseExtensionController<ManifestType>,
MyPermittedControllerType extends ControllerType = PermittedControllerType<ControllerType>
> extends UmbBaseController {
#extensionRegistry: UmbExtensionRegistry<ManifestType>;
#type: ManifestTypeName | Array<ManifestTypeName>;
#filter: undefined | null | ((manifest: ManifestType) => boolean);
#onChange: (permittedManifests: Array<MyPermittedControllerType>, controller: MyPermittedControllerType) => void;
protected _extensions: Array<ControllerType> = [];
private _permittedExts: Array<MyPermittedControllerType> = [];
constructor(
host: UmbControllerHost,
extensionRegistry: UmbExtensionRegistry<ManifestType>,
type: ManifestTypeName | Array<ManifestTypeName>,
filter: undefined | null | ((manifest: ManifestType) => boolean),
onChange: (permittedManifests: Array<MyPermittedControllerType>, controller: MyPermittedControllerType) => void
) {
super(host);
this.#extensionRegistry = extensionRegistry;
this.#type = type;
this.#filter = filter;
this.#onChange = onChange;
}
protected _init() {
let source = Array.isArray(this.#type)
? this.#extensionRegistry.extensionsOfTypes<ManifestType>(this.#type as string[])
: this.#extensionRegistry.extensionsOfType<ManifestTypeName, ManifestType>(this.#type as ManifestTypeName);
if (this.#filter) {
source = source.pipe(map((extensions: Array<ManifestType>) => extensions.filter(this.#filter!)));
}
this.observe(source, this.#gotManifests);
}
#gotManifests = (manifests: Array<ManifestType>) => {
if (!manifests) {
// Clean up:
this._extensions.forEach((controller) => {
controller.destroy();
});
this._extensions.length = 0;
// _permittedExts should have been cleared via the destroy callbacks.
return;
}
// Clean up extensions that are no longer.
this._extensions = this._extensions.filter((controller) => {
if (!manifests.find((manifest) => manifest.alias === controller.alias)) {
controller.destroy();
// destroying the controller will, if permitted, make a last callback with isPermitted = false. This will also remove it from the _permittedExts array.
return false;
}
return true;
});
// ---------------------------------------------------------------
// May change this into a Extensions Manager Controller???
// ---------------------------------------------------------------
manifests.forEach((manifest) => {
const existing = this._extensions.find((x) => x.alias === manifest.alias);
if (!existing) {
// Idea: could be abstracted into a createController method, so we can override it in a subclass.
// (This should be enough to be able to create a element extension controller instead.)
this._extensions.push(this._createController(manifest));
}
});
};
protected abstract _createController(manifest: ManifestType): ControllerType;
protected _extensionChanged = (isPermitted: boolean, controller: UmbBaseExtensionController<ManifestType>) => {
let hasChanged = false;
const existingIndex = this._permittedExts.indexOf(controller as unknown as MyPermittedControllerType);
if (isPermitted) {
if (existingIndex === -1) {
this._permittedExts.push(controller as unknown as MyPermittedControllerType);
hasChanged = true;
}
} else {
if (existingIndex !== -1) {
this._permittedExts.splice(existingIndex, 1);
hasChanged = true;
}
}
if (hasChanged) {
// The final list of permitted extensions to be displayed, this will be stripped from extensions that are overwritten by another extension and sorted accordingly.
const exposedPermittedExts = [...this._permittedExts];
// Removal of overwritten extensions:
this._permittedExts.forEach((extCtrl) => {
// Check if it overwrites another extension:
// if so, look up the extension it overwrites, and remove it from the list. and check that for if it overwrites another extension and so on.
if (extCtrl.overwrites.length > 0) {
extCtrl.overwrites.forEach((overwrite) => {
this.#removeOverwrittenExtensions(exposedPermittedExts, overwrite);
});
}
});
// Sorting:
exposedPermittedExts.sort((a, b) => b.weight - a.weight);
this.#onChange(exposedPermittedExts, this as unknown as MyPermittedControllerType);
}
};
#removeOverwrittenExtensions(list: Array<MyPermittedControllerType>, alias: string) {
const index = list.findIndex((a) => a.alias === alias);
if (index !== -1) {
const entry = list[index];
// Remove this extension:
list.splice(index, 1);
// Then remove other extensions that this was replacing:
if (entry.overwrites.length > 0) {
entry.overwrites.forEach((overwrite) => {
this.#removeOverwrittenExtensions(list, overwrite);
});
}
}
}
public destroy() {
super.destroy();
this._extensions.length = 0;
this._permittedExts.length = 0;
}
}

View File

@@ -0,0 +1,150 @@
import { expect, fixture } from '@open-wc/testing';
import { UmbExtensionRegistry } from '../registry/extension.registry.js';
import { UmbExtensionElementController } from './index.js';
import { UmbControllerHostElement, UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api';
import { customElement, html } from '@umbraco-cms/backoffice/external/lit';
import { type ManifestSection, UmbSwitchCondition } from '@umbraco-cms/backoffice/extension-registry';
@customElement('umb-test-controller-host')
export class UmbTestControllerHostElement extends UmbControllerHostElementMixin(HTMLElement) {}
describe('UmbExtensionElementController', () => {
describe('Manifest without conditions', () => {
let hostElement: UmbControllerHostElement;
let extensionRegistry: UmbExtensionRegistry<ManifestSection>;
let manifest: ManifestSection;
beforeEach(async () => {
hostElement = await fixture(html`<umb-test-controller-host></umb-test-controller-host>`);
extensionRegistry = new UmbExtensionRegistry();
manifest = {
type: 'section',
name: 'test-section-1',
alias: 'Umb.Test.Section.1',
elementName: 'section',
meta: {
label: 'my section',
pathname: 'my-section',
},
};
extensionRegistry.register(manifest);
});
it('permits when there is no conditions', (done) => {
let called = false;
const extensionController = new UmbExtensionElementController(
hostElement,
extensionRegistry,
'Umb.Test.Section.1',
(permitted) => {
if (called === false) {
called = true;
expect(permitted).to.be.true;
if (permitted) {
expect(extensionController?.manifest?.alias).to.eq('Umb.Test.Section.1');
expect(extensionController.component?.nodeName).to.eq('SECTION');
done();
extensionController.destroy();
}
}
}
);
});
it('utilized the default element when there is none provided by manifest', (done) => {
extensionRegistry.unregister(manifest.alias);
const noElementManifest = { ...manifest, elementName: undefined };
extensionRegistry.register(noElementManifest);
let called = false;
const extensionController = new UmbExtensionElementController(
hostElement,
extensionRegistry,
'Umb.Test.Section.1',
(permitted) => {
if (called === false) {
called = true;
expect(permitted).to.be.true;
if (permitted) {
expect(extensionController?.manifest?.alias).to.eq('Umb.Test.Section.1');
expect(extensionController.component?.nodeName).to.eq('UMB-TEST-FALLBACK-ELEMENT');
done();
extensionController.destroy();
}
}
},
'umb-test-fallback-element'
);
});
});
describe('Manifest with multiple conditions that changes over time', () => {
let hostElement: UmbControllerHostElement;
let extensionRegistry: UmbExtensionRegistry<ManifestSection>;
let manifest: ManifestSection;
beforeEach(async () => {
hostElement = await fixture(html`<umb-test-controller-host></umb-test-controller-host>`);
extensionRegistry = new UmbExtensionRegistry();
manifest = {
type: 'section',
name: 'test-section-1',
alias: 'Umb.Test.Section.1',
elementName: 'section',
conditions: [
{
alias: 'Umb.Test.Condition.Delay',
frequency: '100',
},
{
alias: 'Umb.Test.Condition.Delay',
frequency: '200',
},
],
} as any;
// A ASCII timeline for the conditions, when allowed and then not allowed:
// Condition 0ms 100ms 200ms 300ms 400ms 500ms
// First condition: - + - + - +
// Second condition: - - + + - -
// Sum: - - - + - -
const conditionManifest = {
type: 'condition',
name: 'test-condition-delay',
alias: 'Umb.Test.Condition.Delay',
class: UmbSwitchCondition,
};
extensionRegistry.register(manifest);
extensionRegistry.register(conditionManifest);
});
it('does change permission as conditions change', (done) => {
let count = 0;
const extensionController = new UmbExtensionElementController(
hostElement,
extensionRegistry,
'Umb.Test.Section.1',
async () => {
count++;
// We want the controller callback to first fire when conditions are initialized.
expect(extensionController.manifest?.conditions?.length).to.be.equal(2);
expect(extensionController?.manifest?.alias).to.eq('Umb.Test.Section.1');
if (count === 1) {
expect(extensionController?.permitted).to.be.true;
expect(extensionController.component?.nodeName).to.eq('SECTION');
} else if (count === 2) {
expect(extensionController?.permitted).to.be.false;
expect(extensionController.component).to.be.undefined;
done();
extensionController.destroy(); // need to destroy the controller.
}
}
);
});
});
});

View File

@@ -0,0 +1,103 @@
import { createExtensionElement } from '../create-extension-element.function.js';
import { UmbExtensionRegistry } from '../registry/extension.registry.js';
import { isManifestElementableType } from '../type-guards/is-manifest-elementable-type.function.js';
import { ManifestCondition, ManifestWithDynamicConditions } from '../types.js';
import { UmbBaseExtensionController } from './base-extension-controller.js';
import { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
export class UmbExtensionElementController<
ManifestType extends ManifestWithDynamicConditions = ManifestWithDynamicConditions,
ControllerType extends UmbExtensionElementController<ManifestType> = any
> extends UmbBaseExtensionController<ManifestType, ControllerType> {
_defaultElement?: string;
_component?: HTMLElement;
/**
* The component that is created for this extension.
* @readonly
* @type {(HTMLElement | undefined)}
*/
public get component() {
return this._component;
}
/**
* The props that are passed to the component.
* @type {Record<string, any>}
* @memberof UmbElementExtensionController
* @example
* ```ts
* const controller = new UmbElementExtensionController(host, extensionRegistry, alias, onPermissionChanged);
* controller.props = { foo: 'bar' };
* ```
* Is equivalent to:
* ```ts
* controller.component.foo = 'bar';
* ```
*/
#properties?: Record<string, unknown>;
get properties() {
return this.#properties;
}
set properties(newVal) {
this.#properties = newVal;
// TODO: we could optimize this so we only re-set the changed props.
this.#assignProperties();
}
constructor(
host: UmbControllerHost,
extensionRegistry: UmbExtensionRegistry<ManifestCondition>,
alias: string,
onPermissionChanged: (isPermitted: boolean, controller: ControllerType) => void,
defaultElement?: string
) {
super(host, extensionRegistry, alias, onPermissionChanged);
this._defaultElement = defaultElement;
this._init();
}
#assignProperties = () => {
if (!this._component || !this.#properties) return;
// TODO: we could optimize this so we only re-set the updated props.
Object.keys(this.#properties).forEach((key) => {
(this._component as any)[key] = this.#properties![key];
});
};
protected async _conditionsAreGood() {
const manifest = this.manifest!; // In this case we are sure its not undefined.
if (isManifestElementableType(manifest)) {
const newComponent = await createExtensionElement(manifest);
if (!this._positive) {
// We are not positive anymore, so we will back out of this creation.
return false;
}
this._component = newComponent;
} else if (this._defaultElement) {
this._component = document.createElement(this._defaultElement);
} else {
this._component = undefined;
// TODO: Lets make an console.error in this case? we could not initialize any component based on this manifest.
}
if (this._component) {
this.#assignProperties();
(this._component as any).manifest = manifest;
return true; // we will confirm we have a component and are still good to go.
}
return false; // we will reject the state, we have no component, we are not good to be shown.
}
protected async _conditionsAreBad() {
// Destroy the element:
if (this._component) {
if ('destroy' in this._component) {
(this._component as unknown as { destroy: () => void }).destroy();
}
this._component = undefined;
}
}
}

View File

@@ -0,0 +1,26 @@
import type { ManifestCondition, ManifestWithDynamicConditions } from '../types.js';
import type { UmbExtensionRegistry } from '../registry/extension.registry.js';
import { UmbBaseExtensionController } from './base-extension-controller.js';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
export class UmbExtensionManifestController<
ManifestType extends ManifestWithDynamicConditions = ManifestWithDynamicConditions
> extends UmbBaseExtensionController<ManifestType> {
constructor(
host: UmbControllerHost,
extensionRegistry: UmbExtensionRegistry<ManifestCondition>,
alias: string,
onPermissionChanged: (isPermitted: boolean, controller: UmbBaseExtensionController<ManifestType>) => void
) {
super(host, extensionRegistry, alias, onPermissionChanged);
this._init();
}
protected async _conditionsAreGood() {
return true;
}
protected async _conditionsAreBad() {
// Destroy the element/class.
}
}

View File

@@ -0,0 +1,68 @@
import { type PermittedControllerType, UmbBaseExtensionsController } from './base-extensions-controller.js';
import {
type ManifestBase,
type ManifestTypeMap,
type SpecificManifestTypeOrManifestBase,
UmbExtensionElementController,
type UmbExtensionRegistry,
} from '@umbraco-cms/backoffice/extension-api';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
/**
*/
export class UmbExtensionsElementController<
ManifestTypes extends ManifestBase,
ManifestTypeName extends keyof ManifestTypeMap<ManifestTypes> | string = string,
ManifestType extends ManifestBase = SpecificManifestTypeOrManifestBase<ManifestTypes, ManifestTypeName>,
ControllerType extends UmbExtensionElementController<ManifestType> = UmbExtensionElementController<ManifestType>,
MyPermittedControllerType extends ControllerType = PermittedControllerType<ControllerType>
> extends UmbBaseExtensionsController<
ManifestTypes,
ManifestTypeName,
ManifestType,
ControllerType,
MyPermittedControllerType
> {
//
#extensionRegistry;
private _defaultElement?: string;
#props?: Record<string, unknown>;
public get properties() {
return this.#props;
}
public set properties(props: Record<string, unknown> | undefined) {
this.#props = props;
this._extensions.forEach((controller) => {
controller.properties = props;
});
}
constructor(
host: UmbControllerHost,
extensionRegistry: UmbExtensionRegistry<ManifestTypes>,
type: ManifestTypeName | Array<ManifestTypeName>,
filter: undefined | null | ((manifest: ManifestType) => boolean),
onChange: (permittedManifests: Array<MyPermittedControllerType>, controller: MyPermittedControllerType) => void,
defaultElement?: string
) {
super(host, extensionRegistry, type, filter, onChange);
this.#extensionRegistry = extensionRegistry;
this._defaultElement = defaultElement;
this._init();
}
protected _createController(manifest: ManifestType) {
const extController = new UmbExtensionElementController<ManifestType>(
this,
this.#extensionRegistry,
manifest.alias,
this._extensionChanged,
this._defaultElement
) as ControllerType;
extController.properties = this.#props;
return extController;
}
}

View File

@@ -0,0 +1,49 @@
import { UmbExtensionManifestController } from './extension-manifest-controller.js';
import { type PermittedControllerType, UmbBaseExtensionsController } from './base-extensions-controller.js';
import {
ManifestBase,
ManifestTypeMap,
SpecificManifestTypeOrManifestBase,
UmbExtensionRegistry,
} from '@umbraco-cms/backoffice/extension-api';
import { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
/**
*/
export class UmbExtensionsManifestController<
ManifestTypes extends ManifestBase,
ManifestTypeName extends keyof ManifestTypeMap<ManifestTypes> | string,
ManifestType extends ManifestBase = SpecificManifestTypeOrManifestBase<ManifestTypes, ManifestTypeName>,
ControllerType extends UmbExtensionManifestController<ManifestType> = UmbExtensionManifestController<ManifestType>,
MyPermittedControllerType extends ControllerType = PermittedControllerType<ControllerType>
> extends UmbBaseExtensionsController<
ManifestTypes,
ManifestTypeName,
ManifestType,
ControllerType,
MyPermittedControllerType
> {
//
#extensionRegistry: UmbExtensionRegistry<ManifestTypes>;
constructor(
host: UmbControllerHost,
extensionRegistry: UmbExtensionRegistry<ManifestTypes>,
type: ManifestTypeName | Array<ManifestTypeName>,
filter: null | ((manifest: ManifestType) => boolean),
onChange: (permittedManifests: Array<MyPermittedControllerType>, controller: MyPermittedControllerType) => void
) {
super(host, extensionRegistry, type, filter, onChange);
this.#extensionRegistry = extensionRegistry;
this._init();
}
protected _createController(manifest: ManifestType) {
return new UmbExtensionManifestController<ManifestType>(
this,
this.#extensionRegistry,
manifest.alias,
this._extensionChanged
) as ControllerType;
}
}

View File

@@ -0,0 +1,6 @@
export * from './base-extension-controller.js';
export * from './base-extensions-controller.js';
export * from './extension-element-controller.js';
export * from './extensions-element-controller.js';
export * from './extension-manifest-controller.js';
export * from './extensions-manifest-controller.js';

View File

@@ -1,12 +1,14 @@
export * from './bundle-extension-initializer.js';
export * from './condition/index.js';
export * from './controller/index.js';
export * from './create-extension-class.function.js';
export * from './create-extension-element-or-fallback.function.js';
export * from './create-extension-element.function.js';
export * from './entry-point-extension-initializer.js';
export * from './entry-point.interface.js';
export * from './has-default-export.function.js';
export * from './has-init-export.function.js';
export * from './load-extension.function.js';
export * from './registry/extension.registry.js';
export * from './type-guards/index.js';
export * from './types.js';
export * from './entry-point.interface.js';

View File

@@ -73,6 +73,18 @@ describe('UmbExtensionRegistry', () => {
.unsubscribe();
});
it('should get an extension by aliases', (done) => {
const aliases = ['Umb.Test.Section.1', 'Umb.Test.Section.2'];
extensionRegistry
.getByTypeAndAliases('section', aliases)
.subscribe((extensions) => {
expect(extensions[0]?.alias).to.eq(aliases[1]);
expect(extensions[1]?.alias).to.eq(aliases[0]);
done();
})
.unsubscribe();
});
describe('getByType', () => {
const type = 'section';

View File

@@ -1,6 +1,13 @@
import type { ManifestTypeMap, ManifestBase, SpecificManifestTypeOrManifestBase, ManifestKind } from '../types.js';
import { UmbBasicState } from '@umbraco-cms/backoffice/observable-api';
import { map, Observable, distinctUntilChanged, combineLatest } from '@umbraco-cms/backoffice/external/rxjs';
import {
map,
Observable,
distinctUntilChanged,
combineLatest,
of,
switchMap,
} from '@umbraco-cms/backoffice/external/rxjs';
function extensionArrayMemoization<T extends Pick<ManifestBase, 'alias'>>(
previousValue: Array<T>,
@@ -17,7 +24,6 @@ function extensionArrayMemoization<T extends Pick<ManifestBase, 'alias'>>(
return true;
}
// Note: Keeping the memoization in two separate function, for performance concern.
function extensionAndKindMatchArrayMemoization<T extends Pick<ManifestBase, 'alias'> & { isMatchedWithKind?: boolean }>(
previousValue: Array<T>,
currentValue: Array<T>
@@ -45,7 +51,7 @@ function extensionAndKindMatchArrayMemoization<T extends Pick<ManifestBase, 'ali
return true;
}
function extensionSingleMemoization<T extends { alias: string }>(
function extensionSingleMemoization<T extends Pick<ManifestBase, 'alias'>>(
previousValue: T | undefined,
currentValue: T | undefined
): boolean {
@@ -55,6 +61,17 @@ function extensionSingleMemoization<T extends { alias: string }>(
return previousValue === currentValue;
}
function extensionAndKindMatchSingleMemoization<
T extends Pick<ManifestBase, 'alias'> & { isMatchedWithKind?: boolean }
>(previousValue: T | undefined, currentValue: T | undefined): boolean {
if (previousValue && currentValue) {
return (
previousValue.alias === currentValue.alias && previousValue.isMatchedWithKind === currentValue.isMatchedWithKind
);
}
return previousValue === currentValue;
}
const sortExtensions = (a: ManifestBase, b: ManifestBase) => (b.weight || 0) - (a.weight || 0);
export class UmbExtensionRegistry<
@@ -70,6 +87,16 @@ export class UmbExtensionRegistry<
public readonly kinds = this._kinds.asObservable();
defineKind(kind: ManifestKind<ManifestTypes>) {
const extensionsValues = this._extensions.getValue();
const extension = extensionsValues.find(
(extension) => extension.alias === (kind as ManifestKind<ManifestTypes>).alias
);
if (extension) {
console.error(`Extension Kind with alias ${(kind as ManifestKind<ManifestTypes>).alias} is already registered`);
return;
}
const nextData = this._kinds
.getValue()
.filter(
@@ -84,7 +111,15 @@ export class UmbExtensionRegistry<
}
register(manifest: ManifestTypes | ManifestKind<ManifestTypes>): void {
// TODO: Consider if we need to implement some safety features here, like checking if the object has a 'type' and/or 'alias'?
if (!manifest.type) {
console.error(`Extension is missing type`, manifest);
return;
}
if (!manifest.alias) {
console.error(`Extension is missing alias`, manifest);
return;
}
if (manifest.type === 'kind') {
this.defineKind(manifest as ManifestKind<ManifestTypes>);
@@ -166,6 +201,38 @@ export class UmbExtensionRegistry<
) as unknown as Observable<Array<ExtensionType>>;
}
getByAlias<T extends ManifestBase = ManifestBase>(alias: string) {
return this.extensions.pipe(
map((exts) => exts.find((ext) => ext.alias === alias)),
distinctUntilChanged(extensionSingleMemoization),
switchMap((ext) => {
if (ext?.kind) {
return this._kindsOfType(ext.type).pipe(
map((kinds) => {
// Specific Extension Meta merge (does not merge conditions)
if (ext) {
// Since we dont have the type up front in this request, we will just get all kinds here and find the matching one:
const baseManifest = kinds.find((kind) => kind.matchKind === ext.kind)?.manifest;
// TODO: This check can go away when making a find kind based on type and kind.
if (baseManifest) {
const merged = { isMatchedWithKind: true, ...baseManifest, ...ext } as any;
if ((baseManifest as any).meta) {
merged.meta = { ...(baseManifest as any).meta, ...(ext as any).meta };
}
return merged;
}
}
return ext;
})
);
}
return of(ext);
}),
distinctUntilChanged(extensionAndKindMatchSingleMemoization)
) as Observable<T | undefined>;
}
getByTypeAndAlias<
Key extends keyof ManifestTypeMap<ManifestTypes> | string,
T extends ManifestBase = SpecificManifestTypeOrManifestBase<ManifestTypes, Key>
@@ -192,10 +259,41 @@ export class UmbExtensionRegistry<
}
return ext;
}),
distinctUntilChanged(extensionSingleMemoization)
distinctUntilChanged(extensionAndKindMatchSingleMemoization)
) as Observable<T | undefined>;
}
getByTypeAndAliases<
Key extends keyof ManifestTypeMap<ManifestTypes> | string,
T extends ManifestBase = SpecificManifestTypeOrManifestBase<ManifestTypes, Key>
>(type: Key, aliases: Array<string>) {
return combineLatest([
this.extensions.pipe(
map((exts) => exts.filter((ext) => ext.type === type && aliases.indexOf(ext.alias) !== -1)),
distinctUntilChanged(extensionArrayMemoization)
),
this._kindsOfType(type),
]).pipe(
map(([exts, kinds]) =>
exts
.map((ext) => {
// Specific Extension Meta merge (does not merge conditions)
const baseManifest = kinds.find((kind) => kind.matchKind === ext.kind)?.manifest;
if (baseManifest) {
const merged = { isMatchedWithKind: true, ...baseManifest, ...ext } as any;
if ((baseManifest as any).meta) {
merged.meta = { ...(baseManifest as any).meta, ...(ext as any).meta };
}
return merged;
}
return ext;
})
.sort(sortExtensions)
),
distinctUntilChanged(extensionAndKindMatchArrayMemoization)
) as Observable<Array<T>>;
}
extensionsOfType<
Key extends keyof ManifestTypeMap<ManifestTypes> | string,
T extends ManifestBase = SpecificManifestTypeOrManifestBase<ManifestTypes, Key>

View File

@@ -1,5 +1,5 @@
import { UmbEntryPointModule } from './entry-point.interface.js';
import { UmbBackofficeExtensionRegistry } from '@umbraco-cms/backoffice/extension-registry';
import type { UmbExtensionCondition } from './condition/index.js';
import type { UmbEntryPointModule } from './entry-point.interface.js';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type HTMLElementConstructor<T = HTMLElement> = new (...args: any[]) => T;
@@ -55,11 +55,39 @@ export interface ManifestKind<ManifestTypes> {
manifest: Partial<ManifestTypes>;
}
export interface ManifestWithConditions<ConditionsType> {
// TODO: Get rid of this type and implements ManifestWithDynamicConditions instead.
export interface ManifestWithConditions<ConditionType> {
/**
* Set the conditions for when the extension should be loaded
*/
conditions: ConditionsType;
conditions: ConditionType;
}
export interface UmbConditionConfigBase<AliasType extends string = string> {
alias: AliasType;
}
export type ConditionTypeMap<ConditionTypes extends UmbConditionConfigBase> = {
[Condition in ConditionTypes as Condition['alias']]: Condition;
} & {
[key: string]: UmbConditionConfigBase;
};
export type SpecificConditionTypeOrUmbConditionConfigBase<
ConditionTypes extends UmbConditionConfigBase,
T extends keyof ConditionTypeMap<ConditionTypes> | string
> = T extends keyof ConditionTypeMap<ConditionTypes> ? ConditionTypeMap<ConditionTypes>[T] : UmbConditionConfigBase;
export interface ManifestWithDynamicConditions<ConditionTypes extends UmbConditionConfigBase = UmbConditionConfigBase>
extends ManifestBase {
/**
* Set the conditions for when the extension should be loaded
*/
conditions?: Array<ConditionTypes>;
/**
* Define one or more extension aliases that this extension should overwrite.
*/
overwrites?: string | Array<string>;
}
export interface ManifestWithLoader<LoaderReturnType> extends ManifestBase {
@@ -169,18 +197,30 @@ export interface ManifestEntryPoint extends ManifestWithLoader<UmbEntryPointModu
/**
* The file location of the javascript file to load in the backoffice
*/
js: string;
js?: string;
}
/**
* This type of extension takes a JS module and registers all exported manifests from the pointed JS file.
*/
export interface ManifestBundle
extends ManifestWithLoader<{ [key: string]: Array<UmbBackofficeExtensionRegistry['MANIFEST_TYPES']> }> {
export interface ManifestBundle<UmbManifestTypes extends ManifestBase = ManifestBase>
extends ManifestWithLoader<{ [key: string]: Array<UmbManifestTypes> }> {
type: 'bundle';
/**
* The file location of the javascript file to load in the backoffice
*/
js: string;
js?: string;
}
/**
* This type of extension takes a JS module and registers all exported manifests from the pointed JS file.
*/
export interface ManifestCondition extends ManifestClass<UmbExtensionCondition> {
type: 'condition';
/**
* The file location of the javascript file to load in the backoffice
*/
js?: string;
}

View File

@@ -1,7 +1,7 @@
import { BehaviorSubject } from '@umbraco-cms/backoffice/external/rxjs';
interface UmbClassStateData {
equal(otherClass: UmbClassStateData | undefined): boolean;
export interface UmbClassStateData {
equal(otherClass: this | undefined): boolean;
}
/**

View File

@@ -13,12 +13,12 @@ describe('UmbDeepState', () => {
});
it('getValue gives the initial data', () => {
expect(subject.value.another).to.be.equal(initialData.another);
expect(subject.getValue().another).to.be.equal(initialData.another);
});
it('update via next', () => {
subject.next({ key: 'some', another: 'myNewValue' });
expect(subject.value.another).to.be.equal('myNewValue');
expect(subject.getValue().another).to.be.equal('myNewValue');
});
it('replays latests, no matter the amount of subscriptions.', (done) => {

View File

@@ -3,9 +3,11 @@ import { Observable } from '@umbraco-cms/backoffice/external/rxjs';
import { UmbController, UmbControllerAlias, UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
export class UmbObserverController<T = unknown> extends UmbObserver<T> implements UmbController {
_alias?: UmbControllerAlias;
#host: UmbControllerHost;
#alias?: UmbControllerAlias;
public get controllerAlias() {
return this._alias;
return this.#alias;
}
constructor(
@@ -15,7 +17,8 @@ export class UmbObserverController<T = unknown> extends UmbObserver<T> implement
alias?: UmbControllerAlias
) {
super(source, callback);
this._alias = alias;
this.#host = host;
this.#alias = alias;
// Lets check if controller is already here:
// No we don't want this, as multiple different controllers might be looking at the same source.
@@ -28,4 +31,9 @@ export class UmbObserverController<T = unknown> extends UmbObserver<T> implement
host.addController(this);
}
destroy(): void {
super.destroy();
this.#host.removeController(this);
}
}

View File

@@ -0,0 +1,53 @@
import { expect } from '@open-wc/testing';
import { UmbObjectState } from './object-state.js';
import { UmbObserver } from './observer.js';
describe('UmbObserver', () => {
type ObjectType = { key: string; another: string };
let subject: UmbObjectState<ObjectType>;
let initialData: ObjectType;
beforeEach(() => {
initialData = { key: 'some', another: 'myValue' };
subject = new UmbObjectState(initialData);
});
it('gets existing data, no matter the amount of observers.', (done) => {
const observable = subject.asObservable();
new UmbObserver(observable, (value) => {
expect(value).to.be.equal(initialData);
});
new UmbObserver(observable, (value) => {
expect(value).to.be.equal(initialData);
done();
});
});
it('provides an asPromise, which is trigged ones it has its first value', (done) => {
let count = 0;
const lateSubject = new UmbObjectState<ObjectType | undefined>(undefined);
const observable = lateSubject.asObservable();
const umbObserver1 = new UmbObserver(observable, (value) => {
if (count === 0) {
expect(value?.another).to.be.equal(undefined);
}
if (count === 1) {
expect(value?.another).to.be.equal(initialData.another);
}
if (count === 2) {
expect(value?.another).to.be.equal('myChangedValue');
done();
}
count++;
});
// Using promise to first set data, after it has gotten none-undefined data. (promise first resolves ones data is not undefined)
umbObserver1.asPromise().then(() => {
lateSubject.update({ another: 'myChangedValue' });
});
expect(count).to.be.equal(1);
lateSubject.next(initialData);
});
});

View File

@@ -1,4 +1,4 @@
import { Observable, Subscription, lastValueFrom } from '@umbraco-cms/backoffice/external/rxjs';
import { Observable, Subscription } from '@umbraco-cms/backoffice/external/rxjs';
export type ObserverCallbackStack<T> = {
next: (_value: T) => void;
@@ -18,8 +18,31 @@ export class UmbObserver<T> {
this.#subscription = source.subscribe(callback);
}
public async asPromise() {
return await lastValueFrom(this.#source);
/**
* provides a promise which is resolved ones the observer got a value that is not undefined.
* Notice this promise will resolve immediately if the Observable holds an empty array or empty string.
*
*/
public asPromise() {
// Notice, we do not want to store and reuse the Promise, cause this promise guarantees that the value is not undefined when resolved. and reusing the promise would not ensure that.
return new Promise<Exclude<T, undefined>>((resolve) => {
let initialCallback = true;
let wantedToClose = false;
const subscription = this.#source.subscribe((value) => {
if (value !== undefined) {
if (initialCallback) {
wantedToClose = true;
} else {
subscription.unsubscribe();
}
resolve(value as Exclude<T, undefined>);
}
});
initialCallback = false;
if (wantedToClose) {
subscription.unsubscribe();
}
});
}
hostConnected() {
@@ -34,6 +57,11 @@ export class UmbObserver<T> {
}
destroy(): void {
this.#subscription.unsubscribe();
if (this.#subscription) {
this.#subscription.unsubscribe();
(this.#source as any) = undefined;
(this.#callback as any) = undefined;
(this.#subscription as any) = undefined;
}
}
}

View File

@@ -22,7 +22,7 @@ class UmbLogViewerSearchesData extends UmbData<SavedLogSearchResponseModel> {
}
}
class UmbLogviewerTemplatesData extends UmbData<LogTemplateResponseModel> {
class UmbLogViewerTemplatesData extends UmbData<LogTemplateResponseModel> {
constructor(data: LogTemplateResponseModel[]) {
super(data);
}
@@ -33,7 +33,7 @@ class UmbLogviewerTemplatesData extends UmbData<LogTemplateResponseModel> {
}
}
class UmbLogviewerMessagesData extends UmbData<LogMessageResponseModel> {
class UmbLogViewerMessagesData extends UmbData<LogMessageResponseModel> {
constructor(data: LogTemplateResponseModel[]) {
super(data);
}
@@ -411,7 +411,7 @@ export const logLevels = {
export const umbLogViewerData = {
searches: new UmbLogViewerSearchesData(savedSearches),
templates: new UmbLogviewerTemplatesData(messageTemplates),
logs: new UmbLogviewerMessagesData(logs),
templates: new UmbLogViewerTemplatesData(messageTemplates),
logs: new UmbLogViewerMessagesData(logs),
logLevels: logLevels,
};

View File

@@ -44,7 +44,7 @@ export class UmbEntityActionsBundleElement extends UmbLitElement {
this.observe(
umbExtensionsRegistry
.extensionsOfType('entityAction')
.pipe(map((actions) => actions.filter((action) => action.conditions.entityTypes.includes(this.entityType!)))),
.pipe(map((actions) => actions.filter((action) => action.meta.entityTypes.includes(this.entityType!)))),
(actions) => {
this._hasActions = actions.length > 0;
},

View File

@@ -1,119 +1,128 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { css, repeat, TemplateResult, customElement, property, state } from '@umbraco-cms/backoffice/external/lit';
import { map } from '@umbraco-cms/backoffice/external/rxjs';
import { createExtensionElement, isManifestElementableType } from '@umbraco-cms/backoffice/extension-api';
import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';
import { type ManifestTypes, umbExtensionsRegistry } from '../../extension-registry/index.js';
import { css, repeat, customElement, property, state, TemplateResult } from '@umbraco-cms/backoffice/external/lit';
import {
type UmbExtensionElementController,
UmbExtensionsElementController,
} from '@umbraco-cms/backoffice/extension-api';
import { UmbLitElement } from '@umbraco-cms/internal/lit-element';
export type InitializedExtension = { alias: string; weight: number; component: HTMLElement | null };
/**
* @element umb-extension-slot
* @description
* @description A element which renderers the extensions of a given type or types.
* @slot default - slot for inserting additional things into this slot.
* @export
* @class UmbExtensionSlot
* @extends {UmbLitElement}
*/
// TODO: Fire change event.
// TODO: Make property that reveals the amount of displayed/permitted extensions.
@customElement('umb-extension-slot')
export class UmbExtensionSlotElement extends UmbLitElement {
#attached = false;
#extensionsController?: UmbExtensionsElementController<ManifestTypes>;
@state()
private _extensions: InitializedExtension[] = [];
private _permittedExts: Array<UmbExtensionElementController> = [];
/**
* The type or types of extensions to render.
* @type {string | string[]}
* @memberof UmbExtensionSlot
* @example
* <umb-extension-slot type="my-extension-type"></umb-extension-slot>
* or multiple:
* <umb-extension-slot .type=${['my-extension-type','another-extension-type']}></umb-extension-slot>
*
*/
@property({ type: String })
public type = '';
public get type(): string | string[] | undefined {
return this.#type;
}
public set type(value: string | string[] | undefined) {
if (value === this.#type) return;
this.#type = value;
if (this.#attached) {
this._observeExtensions();
}
}
#type?: string | string[] | undefined;
/**
* Filter method for extension manifests.
* This is an initial filter taking effect before conditions or overwrites, the extensions will still be filtered by the conditions defined in the manifest.
* @type {(manifest: any) => boolean}
* @memberof UmbExtensionSlot
* @example
* <umb-extension-slot type="my-extension-type" .filter=${(ext) => ext.meta.anyPropToFilter === 'foo'}></umb-extension-slot>
*
*/
@property({ type: Object, attribute: false })
public filter: (manifest: any) => boolean = () => true;
public get filter(): (manifest: any) => boolean {
return this.#filter;
}
public set filter(value: (manifest: any) => boolean) {
if (value === this.#filter) return;
this.#filter = value;
if (this.#attached) {
this._observeExtensions();
}
}
#filter: (manifest: any) => boolean = () => true;
private _props?: Record<string, any> = {};
/**
* Properties to pass to the extensions elements.
* Notice: The individual manifest of the extension is parsed to each extension element no matter if this is set or not.
* @type {Record<string, any>}
* @memberof UmbExtensionSlot
* @example
* <umb-extension-slot type="my-extension-type" .props=${{foo: 'bar'}}></umb-extension-slot>
*/
@property({ type: Object, attribute: false })
get props() {
return this._props;
return this.#props;
}
set props(newVal) {
this._props = newVal;
// TODO: we could optimize this so we only re-set the updated props.
this.#assignPropsToAllComponents();
set props(newVal: Record<string, unknown> | undefined) {
// TODO, compare changes since last time. only reset the ones that changed. This might be better done by the controller is self:
this.#props = newVal;
if (this.#extensionsController) {
this.#extensionsController.properties = newVal;
}
}
#props?: Record<string, unknown> = {};
@property({ type: String, attribute: 'default-element' })
public defaultElement = '';
@property()
public renderMethod?: (extension: InitializedExtension) => TemplateResult<1 | 2> | HTMLElement | null;
public renderMethod?: (extension: UmbExtensionElementController) => TemplateResult | HTMLElement | null | undefined;
connectedCallback(): void {
super.connectedCallback();
this._observeExtensions();
this.#attached = true;
}
private _observeExtensions() {
this.observe(
umbExtensionsRegistry?.extensionsOfType(this.type).pipe(map((extensions) => extensions.filter(this.filter))),
async (extensions) => {
const oldValue = this._extensions;
const oldLength = this._extensions.length;
this._extensions = this._extensions.filter((current) =>
extensions.find((incoming) => incoming.alias === current.alias)
);
if (this._extensions.length !== oldLength) {
this.requestUpdate('_extensions', oldValue);
}
extensions.forEach(async (extension) => {
const hasExt = this._extensions.find((x) => x.alias === extension.alias);
if (!hasExt) {
const extensionObject: InitializedExtension = {
alias: extension.alias,
weight: (extension as any).weight || 0,
component: null,
};
this._extensions.push(extensionObject);
// sort:
this._extensions.sort((a, b) => b.weight - a.weight);
let component;
if (isManifestElementableType(extension)) {
component = await createExtensionElement(extension);
} else if (this.defaultElement) {
component = document.createElement(this.defaultElement);
} else {
// TODO: Lets make an console.error in this case?
}
if (component) {
this.#assignProps(component);
(component as any).manifest = extension;
extensionObject.component = component;
} else {
// Remove cause we could not get the component, so we will get rid of this.
//this._extensions.splice(this._extensions.indexOf(extensionObject), 1);
// Actually not, because if, then the same extension would come around again in next update.
}
this.requestUpdate('_extensions', oldValue);
}
});
}
);
this.#extensionsController?.destroy();
if (this.#type) {
this.#extensionsController = new UmbExtensionsElementController(
this,
umbExtensionsRegistry,
this.#type,
this.filter,
(extensionControllers) => {
this._permittedExts = extensionControllers;
},
this.defaultElement
);
this.#extensionsController.properties = this.#props;
}
}
#assignPropsToAllComponents() {
this._extensions.forEach((ext) => this.#assignProps(ext.component));
}
#assignProps = (component: HTMLElement | null) => {
if (!component || !this._props) return;
Object.keys(this._props).forEach((key) => {
(component as any)[key] = this._props?.[key];
});
};
render() {
// TODO: check if we can use repeat directly.
return repeat(
this._extensions,
this._permittedExts,
(ext) => ext.alias,
(ext) => (this.renderMethod ? this.renderMethod(ext) : ext.component)
);

View File

@@ -1,13 +1,16 @@
import { expect, fixture, html } from '@open-wc/testing';
import { InitializedExtension, UmbExtensionSlotElement } from './extension-slot.element.js';
import { UmbExtensionSlotElement } from './extension-slot.element.js';
import { customElement } from '@umbraco-cms/backoffice/external/lit';
import { ManifestDashboard, umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';
import { UmbExtensionElementController } from '@umbraco-cms/backoffice/extension-api';
@customElement('umb-test-extension-slot-manifest-element')
class UmbTestExtensionSlotManifestElement extends HTMLElement {}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
function sleep(timeMs: number) {
return new Promise((resolve) => {
setTimeout(resolve, timeMs);
});
}
describe('UmbExtensionSlotElement', () => {
@@ -54,9 +57,6 @@ describe('UmbExtensionSlotElement', () => {
meta: {
pathname: 'test/test',
},
conditions: {
sections: ['test'],
},
});
});
@@ -65,6 +65,14 @@ describe('UmbExtensionSlotElement', () => {
});
it('renders a manifest element', async () => {
element = await fixture(html`<umb-extension-slot type="dashboard"></umb-extension-slot>`);
await sleep(0);
expect(element.shadowRoot!.firstElementChild).to.be.instanceOf(UmbTestExtensionSlotManifestElement);
});
it('works with the filtering method', async () => {
element = await fixture(
html`<umb-extension-slot
type="dashboard"
@@ -81,7 +89,7 @@ describe('UmbExtensionSlotElement', () => {
html` <umb-extension-slot
type="dashboard"
.filter=${(x: ManifestDashboard) => x.alias === 'unit-test-ext-slot-element-manifest'}
.renderMethod=${(manifest: InitializedExtension) => html`<bla>${manifest.component}</bla>`}>
.renderMethod=${(controller: UmbExtensionElementController) => html`<bla>${controller.component}</bla>`}>
</umb-extension-slot>`
);
@@ -92,5 +100,20 @@ describe('UmbExtensionSlotElement', () => {
UmbTestExtensionSlotManifestElement
);
});
it('parses the props', async () => {
element = await fixture(
html` <umb-extension-slot
type="dashboard"
.filter=${(x: ManifestDashboard) => x.alias === 'unit-test-ext-slot-element-manifest'}
.props=${{ testProp: 'fooBar' }}>
</umb-extension-slot>`
);
await sleep(0);
expect((element.shadowRoot!.firstElementChild as any).testProp).to.be.equal('fooBar');
expect(element.shadowRoot!.firstElementChild).to.be.instanceOf(UmbTestExtensionSlotManifestElement);
});
});
});

View File

@@ -7,7 +7,7 @@ import type { UmbVariantId } from '@umbraco-cms/backoffice/variant';
import type { DataTypeResponseModel, PropertyTypeModelBaseModel } from '@umbraco-cms/backoffice/backend-api';
import { UmbLitElement } from '@umbraco-cms/internal/lit-element';
import { UmbObserverController } from '@umbraco-cms/backoffice/observable-api';
import { UMB_ENTITY_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/workspace';
import { UMB_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/workspace';
import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';
@customElement('umb-property-type-based-property')
export class UmbPropertyTypeBasedPropertyElement extends UmbLitElement {
@@ -61,7 +61,7 @@ export class UmbPropertyTypeBasedPropertyElement extends UmbLitElement {
constructor() {
super();
this.consumeContext(UMB_ENTITY_WORKSPACE_CONTEXT, (workspaceContext) => {
this.consumeContext(UMB_WORKSPACE_CONTEXT, (workspaceContext) => {
this._workspaceContext = workspaceContext as UmbDocumentWorkspaceContext;
this._observeProperty();
});

View File

@@ -1,50 +1,38 @@
import { html, customElement, property, state } from '@umbraco-cms/backoffice/external/lit';
import { map } from '@umbraco-cms/backoffice/external/rxjs';
import { ManifestEntityAction, umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';
import { ManifestEntityAction } from '@umbraco-cms/backoffice/extension-registry';
import { UmbLitElement } from '@umbraco-cms/internal/lit-element';
@customElement('umb-entity-action-list')
export class UmbEntityActionListElement extends UmbLitElement {
private _entityType = '';
@property({ type: String, attribute: 'entity-type' })
public get entityType() {
public get entityType(): string {
return this._entityType;
}
public set entityType(value) {
const oldValue = this._entityType;
public set entityType(value: string) {
if (value === this._entityType) return;
this._entityType = value;
if (oldValue !== this._entityType) {
this.#observeEntityActions();
this.requestUpdate('entityType', oldValue);
}
const oldValue = this._filter;
this._filter = (extension: ManifestEntityAction) => extension.meta.entityTypes.includes(this.entityType);
this.requestUpdate('_filter', oldValue);
}
@property({ type: String })
public unique?: string;
private _entityType: string = '';
@state()
private _entityActions?: Array<ManifestEntityAction>;
_filter?: (extension: ManifestEntityAction) => boolean;
// TODO: find a solution to use extension slot
#observeEntityActions() {
this.observe(
umbExtensionsRegistry.extensionsOfType('entityAction').pipe(
map((extensions) => {
return extensions.filter((extension) => extension.conditions.entityTypes.includes(this.entityType));
})
),
(actions) => {
this._entityActions = actions;
}
);
}
@property({ type: String })
public unique?: string | null;
render() {
return html`
${this._entityActions?.map(
(manifest) => html`<umb-entity-action .unique=${this.unique} .manifest=${manifest}></umb-entity-action>`
)}
`;
return this._filter
? html`
<umb-extension-slot
type="entityAction"
default-element="umb-entity-action"
.filter=${this._filter}
.props=${{ unique: this.unique }}></umb-extension-slot>
`
: '';
}
}

View File

@@ -0,0 +1,4 @@
export { UmbSwitchCondition } from './switch.condition.js';
/*
export { UmbSectionAliasCondition } from './section-alias.condition.js';
*/

View File

@@ -0,0 +1,4 @@
import { manifest as switchConditionManifest } from './switch.condition.js';
import { manifest as sectionAliasConditionManifest } from './section-alias.condition.js';
export const manifests = [switchConditionManifest, sectionAliasConditionManifest];

View File

@@ -0,0 +1,37 @@
import { UMB_MENU_CONTEXT_TOKEN } from '../../menu/menu.context.js';
import { UmbBaseController } from '@umbraco-cms/backoffice/controller-api';
import {
ManifestCondition,
UmbConditionConfigBase,
UmbConditionControllerArguments,
UmbExtensionCondition,
} from '@umbraco-cms/backoffice/extension-api';
export type MenuAliasConditionConfig = UmbConditionConfigBase & {
match: string;
};
export class UmbMenuAliasCondition extends UmbBaseController implements UmbExtensionCondition {
config: MenuAliasConditionConfig;
permitted = false;
#onChange: () => void;
constructor(args: UmbConditionControllerArguments<MenuAliasConditionConfig>) {
super(args.host);
this.config = args.config;
this.#onChange = args.onChange;
this.consumeContext(UMB_MENU_CONTEXT_TOKEN, (context) => {
this.observe(context.alias, (MenuAlias) => {
this.permitted = MenuAlias === this.config.match;
this.#onChange();
});
});
}
}
export const manifest: ManifestCondition = {
type: 'condition',
name: 'Menu Alias Condition',
alias: 'Umb.Condition.MenuAlias',
class: UmbMenuAliasCondition,
};

View File

@@ -0,0 +1,43 @@
import { UmbBaseController } from '@umbraco-cms/backoffice/controller-api';
import {
ManifestCondition,
UmbConditionConfigBase,
UmbConditionControllerArguments,
UmbExtensionCondition,
} from '@umbraco-cms/backoffice/extension-api';
import { UMB_SECTION_CONTEXT_TOKEN } from '@umbraco-cms/backoffice/section';
export class UmbSectionAliasCondition extends UmbBaseController implements UmbExtensionCondition {
config: SectionAliasConditionConfig;
permitted = false;
#onChange: () => void;
constructor(args: UmbConditionControllerArguments<SectionAliasConditionConfig>) {
super(args.host);
this.config = args.config;
this.#onChange = args.onChange;
this.consumeContext(UMB_SECTION_CONTEXT_TOKEN, (context) => {
this.observe(context.alias, (sectionAlias) => {
this.permitted = sectionAlias === this.config.match;
this.#onChange();
});
});
}
}
export type SectionAliasConditionConfig = UmbConditionConfigBase<'Umb.Condition.SectionAlias'> & {
/**
* Define the section that this extension should be available in
*
* @example
* "Umb.Section.Content"
*/
match: string;
};
export const manifest: ManifestCondition = {
type: 'condition',
name: 'Section Alias Condition',
alias: 'Umb.Condition.SectionAlias',
class: UmbSectionAliasCondition,
};

View File

@@ -0,0 +1,54 @@
import { UmbBaseController, UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import {
ManifestCondition,
UmbConditionConfigBase,
UmbExtensionCondition,
} from '@umbraco-cms/backoffice/extension-api';
export class UmbSwitchCondition extends UmbBaseController implements UmbExtensionCondition {
#timer?: ReturnType<typeof setTimeout>;
config: SwitchConditionConfig;
permitted = false;
#onChange: () => void;
constructor(args: { host: UmbControllerHost; config: SwitchConditionConfig; onChange: () => void }) {
super(args.host);
this.config = args.config;
this.#onChange = args.onChange;
this.startApprove();
}
startApprove() {
clearTimeout(this.#timer);
this.#timer = setTimeout(() => {
this.permitted = true;
this.#onChange();
this.startDisapprove();
}, parseInt(this.config.frequency));
}
startDisapprove() {
clearTimeout(this.#timer);
this.#timer = setTimeout(() => {
this.permitted = false;
this.#onChange();
this.startApprove();
}, parseInt(this.config.frequency));
}
destroy() {
clearTimeout(this.#timer);
super.destroy();
}
}
export const manifest: ManifestCondition = {
type: 'condition',
name: 'Switch Condition',
alias: 'Umb.Condition.Switch',
class: UmbSwitchCondition,
};
export type SwitchConditionConfig = UmbConditionConfigBase & {
frequency: string;
};

View File

@@ -0,0 +1,10 @@
import type { SectionAliasConditionConfig } from './section-alias.condition.js';
import type { SwitchConditionConfig } from './switch.condition.js';
import type { WorkspaceAliasConditionConfig } from '@umbraco-cms/backoffice/workspace';
import { UmbConditionConfigBase } from '@umbraco-cms/backoffice/extension-api';
export type ConditionTypes =
| SectionAliasConditionConfig
| WorkspaceAliasConditionConfig
| SwitchConditionConfig
| UmbConditionConfigBase;

View File

@@ -1,5 +1,6 @@
export * from './conditions/index.js';
export * from './extension-class-initializer.js';
export * from './interfaces/index.js';
export * from './models/index.js';
export * from './multi-extensions-class-initializer.js';
export * from './registry.js';
export * from './extension-class-initializer.js';
export * from './class-extensions-initializer.js';

View File

@@ -1,9 +1,10 @@
import { ConditionTypes } from '../conditions/types.js';
import type { UmbDashboardExtensionElement } from '../interfaces/index.js';
import type { ManifestElement, ManifestWithConditions } from '@umbraco-cms/backoffice/extension-api';
import type { ManifestElement, ManifestWithDynamicConditions } from '@umbraco-cms/backoffice/extension-api';
export interface ManifestDashboard
extends ManifestElement<UmbDashboardExtensionElement>,
ManifestWithConditions<ConditionsDashboard> {
ManifestWithDynamicConditions<ConditionTypes> {
type: 'dashboard';
meta: MetaDashboard;
}
@@ -26,17 +27,8 @@ export interface MetaDashboard {
pathname?: string;
}
/*
export interface ConditionsDashboard {
/**
* An array of section aliases that the dashboard should be available in
*
* @uniqueItems true
* @minItems 1
* @items.examples [
* "Umb.Section.Content",
* "Umb.Section.Settings"
* ]
*
*/
sections: string[];
}
*/

View File

@@ -1,13 +1,12 @@
import type { ManifestElement } from '@umbraco-cms/backoffice/extension-api';
import type { ManifestElement, ManifestWithDynamicConditions } from '@umbraco-cms/backoffice/extension-api';
/**
* An action to perform on an entity
* For example for content you may wish to create a new document etc
*/
export interface ManifestEntityAction extends ManifestElement {
export interface ManifestEntityAction extends ManifestElement, ManifestWithDynamicConditions {
type: 'entityAction';
meta: MetaEntityAction;
conditions: ConditionsEntityAction;
}
export interface MetaEntityAction {
@@ -44,9 +43,7 @@ export interface MetaEntityAction {
* ]
*/
repositoryAlias: string;
}
export interface ConditionsEntityAction {
/**
* The entity types that this action can be performed on
* @examples [

View File

@@ -28,7 +28,12 @@ import type { ManifestWorkspace } from './workspace.model.js';
import type { ManifestWorkspaceAction } from './workspace-action.model.js';
import type { ManifestWorkspaceEditorView } from './workspace-editor-view.model.js';
import type { ManifestWorkspaceViewCollection } from './workspace-view-collection.model.js';
import type { ManifestBase, ManifestBundle, ManifestEntryPoint } from '@umbraco-cms/backoffice/extension-api';
import type {
ManifestBase,
ManifestBundle,
ManifestCondition,
ManifestEntryPoint,
} from '@umbraco-cms/backoffice/extension-api';
export * from './collection-view.model.js';
export * from './dashboard-collection.model.js';
@@ -62,7 +67,8 @@ export * from './workspace-editor-view.model.js';
export * from './workspace.model.js';
export type ManifestTypes =
| ManifestBundle
| ManifestBundle<ManifestTypes>
| ManifestCondition
| ManifestCollectionView
| ManifestDashboard
| ManifestDashboardCollection

View File

@@ -4,16 +4,12 @@ import type { ManifestElement } from '@umbraco-cms/backoffice/extension-api';
export interface ManifestMenuItem extends ManifestElement<UmbMenuItemExtensionElement> {
type: 'menuItem';
meta: MetaMenuItem;
conditions: ConditionsMenuItem;
}
export interface MetaMenuItem {
label: string;
icon: string;
entityType?: string;
}
export interface ConditionsMenuItem {
menus: Array<string>;
}
@@ -23,9 +19,6 @@ export interface ManifestMenuItemTreeKind extends ManifestMenuItem {
meta: MetaMenuItemTreeKind;
}
export interface MetaMenuItemTreeKind {
export interface MetaMenuItemTreeKind extends MetaMenuItem {
treeAlias: string;
label: string;
icon: string;
entityType?: string;
}

View File

@@ -1,9 +1,11 @@
import type { ManifestElement, ManifestWithConditions } from '@umbraco-cms/backoffice/extension-api';
import type { ConditionTypes } from '../conditions/types.js';
import type { ManifestElement, ManifestWithDynamicConditions } from '@umbraco-cms/backoffice/extension-api';
export interface ManifestPropertyAction extends ManifestElement, ManifestWithConditions<ConditionsPropertyAction> {
export interface ManifestPropertyAction extends ManifestElement, ManifestWithDynamicConditions<ConditionTypes> {
type: 'propertyAction';
meta: MetaPropertyAction;
}
export interface ConditionsPropertyAction {
export interface MetaPropertyAction {
propertyEditors: string[];
}

View File

@@ -1,13 +1,11 @@
import { ConditionTypes } from '../conditions/types.js';
import type { UmbSectionSidebarAppExtensionElement } from '../interfaces/section-sidebar-app-extension-element.interface.js';
import type { ManifestElement } from '@umbraco-cms/backoffice/extension-api';
import type { ManifestElement, ManifestWithDynamicConditions } from '@umbraco-cms/backoffice/extension-api';
export interface ManifestSectionSidebarApp extends ManifestElement<UmbSectionSidebarAppExtensionElement> {
export interface ManifestSectionSidebarApp
extends ManifestElement<UmbSectionSidebarAppExtensionElement>,
ManifestWithDynamicConditions<ConditionTypes> {
type: 'sectionSidebarApp';
conditions: ConditionsSectionSidebarApp;
}
export interface ConditionsSectionSidebarApp {
sections: Array<string>;
}
export interface ManifestSectionSidebarAppBaseMenu extends ManifestSectionSidebarApp {

View File

@@ -1,9 +1,10 @@
import { ConditionTypes } from '../conditions/types.js';
import type { UmbSectionViewExtensionElement } from '../interfaces/section-view-extension-element.interface.js';
import type { ManifestElement, ManifestWithConditions } from '@umbraco-cms/backoffice/extension-api';
import type { ManifestElement, ManifestWithDynamicConditions } from '@umbraco-cms/backoffice/extension-api';
export interface ManifestSectionView
extends ManifestElement<UmbSectionViewExtensionElement>,
ManifestWithConditions<ConditionsSectionView> {
ManifestWithDynamicConditions<ConditionTypes> {
type: 'sectionView';
meta: MetaSectionView;
}
@@ -34,7 +35,3 @@ export interface MetaSectionView {
*/
icon: string;
}
export interface ConditionsSectionView {
sections: Array<string>;
}

View File

@@ -1,7 +1,7 @@
import type { UmbSectionExtensionElement } from '../interfaces/index.js';
import type { ManifestElement } from '@umbraco-cms/backoffice/extension-api';
import type { ManifestElement, ManifestWithDynamicConditions } from '@umbraco-cms/backoffice/extension-api';
export interface ManifestSection extends ManifestElement<UmbSectionExtensionElement> {
export interface ManifestSection extends ManifestElement<UmbSectionExtensionElement>, ManifestWithDynamicConditions {
type: 'section';
meta: MetaSection;
}

View File

@@ -3,9 +3,9 @@ import type { ManifestElement } from '@umbraco-cms/backoffice/extension-api';
export interface ManifestTreeItem extends ManifestElement<UmbTreeItemExtensionElement> {
type: 'treeItem';
conditions: ConditionsTreeItem;
meta: MetaTreeItem;
}
export interface ConditionsTreeItem {
export interface MetaTreeItem {
entityTypes: Array<string>;
}

View File

@@ -1,11 +1,15 @@
import type { ConditionTypes } from '../conditions/types.js';
import type { InterfaceColor, InterfaceLook } from '@umbraco-cms/backoffice/external/uui';
import type { ManifestElement, ClassConstructor } from '@umbraco-cms/backoffice/extension-api';
import { UmbWorkspaceAction } from '@umbraco-cms/backoffice/workspace';
import type {
ManifestElement,
ClassConstructor,
ManifestWithDynamicConditions,
} from '@umbraco-cms/backoffice/extension-api';
import type { UmbWorkspaceAction } from '@umbraco-cms/backoffice/workspace';
export interface ManifestWorkspaceAction extends ManifestElement {
export interface ManifestWorkspaceAction extends ManifestElement, ManifestWithDynamicConditions<ConditionTypes> {
type: 'workspaceAction';
meta: MetaWorkspaceAction;
conditions: ConditionsWorkspaceAction;
}
export interface MetaWorkspaceAction {
@@ -14,7 +18,3 @@ export interface MetaWorkspaceAction {
color?: InterfaceColor;
api: ClassConstructor<UmbWorkspaceAction>;
}
export interface ConditionsWorkspaceAction {
workspaces: Array<string>;
}

View File

@@ -1,11 +1,9 @@
import { ConditionTypes } from '../conditions/types.js';
import type { UmbWorkspaceEditorViewExtensionElement } from '../interfaces/workspace-editor-view-extension-element.interface.js';
import type { ManifestWithView } from '@umbraco-cms/backoffice/extension-api';
import type { ManifestWithDynamicConditions, ManifestWithView } from '@umbraco-cms/backoffice/extension-api';
export interface ManifestWorkspaceEditorView extends ManifestWithView<UmbWorkspaceEditorViewExtensionElement> {
export interface ManifestWorkspaceEditorView
extends ManifestWithView<UmbWorkspaceEditorViewExtensionElement>,
ManifestWithDynamicConditions<ConditionTypes> {
type: 'workspaceEditorView';
conditions: ConditionsWorkspaceView;
}
export interface ConditionsWorkspaceView {
workspaces: string[];
}

View File

@@ -1,12 +1,13 @@
import { ConditionTypes } from '../conditions/types.js';
import type {
ManifestWithConditions,
ManifestWithDynamicConditions,
ManifestWithView,
MetaManifestWithView,
} from '@umbraco-cms/backoffice/extension-api';
export interface ManifestWorkspaceViewCollection
extends ManifestWithView,
ManifestWithConditions<ConditionsEditorViewCollection> {
ManifestWithDynamicConditions<ConditionTypes> {
type: 'workspaceViewCollection';
meta: MetaEditorViewCollection;
}
@@ -29,31 +30,3 @@ export interface MetaEditorViewCollection extends MetaManifestWithView {
*/
repositoryAlias: string;
}
export interface ConditionsEditorViewCollection {
/**
* The workspaces that this view collection should be available in
*
* @examples [
* "Umb.Workspace.DataType",
* "Umb.Workspace.Dictionary",
* "Umb.Workspace.Document",
* "Umb.Workspace.DocumentType",
* "Umb.Workspace.Language",
* "Umb.Workspace.LanguageRoot",
* "Umb.Workspace.LogviewerRoot",
* "Umb.Workspace.Media",
* "Umb.Workspace.MediaType",
* "Umb.Workspace.Member",
* "Umb.Workspace.MemberType",
* "Umb.Workspace.MemberGroup",
* "Umb.Workspace.Package",
* "Umb.Workspace.PackageBuilder",
* "Umb.Workspace.PartialView",
* "Umb.Workspace.RelationType",
* "Umb.Workspace.Stylesheet",
* "Umb.Workspace.Template"
* ]
*/
workspaces: string[];
}

View File

@@ -3,14 +3,14 @@ import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registr
import { UmbBaseController, UmbControllerHostElement } from '@umbraco-cms/backoffice/controller-api';
/**
* Initializes extension classes for a host element.
* Initializes multiple extensions classes for a host element.
* Extension class will be given one argument, the host element.
*
* @param host The host element to initialize extension classes for.
* @param extensionTypes The extension types(strings) to initialize.
*
*/
export class UmbClassExtensionsInitializer extends UmbBaseController {
export class UmbMultiExtensionsClassInitializer extends UmbBaseController {
#extensionMap = new Map();
constructor(host: UmbControllerHostElement, extensionTypes: Array<string>) {
@@ -19,9 +19,19 @@ export class UmbClassExtensionsInitializer extends UmbBaseController {
this.observe(umbExtensionsRegistry.extensionsOfTypes(extensionTypes), (extensions) => {
if (!extensions) return;
// Clean up removed extensions:
this.#extensionMap.forEach((value, key) => {
if (!extensions.find((incoming) => incoming.alias === key)) {
this.#extensionMap.delete(key);
value.destroy();
}
});
extensions.forEach((extension) => {
if (this.#extensionMap.has(extension.alias)) return;
// Notice, currently no way to re-initialize an extension class if it changes. But that does not seem necessary currently. (Otherwise look at implementing the UmbExtensionClassInitializer)
// Instantiate and provide extension JS class. For Context API the classes provide them selfs when the class instantiates.
this.#extensionMap.set(extension.alias, createExtensionClass(extension, [this._host]));
});

View File

@@ -7,6 +7,7 @@ import { manifests as tinyMcePluginManifests } from './property-editor/uis/tiny-
import { manifests as workspaceManifests } from './workspace/manifests.js';
import { manifests as modalManifests } from './modal/common/manifests.js';
import { manifests as themeManifests } from './themes/manifests.js';
import { manifests as conditionManifests } from './extension-registry/conditions/manifests.js';
import { UmbNotificationContext, UMB_NOTIFICATION_CONTEXT_TOKEN } from '@umbraco-cms/backoffice/notification';
import { UmbModalManagerContext, UMB_MODAL_MANAGER_CONTEXT_TOKEN } from '@umbraco-cms/backoffice/modal';
@@ -15,7 +16,7 @@ import type { UmbEntryPointOnInit } from '@umbraco-cms/backoffice/extension-api'
import {
ManifestTypes,
UmbBackofficeManifestKind,
UmbClassExtensionsInitializer,
UmbMultiExtensionsClassInitializer,
} from '@umbraco-cms/backoffice/extension-registry';
export * from './localization/index.js';
@@ -43,6 +44,7 @@ export * from './variant/index.js';
export * from './workspace/index.js';
const manifests: Array<ManifestTypes | UmbBackofficeManifestKind> = [
...conditionManifests,
...debugManifests,
...localizationManifests,
...propertyActionManifests,
@@ -54,7 +56,7 @@ const manifests: Array<ManifestTypes | UmbBackofficeManifestKind> = [
];
export const onInit: UmbEntryPointOnInit = (host, extensionRegistry) => {
new UmbClassExtensionsInitializer(host, ['globalContext', 'store', 'treeStore', 'itemStore']);
new UmbMultiExtensionsClassInitializer(host, ['globalContext', 'store', 'treeStore', 'itemStore']);
extensionRegistry.registerMany(manifests);

View File

@@ -1,3 +1,4 @@
export * from './menu-item/index.js';
export * from './menu-item-base/index.js';
export * from './menu.element.js';
export * from './menu.context.js';

View File

@@ -0,0 +1,15 @@
import type { ManifestMenu } from '../extension-registry/models/index.js';
import { UmbContextToken } from '@umbraco-cms/backoffice/context-api';
import { UmbDeepState } from '@umbraco-cms/backoffice/observable-api';
export class UmbMenuContext {
#manifest = new UmbDeepState<ManifestMenu | undefined>(undefined);
public readonly manifest = this.#manifest.asObservable();
public readonly alias = this.#manifest.asObservablePart((x) => x?.alias);
public setManifest(manifest: ManifestMenu | undefined) {
this.#manifest.next(manifest);
}
}
export const UMB_MENU_CONTEXT_TOKEN = new UmbContextToken<UmbMenuContext>('UMB_MENU_CONTEXT_TOKEN');

View File

@@ -7,13 +7,18 @@ import './menu-item/menu-item.element.js';
@customElement('umb-menu')
export class UmbMenuElement extends UmbLitElement {
@property()
@property({ attribute: false })
manifest?: ManifestMenu;
constructor() {
super();
//this.provideContext(UMB_MENU_CONTEXT_TOKEN, new UmbMenuContext());
}
render() {
return html` <umb-extension-slot
type="menuItem"
.filter=${(items: ManifestMenuItem) => items.conditions.menus.includes(this.manifest!.alias)}
.filter=${(items: ManifestMenuItem) => items.meta.menus.includes(this.manifest!.alias)}
default-element="umb-menu-item"></umb-extension-slot>`;
}

View File

@@ -9,6 +9,7 @@ import { UmbModalBaseElement } from '@umbraco-cms/internal/modal';
import { UmbPropertySettingsModalResult, UmbPropertySettingsModalData } from '@umbraco-cms/backoffice/modal';
import { generateAlias } from '@umbraco-cms/backoffice/utils';
// TODO: Could base take a token to get its types?.
// TODO: Missing a workspace context... unless this should not be a workspace any way.
@customElement('umb-property-settings-modal')
export class UmbPropertySettingsModalElement extends UmbModalBaseElement<
UmbPropertySettingsModalData,

View File

@@ -6,7 +6,7 @@ export const manifests: Array<ManifestPropertyAction> = [
alias: 'Umb.PropertyAction.Copy',
name: 'Copy Property Action',
loader: () => import('./common/copy/property-action-copy.element.js'),
conditions: {
meta: {
propertyEditors: ['Umb.PropertyEditorUi.TextBox'],
},
},
@@ -15,7 +15,7 @@ export const manifests: Array<ManifestPropertyAction> = [
alias: 'Umb.PropertyAction.Clear',
name: 'Clear Property Action',
loader: () => import('./common/clear/property-action-clear.element.js'),
conditions: {
meta: {
propertyEditors: ['Umb.PropertyEditorUi.TextBox'],
},
},

View File

@@ -47,7 +47,7 @@ export class UmbPropertyActionMenuElement extends UmbLitElement {
this._actionsObserver = this.observe(
umbExtensionsRegistry.extensionsOfType('propertyAction').pipe(
map((propertyActions) => {
return propertyActions.filter((propertyAction) => propertyAction.conditions.propertyEditors.includes(alias));
return propertyActions.filter((propertyAction) => propertyAction.meta.propertyEditors.includes(alias));
})
),
(manifests) => {
@@ -65,6 +65,7 @@ export class UmbPropertyActionMenuElement extends UmbLitElement {
event.stopPropagation();
}
// TODO: Implement extension-slot on change event. And use the extension slot instead of custom implementation.
render() {
return this._actions.length > 0
? html`

View File

@@ -3,6 +3,6 @@ export * from './section-sidebar/index.js';
export * from './section-sidebar-context-menu/index.js';
export * from './section-sidebar-menu/index.js';
export * from './section-sidebar-menu-with-entity-actions/index.js';
export * from './section-views/index.js';
export * from './section-main-views/index.js';
export * from './section-default.element.js';
export * from './section.context.js';

View File

@@ -1,16 +1,17 @@
import type { UmbWorkspaceElement } from '../workspace/workspace.element.js';
import type { UmbSectionViewsElement } from './section-views/section-views.element.js';
import type { UmbSectionMainViewElement } from './section-main-views/section-main-views.element.js';
import { UUITextStyles } from '@umbraco-cms/backoffice/external/uui';
import { css, html, nothing, customElement, property, state } from '@umbraco-cms/backoffice/external/lit';
import { map } from '@umbraco-cms/backoffice/external/rxjs';
import { css, html, nothing, customElement, property, state, repeat } from '@umbraco-cms/backoffice/external/lit';
import {
ManifestSection,
ManifestSectionSidebarApp,
ManifestSectionSidebarAppMenuKind,
UmbSectionExtensionElement,
umbExtensionsRegistry,
} from '@umbraco-cms/backoffice/extension-registry';
import type { UmbRoute } from '@umbraco-cms/backoffice/router';
import { UmbLitElement } from '@umbraco-cms/internal/lit-element';
import { UmbExtensionElementController, UmbExtensionsElementController } from '@umbraco-cms/backoffice/extension-api';
/**
* @export
@@ -19,8 +20,9 @@ import { UmbLitElement } from '@umbraco-cms/internal/lit-element';
*/
@customElement('umb-section-default')
export class UmbSectionDefaultElement extends UmbLitElement implements UmbSectionExtensionElement {
@property()
private _manifest?: ManifestSection | undefined;
@property({ type: Object, attribute: false })
public get manifest(): ManifestSection | undefined {
return this._manifest;
}
@@ -28,7 +30,7 @@ export class UmbSectionDefaultElement extends UmbLitElement implements UmbSectio
const oldValue = this._manifest;
if (oldValue === value) return;
this._manifest = value;
this.#observeSectionSidebarApps();
this.requestUpdate('manifest', oldValue);
}
@@ -36,10 +38,19 @@ export class UmbSectionDefaultElement extends UmbLitElement implements UmbSectio
private _routes?: Array<UmbRoute>;
@state()
private _menus?: Array<Omit<ManifestSectionSidebarApp, 'kind'>>;
private _sidebarApps?: Array<
UmbExtensionElementController<ManifestSectionSidebarApp | ManifestSectionSidebarAppMenuKind>
>;
constructor() {
super();
new UmbExtensionsElementController(this, umbExtensionsRegistry, 'sectionSidebarApp', null, (sidebarApps) => {
const oldValue = this._sidebarApps;
this._sidebarApps = sidebarApps;
this.requestUpdate('_sidebarApps', oldValue);
});
this.#createRoutes();
}
@@ -54,42 +65,25 @@ export class UmbSectionDefaultElement extends UmbLitElement implements UmbSectio
},
{
path: '**',
component: () => import('./section-views/section-views.element.js'),
component: () => import('./section-main-views/section-main-views.element.js'),
setup: (element) => {
(element as UmbSectionViewsElement).sectionAlias = this.manifest?.alias;
(element as UmbSectionMainViewElement).sectionAlias = this.manifest?.alias;
},
},
];
}
// TODO: Can this be omitted? or can the same data be used for the extension slot or alike extension presentation?
#observeSectionSidebarApps() {
this.observe(
umbExtensionsRegistry
.extensionsOfType('sectionSidebarApp')
.pipe(
map((manifests) =>
manifests.filter((manifest) => manifest.conditions.sections.includes(this._manifest?.alias ?? ''))
)
),
(manifests) => {
const oldValue = this._menus;
this._menus = manifests;
this.requestUpdate('_menu', oldValue);
}
);
}
render() {
return html`
${this._menus && this._menus.length > 0
${this._sidebarApps && this._sidebarApps.length > 0
? html`
<!-- TODO: these extensions should be combined into one type: sectionSidebarApp with a "subtype" -->
<umb-section-sidebar>
<umb-extension-slot
type="sectionSidebarApp"
.filter=${(items: ManifestSectionSidebarApp) =>
items.conditions.sections.includes(this.manifest?.alias ?? '')}></umb-extension-slot>
${repeat(
this._sidebarApps,
(app) => app.alias,
(app) => app.component
)}
</umb-section-sidebar>
`
: nothing}

View File

@@ -0,0 +1 @@
export * from './section-main-views.element.js';

View File

@@ -1,7 +1,5 @@
import { UmbSectionContext, UMB_SECTION_CONTEXT_TOKEN } from '../section.context.js';
import { UUITextStyles } from '@umbraco-cms/backoffice/external/uui';
import { css, html, nothing, customElement, property, state } from '@umbraco-cms/backoffice/external/lit';
import { map, of } from '@umbraco-cms/backoffice/external/rxjs';
import type { UmbRoute, UmbRouterSlotChangeEvent, UmbRouterSlotInitEvent } from '@umbraco-cms/backoffice/router';
import {
ManifestDashboard,
@@ -10,14 +8,13 @@ import {
UmbSectionViewExtensionElement,
umbExtensionsRegistry,
} from '@umbraco-cms/backoffice/extension-registry';
import { createExtensionElement } from '@umbraco-cms/backoffice/extension-api';
import { UmbExtensionsManifestController, createExtensionElement } from '@umbraco-cms/backoffice/extension-api';
import { UmbLitElement } from '@umbraco-cms/internal/lit-element';
import { UmbObserverController } from '@umbraco-cms/backoffice/observable-api';
import { pathFolderName } from '@umbraco-cms/backoffice/utils';
// TODO: this might need a new name, since it's both view and dashboard now
@customElement('umb-section-views')
export class UmbSectionViewsElement extends UmbLitElement {
// TODO: this might need a new name, since it's both views and dashboards
@customElement('umb-section-main-views')
export class UmbSectionMainViewElement extends UmbLitElement {
@property({ type: String, attribute: 'section-alias' })
public sectionAlias?: string;
@@ -36,17 +33,17 @@ export class UmbSectionViewsElement extends UmbLitElement {
@state()
private _routes: Array<UmbRoute> = [];
private _sectionContext?: UmbSectionContext;
private _extensionsObserver?: UmbObserverController<ManifestSectionView[]>;
private _viewsObserver?: UmbObserverController<ManifestSectionView[]>;
private _dashboardObserver?: UmbObserverController<ManifestDashboard[]>;
constructor() {
super();
this.consumeContext(UMB_SECTION_CONTEXT_TOKEN, (sectionContext) => {
this._sectionContext = sectionContext;
this._observeSectionAlias();
new UmbExtensionsManifestController(this, umbExtensionsRegistry, 'dashboard', null, (dashboards) => {
this._dashboards = dashboards.map((dashboard) => dashboard.manifest);
this.#createRoutes();
});
new UmbExtensionsManifestController(this, umbExtensionsRegistry, 'sectionView', null, (views) => {
this._views = views.map((view) => view.manifest);
this.#createRoutes();
});
}
@@ -85,50 +82,6 @@ export class UmbSectionViewsElement extends UmbLitElement {
this._routes = routes?.length > 0 ? [...routes, { path: '', redirectTo: routes?.[0]?.path }] : [];
}
private _observeSectionAlias() {
if (!this._sectionContext) return;
this.observe(
this._sectionContext.alias,
(sectionAlias) => {
this._observeViews(sectionAlias);
this._observeDashboards(sectionAlias);
},
'viewsObserver'
);
}
private _observeViews(sectionAlias?: string) {
this._viewsObserver?.destroy();
if (sectionAlias) {
this._viewsObserver = this.observe(
umbExtensionsRegistry
?.extensionsOfType('sectionView')
.pipe(map((views) => views.filter((view) => view.conditions.sections.includes(sectionAlias)))) ?? of([]),
(views) => {
this._views = views;
this.#createRoutes();
}
);
}
}
private _observeDashboards(sectionAlias?: string) {
this._dashboardObserver?.destroy();
if (sectionAlias) {
this._dashboardObserver = this.observe(
umbExtensionsRegistry
?.extensionsOfType('dashboard')
.pipe(map((views) => views.filter((view) => view.conditions.sections.includes(sectionAlias)))) ?? of([]),
(views) => {
this._dashboards = views;
this.#createRoutes();
}
);
}
}
render() {
return this._routes.length > 0
? html`
@@ -210,10 +163,10 @@ export class UmbSectionViewsElement extends UmbLitElement {
];
}
export default UmbSectionViewsElement;
export default UmbSectionMainViewElement;
declare global {
interface HTMLElementTagNameMap {
'umb-section-views': UmbSectionViewsElement;
'umb-section-main-views': UmbSectionMainViewElement;
}
}

View File

@@ -1,6 +1,6 @@
import { UmbSectionSidebarContext, UMB_SECTION_SIDEBAR_CONTEXT_TOKEN } from '../section-sidebar/index.js';
import { UUITextStyles } from '@umbraco-cms/backoffice/external/uui';
import { css, html, nothing, customElement, state, ifDefined } from '@umbraco-cms/backoffice/external/lit';
import { css, html, nothing, customElement, state } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/internal/lit-element';
@customElement('umb-section-sidebar-context-menu')
@@ -25,10 +25,22 @@ export class UmbSectionSidebarContextMenuElement extends UmbLitElement {
this.consumeContext(UMB_SECTION_SIDEBAR_CONTEXT_TOKEN, (instance) => {
this.#sectionSidebarContext = instance;
this.observe(this.#sectionSidebarContext.contextMenuIsOpen, (value) => (this._isOpen = value));
this.observe(this.#sectionSidebarContext.unique, (value) => (this._unique = value));
this.observe(this.#sectionSidebarContext.entityType, (value) => (this._entityType = value));
this.observe(this.#sectionSidebarContext.headline, (value) => (this._headline = value));
if (this.#sectionSidebarContext) {
// make prettier not break the lines on the next 4 lines:
// prettier-ignore
this.observe( this.#sectionSidebarContext.contextMenuIsOpen, (value) => (this._isOpen = value), '_observeContextMenuIsOpen');
// prettier-ignore
this.observe(this.#sectionSidebarContext.unique, (value) => (this._unique = value), '_observeUnique');
// prettier-ignore
this.observe(this.#sectionSidebarContext.entityType, (value) => (this._entityType = value), '_observeEntityType');
// prettier-ignore
this.observe(this.#sectionSidebarContext.headline, (value) => (this._headline = value), '_observeHeadline');
} else {
this.removeControllerByAlias('_observeContextMenuIsOpen');
this.removeControllerByAlias('_observeUnique');
this.removeControllerByAlias('_observeEntityType');
this.removeControllerByAlias('_observeHeadline');
}
});
}
@@ -59,7 +71,7 @@ export class UmbSectionSidebarContextMenuElement extends UmbLitElement {
// TODO: allow different views depending on left or right click
#renderModal() {
return this._isOpen && this._unique !== undefined
return this._isOpen && this._unique !== undefined && this._entityType
? html`<div id="action-modal">
<h3>${this._headline}</h3>
<umb-entity-action-list

View File

@@ -10,7 +10,7 @@ import '../../menu/menu.element.js';
const manifestWithEntityActions: UmbBackofficeManifestKind = {
type: 'kind',
alias: 'Umb.Kind.Menu',
alias: 'Umb.Kind.SectionSidebarAppMenuWithEntityActions',
matchKind: 'menuWithEntityActions',
matchType: 'sectionSidebarApp',
manifest: {
@@ -28,7 +28,7 @@ export class UmbSectionSidebarMenuWithEntityActionsElement extends UmbSectionSid
<umb-entity-actions-bundle
slot="actions"
.unique=${null}
entity-type=${this.manifest?.meta.entityType}
.entityType=${this.manifest?.meta.entityType}
.label=${this.manifest?.meta.label}>
</umb-entity-actions-bundle>
</div> `;

View File

@@ -12,7 +12,7 @@ import { UmbLitElement } from '@umbraco-cms/internal/lit-element';
// TODO: Move to separate file:
const manifest: UmbBackofficeManifestKind = {
type: 'kind',
alias: 'Umb.Kind.Menu',
alias: 'Umb.Kind.SectionSidebarAppMenu',
matchKind: 'menu',
matchType: 'sectionSidebarApp',
manifest: {
@@ -26,7 +26,7 @@ umbExtensionsRegistry.register(manifest);
export class UmbSectionSidebarMenuElement<
ManifestType extends ManifestSectionSidebarAppBaseMenu = ManifestSectionSidebarAppMenuKind
> extends UmbLitElement {
@property()
@property({ type: Object, attribute: false })
manifest?: ManifestType;
renderHeader() {
@@ -34,11 +34,10 @@ export class UmbSectionSidebarMenuElement<
}
render() {
// TODO: link to dashboards when clicking on the menu item header
return html`${this.renderHeader()}
<umb-extension-slot
type="menu"
.filter=${(menu: ManifestMenu) => menu.alias === this.manifest?.meta?.menu}
.filter="${(menu: ManifestMenu) => menu.alias === this.manifest?.meta?.menu}"
default-element="umb-menu"></umb-extension-slot>`;
}

View File

@@ -1 +0,0 @@
export type { UmbSectionViewsElement } from './section-views.element.js';

View File

@@ -3,19 +3,10 @@ import { UmbTreeContextBase } from '../tree.context.js';
import { map } from '@umbraco-cms/backoffice/external/rxjs';
import { UMB_SECTION_CONTEXT_TOKEN, UMB_SECTION_SIDEBAR_CONTEXT_TOKEN } from '@umbraco-cms/backoffice/section';
import type { UmbSectionContext, UmbSectionSidebarContext } from '@umbraco-cms/backoffice/section';
import { ManifestEntityAction, umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';
import {
UmbBooleanState,
UmbDeepState,
UmbStringState,
UmbObserverController,
} from '@umbraco-cms/backoffice/observable-api';
import { UmbControllerHostElement } from '@umbraco-cms/backoffice/controller-api';
import {
UmbContextConsumerController,
UmbContextProviderController,
UmbContextToken,
} from '@umbraco-cms/backoffice/context-api';
import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';
import { UmbBooleanState, UmbDeepState, UmbStringState } from '@umbraco-cms/backoffice/observable-api';
import { UmbBaseController, UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import { UmbContextToken } from '@umbraco-cms/backoffice/context-api';
import type { TreeItemPresentationModel } from '@umbraco-cms/backoffice/backend-api';
// add type for unique function
@@ -24,9 +15,9 @@ export type UmbTreeItemUniqueFunction<TreeItemType extends TreeItemPresentationM
) => string | null | undefined;
export class UmbTreeItemContextBase<TreeItemType extends TreeItemPresentationModel>
extends UmbBaseController
implements UmbTreeItemContext<TreeItemType>
{
public host: UmbControllerHostElement;
public unique?: string | null;
public type?: string;
@@ -61,13 +52,12 @@ export class UmbTreeItemContextBase<TreeItemType extends TreeItemPresentationMod
#sectionContext?: UmbSectionContext;
#sectionSidebarContext?: UmbSectionSidebarContext;
#getUniqueFunction: UmbTreeItemUniqueFunction<TreeItemType>;
#actionObserver?: UmbObserverController<ManifestEntityAction[]>;
constructor(host: UmbControllerHostElement, getUniqueFunction: UmbTreeItemUniqueFunction<TreeItemType>) {
this.host = host;
constructor(host: UmbControllerHost, getUniqueFunction: UmbTreeItemUniqueFunction<TreeItemType>) {
super(host);
this.#getUniqueFunction = getUniqueFunction;
this.#consumeContexts();
new UmbContextProviderController(host, UMB_TREE_ITEM_CONTEXT_TOKEN, this);
this.provideContext(UMB_TREE_ITEM_CONTEXT_TOKEN, this);
}
public setTreeItem(treeItem: TreeItemType | undefined) {
@@ -85,8 +75,13 @@ export class UmbTreeItemContextBase<TreeItemType extends TreeItemPresentationMod
this.type = treeItem.type;
this.#hasChildren.next(treeItem.hasChildren || false);
this.#observeActions();
this.#treeItem.next(treeItem);
// Update observers:
this.#observeActions();
this.#observeIsSelectable();
this.#observeIsSelected();
this.#observeSectionPath();
}
public async requestChildren() {
@@ -118,16 +113,16 @@ export class UmbTreeItemContextBase<TreeItemType extends TreeItemPresentationMod
}
#consumeContexts() {
new UmbContextConsumerController(this.host, UMB_SECTION_CONTEXT_TOKEN, (instance) => {
this.consumeContext(UMB_SECTION_CONTEXT_TOKEN, (instance) => {
this.#sectionContext = instance;
this.#observeSectionPath();
});
new UmbContextConsumerController(this.host, UMB_SECTION_SIDEBAR_CONTEXT_TOKEN, (instance) => {
this.consumeContext(UMB_SECTION_SIDEBAR_CONTEXT_TOKEN, (instance) => {
this.#sectionSidebarContext = instance;
});
new UmbContextConsumerController(this.host, 'umbTreeContext', (treeContext: UmbTreeContextBase<TreeItemType>) => {
this.consumeContext('umbTreeContext', (treeContext: UmbTreeContextBase<TreeItemType>) => {
this.treeContext = treeContext;
this.#observeIsSelectable();
this.#observeIsSelected();
@@ -140,53 +135,56 @@ export class UmbTreeItemContextBase<TreeItemType extends TreeItemPresentationMod
#observeIsSelectable() {
if (!this.treeContext) return;
new UmbObserverController(this.host, this.treeContext.selectable, (value) => {
this.#isSelectableContext.next(value);
this.observe(
this.treeContext.selectable,
(value) => {
this.#isSelectableContext.next(value);
// If the tree is selectable, check if this item is selectable
if (value === true) {
const isSelectable = this.treeContext?.selectableFilter?.(this.getTreeItem()!) ?? true;
this.#isSelectable.next(isSelectable);
}
});
// If the tree is selectable, check if this item is selectable
if (value === true) {
const isSelectable = this.treeContext?.selectableFilter?.(this.getTreeItem()!) ?? true;
this.#isSelectable.next(isSelectable);
}
},
'observeIsSelectable'
);
}
#observeIsSelected() {
if (!this.treeContext) throw new Error('Could not request children, tree context is missing');
if (this.unique === undefined) throw new Error('Could not request children, unique key is missing');
if (!this.treeContext || !this.unique) return;
new UmbObserverController(
this.host,
this.observe(
this.treeContext.selection.pipe(map((selection) => selection.includes(this.unique!))),
(isSelected) => {
this.#isSelected.next(isSelected);
}
},
'observeIsSelected'
);
}
#observeSectionPath() {
if (!this.#sectionContext) return;
new UmbObserverController(this.host, this.#sectionContext.pathname, (pathname) => {
if (!pathname) return;
if (!this.type) throw new Error('Cant construct path, entity type is missing');
if (this.unique === undefined) throw new Error('Cant construct path, unique is missing');
const path = this.constructPath(pathname, this.type, this.unique);
this.#path.next(path);
});
this.observe(
this.#sectionContext.pathname,
(pathname) => {
if (!pathname || !this.type || this.unique === undefined) return;
const path = this.constructPath(pathname, this.type, this.unique);
this.#path.next(path);
},
'observeSectionPath'
);
}
#observeActions() {
if (this.#actionObserver) this.#actionObserver.destroy();
this.#actionObserver = new UmbObserverController(
this.host,
this.observe(
umbExtensionsRegistry
.extensionsOfType('entityAction')
.pipe(map((actions) => actions.filter((action) => action.conditions.entityTypes.includes(this.type!)))),
.pipe(map((actions) => actions.filter((action) => action.meta.entityTypes.includes(this.type!)))),
(actions) => {
this.#hasActions.next(actions.length > 0);
}
},
'observeActions'
);
}

View File

@@ -1,10 +1,8 @@
import type { Observable } from '@umbraco-cms/backoffice/external/rxjs';
import type { UmbControllerHostElement } from '@umbraco-cms/backoffice/controller-api';
import type { ProblemDetails, TreeItemPresentationModel } from '@umbraco-cms/backoffice/backend-api';
import { UmbPagedData } from '@umbraco-cms/backoffice/repository';
export interface UmbTreeItemContext<TreeItemType extends TreeItemPresentationModel> {
host: UmbControllerHostElement;
unique?: string | null;
type?: string;
treeItem: Observable<TreeItemType | undefined>;

View File

@@ -13,7 +13,7 @@ export class UmbTreeItemElement extends UmbLitElement {
if (!this.item) return nothing;
return html`<umb-extension-slot
type="treeItem"
.filter=${(manifests: ManifestTreeItem) => manifests.conditions.entityTypes.includes(this.item!.type!)}
.filter=${(manifests: ManifestTreeItem) => manifests.meta.entityTypes.includes(this.item!.type!)}
.props=${{
item: this.item,
}}></umb-extension-slot>`;

View File

@@ -9,3 +9,4 @@ export * from './workspace-property-layout/workspace-property-layout.element.js'
export * from './workspace-property/index.js';
export * from './workspace-split-view-manager.class.js';
export * from './workspace-variant/index.js';
export * from './workspace-alias.condition.js';

View File

@@ -1,3 +1,4 @@
import { manifests as workspaceModals } from './workspace-modal/manifests.js';
import { manifest as workspaceCondition } from './workspace-alias.condition.js';
export const manifests = [...workspaceModals];
export const manifests = [...workspaceModals, workspaceCondition];

View File

@@ -1 +1 @@
export * from './workspace-property-data.type';
export * from './workspace-property-data.type.js';

View File

@@ -2,13 +2,13 @@ import { UUITextStyles } from '@umbraco-cms/backoffice/external/uui';
import { css, html, customElement, state } from '@umbraco-cms/backoffice/external/lit';
import { UmbExecutedEvent } from '@umbraco-cms/backoffice/events';
import { UmbLitElement } from '@umbraco-cms/internal/lit-element';
import { UMB_ENTITY_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/workspace';
import { UMB_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/workspace';
@customElement('umb-workspace-action-menu')
export class UmbWorkspaceActionMenuElement extends UmbLitElement {
@state()
private _actionMenuIsOpen = false;
private _workspaceContext?: typeof UMB_ENTITY_WORKSPACE_CONTEXT.TYPE;
private _workspaceContext?: typeof UMB_WORKSPACE_CONTEXT.TYPE;
@state()
_entityId?: string;
@@ -19,7 +19,7 @@ export class UmbWorkspaceActionMenuElement extends UmbLitElement {
constructor() {
super();
this.consumeContext(UMB_ENTITY_WORKSPACE_CONTEXT, (context) => {
this.consumeContext(UMB_WORKSPACE_CONTEXT, (context) => {
this._workspaceContext = context;
this._observeInfo();
});
@@ -49,15 +49,17 @@ export class UmbWorkspaceActionMenuElement extends UmbLitElement {
}
#renderActionsMenu() {
return this._entityId
return this._entityId && this._entityType
? html`
<uui-popover id="action-menu-popover" .open=${this._actionMenuIsOpen} @close=${this.#close}>
<uui-button slot="trigger" label="Actions" @click=${this.#open}></uui-button>
<div id="action-menu-dropdown" slot="popover">
<uui-scroll-container>
<umb-entity-action-list @executed=${this.#onActionExecuted} entity-type=${this._entityType as string} unique=${
this._entityId
}></umb-entity-action-list>
<umb-entity-action-list
@executed=${this.#onActionExecuted}
.entityType=${this._entityType}
.unique=${this._entityId}>
</umb-entity-action-list>
</uui-scroll-container>
</div>
</uui-popover>

View File

@@ -1,4 +1,4 @@
import { UmbWorkspaceContextInterface, UMB_ENTITY_WORKSPACE_CONTEXT } from '../workspace-context/index.js';
import { UmbWorkspaceContextInterface, UMB_WORKSPACE_CONTEXT } from '../workspace-context/index.js';
import type { UmbControllerHostElement } from '@umbraco-cms/backoffice/controller-api';
import { UmbContextConsumerController } from '@umbraco-cms/backoffice/context-api';
@@ -16,7 +16,7 @@ export abstract class UmbWorkspaceActionBase<WorkspaceType extends UmbWorkspaceC
constructor(host: UmbControllerHostElement) {
this.host = host;
new UmbContextConsumerController(this.host, UMB_ENTITY_WORKSPACE_CONTEXT, (instance) => {
new UmbContextConsumerController(this.host, UMB_WORKSPACE_CONTEXT, (instance) => {
// TODO: Be aware we are casting here. We should consider a better solution for typing the contexts. (But notice we still want to capture the first workspace...)
this.workspaceContext = instance as unknown as WorkspaceType;
});

View File

@@ -0,0 +1,41 @@
import { UMB_WORKSPACE_CONTEXT } from './workspace-context/index.js';
import { UmbBaseController } from '@umbraco-cms/backoffice/controller-api';
import {
ManifestCondition,
UmbConditionConfigBase,
UmbConditionControllerArguments,
UmbExtensionCondition,
} from '@umbraco-cms/backoffice/extension-api';
export class UmbWorkspaceAliasCondition extends UmbBaseController implements UmbExtensionCondition {
config: WorkspaceAliasConditionConfig;
permitted = false;
#onChange: () => void;
constructor(args: UmbConditionControllerArguments<WorkspaceAliasConditionConfig>) {
super(args.host);
this.config = args.config;
this.#onChange = args.onChange;
this.consumeContext(UMB_WORKSPACE_CONTEXT, (context) => {
this.permitted = context.workspaceAlias === this.config.match;
this.#onChange();
});
}
}
export type WorkspaceAliasConditionConfig = UmbConditionConfigBase<'Umb.Condition.WorkspaceAlias'> & {
/**
* Define the workspace that this extension should be available in
*
* @example
* "Umb.Workspace.Document"
*/
match: string;
};
export const manifest: ManifestCondition = {
type: 'condition',
name: 'Workspace Alias Condition',
alias: 'Umb.Condition.WorkspaceAlias',
class: UmbWorkspaceAliasCondition,
};

View File

@@ -4,7 +4,7 @@ import { UmbCollectionContext, UMB_COLLECTION_CONTEXT_TOKEN } from '@umbraco-cms
import { UmbLitElement } from '@umbraco-cms/internal/lit-element';
import type { FolderTreeItemResponseModel } from '@umbraco-cms/backoffice/backend-api';
import type { ManifestWorkspaceViewCollection } from '@umbraco-cms/backoffice/extension-registry';
import { UMB_ENTITY_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/workspace';
import { UMB_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/workspace';
import '../../../../collection/dashboards/dashboard-collection.element.js';
@@ -12,7 +12,7 @@ import '../../../../collection/dashboards/dashboard-collection.element.js';
export class UmbWorkspaceViewCollectionElement extends UmbLitElement {
public manifest!: ManifestWorkspaceViewCollection;
private _workspaceContext?: typeof UMB_ENTITY_WORKSPACE_CONTEXT.TYPE;
private _workspaceContext?: typeof UMB_WORKSPACE_CONTEXT.TYPE;
// TODO: add type for the collection context.
private _collectionContext?: UmbCollectionContext<FolderTreeItemResponseModel, any>;
@@ -20,7 +20,7 @@ export class UmbWorkspaceViewCollectionElement extends UmbLitElement {
constructor() {
super();
this.consumeContext(UMB_ENTITY_WORKSPACE_CONTEXT, (nodeContext) => {
this.consumeContext(UMB_WORKSPACE_CONTEXT, (nodeContext) => {
this._workspaceContext = nodeContext;
this._provideWorkspace();
});

View File

@@ -1,7 +0,0 @@
import { UmbEntityWorkspaceContextInterface } from './workspace-entity-context.interface.js';
import { UmbContextToken } from '@umbraco-cms/backoffice/context-api';
import type { UmbEntityBase } from '@umbraco-cms/backoffice/models';
export const UMB_ENTITY_WORKSPACE_CONTEXT = new UmbContextToken<UmbEntityWorkspaceContextInterface<UmbEntityBase>>(
'UmbEntityWorkspaceContext'
);

View File

@@ -4,4 +4,4 @@ export * from './workspace-context.interface.js';
export * from './workspace-entity-context.interface.js';
export * from './workspace-invariantable-entity-context.interface.js';
export * from './workspace-variable-entity-context.interface.js';
export * from './entity-workspace-context.token.js';
export * from './workspace-context.token.js';

View File

@@ -1,12 +1,12 @@
import { Observable } from '@umbraco-cms/backoffice/external/rxjs';
import { UmbControllerHostElement } from '@umbraco-cms/backoffice/controller-api';
export interface UmbWorkspaceContextInterface<DataType = unknown> {
host: UmbControllerHostElement;
workspaceAlias: string;
repository: any; // TODO: add type
isNew: Observable<boolean | undefined>;
getIsNew(): boolean | undefined;
setIsNew(value: boolean): void;
getEntityId(): string | undefined; // COnsider if this should go away now that we have getUnique()
// TODO: should we consider another name than entity type. File system files are not entities but still have this type.
getEntityType(): string;
getData(): DataType | undefined;

View File

@@ -0,0 +1,7 @@
import type { UmbWorkspaceContextInterface } from './workspace-context.interface.js';
import { UmbContextToken } from '@umbraco-cms/backoffice/context-api';
import type { UmbEntityBase } from '@umbraco-cms/backoffice/models';
export const UMB_WORKSPACE_CONTEXT = new UmbContextToken<UmbWorkspaceContextInterface<UmbEntityBase>>(
'UmbWorkspaceContext'
);

View File

@@ -3,7 +3,7 @@ import { UmbContextConsumerController, UmbContextProviderController } from '@umb
import { UmbControllerHostElement } from '@umbraco-cms/backoffice/controller-api';
import { UmbBooleanState } from '@umbraco-cms/backoffice/observable-api';
import type { UmbEntityBase } from '@umbraco-cms/backoffice/models';
import { UMB_ENTITY_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/workspace';
import { UMB_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/workspace';
import { UMB_MODAL_CONTEXT_TOKEN, UmbModalContext } from '@umbraco-cms/backoffice/modal';
/*
@@ -15,6 +15,7 @@ export abstract class UmbWorkspaceContext<RepositoryType, EntityType extends Umb
implements UmbEntityWorkspaceContextInterface<EntityType>
{
public readonly host: UmbControllerHostElement;
public readonly workspaceAlias: string;
public readonly repository: RepositoryType;
// TODO: We could make a base type for workspace modal data, and use this here: As well as a base for the result, to make sure we always include the unique.
@@ -23,10 +24,11 @@ export abstract class UmbWorkspaceContext<RepositoryType, EntityType extends Umb
#isNew = new UmbBooleanState(undefined);
isNew = this.#isNew.asObservable();
constructor(host: UmbControllerHostElement, repository: RepositoryType) {
constructor(host: UmbControllerHostElement, workspaceAlias: string, repository: RepositoryType) {
this.host = host;
this.workspaceAlias = workspaceAlias;
this.repository = repository;
new UmbContextProviderController(host, UMB_ENTITY_WORKSPACE_CONTEXT, this);
new UmbContextProviderController(host, UMB_WORKSPACE_CONTEXT, this);
new UmbContextConsumerController(host, UMB_MODAL_CONTEXT_TOKEN, (context) => {
(this.modalContext as UmbModalContext) = context;
});

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