diff --git a/src/Umbraco.Web.UI.Client/.eslintignore b/src/Umbraco.Web.UI.Client/.eslintignore index 973eb4106a..0e94763217 100644 --- a/src/Umbraco.Web.UI.Client/.eslintignore +++ b/src/Umbraco.Web.UI.Client/.eslintignore @@ -6,3 +6,4 @@ schemas temp-schema-generator APP_PLUGINS /src/external/router-slot +/examples diff --git a/src/Umbraco.Web.UI.Client/devops/example-runner/index.js b/src/Umbraco.Web.UI.Client/devops/example-runner/index.js new file mode 100644 index 0000000000..2dd960c198 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/devops/example-runner/index.js @@ -0,0 +1,49 @@ +import * as readline from 'readline'; +import { execSync } from 'child_process'; +import { readdir } from 'fs/promises'; + +const exampleDirectory = 'examples'; + +const getDirectories = async (source) => + (await readdir(source, { withFileTypes: true })) + .filter(dirent => dirent.isDirectory()) + .map(dirent => dirent.name) + +async function pickExampleUI(){ + + // Find sub folder: + const exampleFolderNames = await getDirectories(`${exampleDirectory}`); + + // Create UI: + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout + }); + + // List examples: + console.log('Please select an example by entering the corresponding number:'); + exampleFolderNames.forEach((folder, index) => { + console.log(`[${index + 1}] ${folder}`); + }); + + // Ask user to select an example: + rl.question('Enter your selection: ', (answer) => { + + // User picked an example: + const selectedFolder = exampleFolderNames[parseInt(answer) - 1]; + console.log(`You selected: ${selectedFolder}`); + + process.env['VITE_EXAMPLE_PATH'] = `${exampleDirectory}/${selectedFolder}`; + + // Start vite server: + try { + execSync('npm run dev', {stdio: 'inherit'}); + } catch (error) { + // Nothing, cause this is most likely just the server begin stopped. + //console.log(error); + } + }); + +}; + +pickExampleUI(); diff --git a/src/Umbraco.Web.UI.Client/examples/README.md b/src/Umbraco.Web.UI.Client/examples/README.md new file mode 100644 index 0000000000..8f6d34050e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/examples/README.md @@ -0,0 +1,7 @@ +# Backoffice Examples + +This folder contains example packages showcasing the usage of extensions in Backoffice. + +The purpose of these projects includes serving as demonstration or example for +packages, as well as testing to make sure the extension points continue +to work in these situations and to assist in developing new integrations. diff --git a/src/Umbraco.Web.UI.Client/examples/index.js b/src/Umbraco.Web.UI.Client/examples/index.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/Umbraco.Web.UI.Client/examples/workspace-context-counter/README.md b/src/Umbraco.Web.UI.Client/examples/workspace-context-counter/README.md new file mode 100644 index 0000000000..04336ea3dc --- /dev/null +++ b/src/Umbraco.Web.UI.Client/examples/workspace-context-counter/README.md @@ -0,0 +1,8 @@ +# Workspace Context Counter Example + +This example demonstrates the essence of the Workspace Context. + +The Workspace Context is available for everything within the Workspace, giving any extension within the ability to communicate through this. +In this example, the Workspace Context houses a counter, which can be incremented by a Workspace Action and shown in the Workspace View. + +To demonstrate this, the example comes with: A Workspace Context, A Workspace Action and a Workspace View. diff --git a/src/Umbraco.Web.UI.Client/examples/workspace-context-counter/counter-workspace-context.ts b/src/Umbraco.Web.UI.Client/examples/workspace-context-counter/counter-workspace-context.ts new file mode 100644 index 0000000000..3b31fd648a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/examples/workspace-context-counter/counter-workspace-context.ts @@ -0,0 +1,29 @@ +import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; +import { UmbBaseController, type UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbNumberState } from '@umbraco-cms/backoffice/observable-api'; + +// The Example Workspace Context Controller: +export class WorkspaceContextCounter extends UmbBaseController { + + // We always keep our states private, and expose the values as observables: + #counter = new UmbNumberState(0); + readonly counter = this.#counter.asObservable(); + + constructor(host: UmbControllerHost) { + super(host); + this.provideContext(EXAMPLE_COUNTER_CONTEXT, this); + } + + // Lets expose methods to update the state: + increment() { + this.#counter.next(this.#counter.value + 1); + } + +} + +// Declare a api export, so Extension Registry can initialize this class: +export const api = WorkspaceContextCounter; + + +// Declare a Context Token that other elements can use to request the WorkspaceContextCounter: +export const EXAMPLE_COUNTER_CONTEXT = new UmbContextToken('example.workspaceContext.counter'); diff --git a/src/Umbraco.Web.UI.Client/examples/workspace-context-counter/counter-workspace-view.ts b/src/Umbraco.Web.UI.Client/examples/workspace-context-counter/counter-workspace-view.ts new file mode 100644 index 0000000000..225a34bea8 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/examples/workspace-context-counter/counter-workspace-view.ts @@ -0,0 +1,60 @@ +import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; +import { UMB_CURRENT_USER_CONTEXT } from '@umbraco-cms/backoffice/current-user'; +import { css, html, customElement, state, LitElement } from '@umbraco-cms/backoffice/external/lit'; +import { UmbElementMixin } from '@umbraco-cms/backoffice/element-api'; +import { EXAMPLE_COUNTER_CONTEXT } from './counter-workspace-context'; + +@customElement('example-counter-workspace-view') +export class ExampleCounterWorkspaceView extends UmbElementMixin(LitElement) { + #counterContext?: typeof EXAMPLE_COUNTER_CONTEXT.TYPE; + + @state() + private count = ''; + + constructor() { + super(); + this.consumeContext(EXAMPLE_COUNTER_CONTEXT, (instance) => { + this.#counterContext = instance; + this.#observeCounter(); + }); + } + + #observeCounter(): void { + if (!this.#counterContext) return; + this.observe(this.#counterContext.counter, (count) => { + this.count = count; + }); + } + + render() { + return html` + +

Counter Example

+

+ Current count value: ${this.count} +

+

+ This is a Workspace View, that consumes the Counter Context, and displays the current count. +

+
+ `; + } + + static styles = [ + UmbTextStyles, + css` + :host { + display: block; + padding: var(--uui-size-layout-1); + } + `, + ]; +} + +export default ExampleCounterWorkspaceView; + +declare global { + interface HTMLElementTagNameMap { + 'example-counter-workspace-view': ExampleCounterWorkspaceView; + } +} diff --git a/src/Umbraco.Web.UI.Client/examples/workspace-context-counter/incrementor-workspace-action.ts b/src/Umbraco.Web.UI.Client/examples/workspace-context-counter/incrementor-workspace-action.ts new file mode 100644 index 0000000000..f53371786d --- /dev/null +++ b/src/Umbraco.Web.UI.Client/examples/workspace-context-counter/incrementor-workspace-action.ts @@ -0,0 +1,17 @@ +import { UmbBaseController } from '@umbraco-cms/backoffice/controller-api'; +import type { UmbWorkspaceAction } from '@umbraco-cms/backoffice/workspace'; +import { EXAMPLE_COUNTER_CONTEXT } from './counter-workspace-context'; + +// The Example Incrementor Workspace Action Controller: +export class ExampleIncrementorWorkspaceAction extends UmbBaseController implements UmbWorkspaceAction { + + // This method is executed + async execute() { + await this.consumeContext(EXAMPLE_COUNTER_CONTEXT, (context) => { + context.increment(); + }).asPromise(); + } +} + +// Declare a api export, so Extension Registry can initialize this class: +export const api = ExampleIncrementorWorkspaceAction; diff --git a/src/Umbraco.Web.UI.Client/examples/workspace-context-counter/index.ts b/src/Umbraco.Web.UI.Client/examples/workspace-context-counter/index.ts new file mode 100644 index 0000000000..e22c3603ec --- /dev/null +++ b/src/Umbraco.Web.UI.Client/examples/workspace-context-counter/index.ts @@ -0,0 +1,52 @@ +import type { ManifestTypes } from '@umbraco-cms/backoffice/extension-registry'; + +export const manifests: Array = [ + { + type: 'workspaceContext', + name: 'Example Counter Workspace Context', + alias: 'example.workspaceCounter.counter', + js: () => import('./counter-workspace-context.js'), + conditions: [ + { + alias: 'Umb.Condition.WorkspaceAlias', + match: 'Umb.Workspace.Document', + }, + ], + }, + { + type: 'workspaceAction', + name: 'Example Count Incerementor Workspace Action', + alias: 'example.workspaceAction.incrementor', + weight: 1000, + api: () => import('./incrementor-workspace-action.js'), + meta: { + label: 'Increment', + look: 'primary', + color: 'danger', + }, + conditions: [ + { + alias: 'Umb.Condition.WorkspaceAlias', + match: 'Umb.Workspace.Document', + }, + ], + }, + { + type: 'workspaceEditorView', + name: 'Example Counter Workspace View', + alias: 'example.workspaceView.counter', + element: () => import('./counter-workspace-view.js'), + weight: 900, + meta: { + label: 'Counter', + pathname: 'counter', + icon: 'icon-lab', + }, + conditions: [ + { + alias: 'Umb.Condition.WorkspaceAlias', + match: 'Umb.Workspace.Document', + }, + ], + }, +] diff --git a/src/Umbraco.Web.UI.Client/index.ts b/src/Umbraco.Web.UI.Client/index.ts index 2afb9dadf5..ecb0f617cf 100644 --- a/src/Umbraco.Web.UI.Client/index.ts +++ b/src/Umbraco.Web.UI.Client/index.ts @@ -1,5 +1,6 @@ import { UmbAppElement } from './src/apps/app/app.element.js'; import { startMockServiceWorker } from './src/mocks/index.js'; +import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; if (import.meta.env.VITE_UMBRACO_USE_MSW === 'on') { startMockServiceWorker(); @@ -18,4 +19,24 @@ if (import.meta.env.DEV) { appElement.bypassAuth = isMocking; + document.body.appendChild(appElement); + + +// Example injector: +if(import.meta.env.VITE_EXAMPLE_PATH) { + import(/* @vite-ignore */ './'+import.meta.env.VITE_EXAMPLE_PATH+'/index.ts').then((js) => { + if (js) { + Object.keys(js).forEach((key) => { + const value = js[key]; + + if (Array.isArray(value)) { + umbExtensionsRegistry.registerMany(value); + } else if (typeof value === 'object') { + umbExtensionsRegistry.register(value); + } + }); + } + }); + +} diff --git a/src/Umbraco.Web.UI.Client/package.json b/src/Umbraco.Web.UI.Client/package.json index eeccddd9c6..5a07ebb6ed 100644 --- a/src/Umbraco.Web.UI.Client/package.json +++ b/src/Umbraco.Web.UI.Client/package.json @@ -71,10 +71,13 @@ "./user": "./dist-cms/packages/user/user/index.js", "./user-permission": "./dist-cms/packages/user/user-permission/index.js", "./code-editor": "./dist-cms/packages/templating/code-editor/index.js", - "./external/*": "./dist-cms/external/*/index.js" + "./external/*": "./dist-cms/external/*/index.js", + "./examples/*": "./examples/*/index.js", + "./examples": "./examples/index.js" }, "files": [ "dist-cms", + "examples", "README.md" ], "repository": { @@ -120,7 +123,8 @@ "new-extension": "plop --plopfile ./devops/plop/plop.js", "compile": "tsc", "check": "npm run lint:errors && npm run compile && npm run build-storybook && npm run generate:jsonschema:dist", - "prepublishOnly": "node ./devops/publish/cleanse-pkg.js" + "prepublishOnly": "node ./devops/publish/cleanse-pkg.js", + "example": "node ./devops/example-runner/index.js" }, "engines": { "node": ">=20.9 <21", diff --git a/src/Umbraco.Web.UI.Client/src/libs/class-api/class.mixin.ts b/src/Umbraco.Web.UI.Client/src/libs/class-api/class.mixin.ts index ac2d5f957f..ea13fbc663 100644 --- a/src/Umbraco.Web.UI.Client/src/libs/class-api/class.mixin.ts +++ b/src/Umbraco.Web.UI.Client/src/libs/class-api/class.mixin.ts @@ -17,7 +17,7 @@ import { UmbObserverController } from '@umbraco-cms/backoffice/observable-api'; type UmbClassMixinConstructor = new ( host: UmbControllerHost, - controllerAlias: UmbControllerAlias + controllerAlias: UmbControllerAlias, ) => UmbClassMixinDeclaration; declare class UmbClassMixinDeclaration implements UmbClassMixinInterface { @@ -25,16 +25,19 @@ declare class UmbClassMixinDeclaration implements UmbClassMixinInterface { observe( source: Observable, callback: (_value: T) => void, - controllerAlias?: UmbControllerAlias + controllerAlias?: UmbControllerAlias, ): UmbObserverController; provideContext< BaseType = unknown, ResultType extends BaseType = BaseType, - InstanceType extends ResultType = ResultType - >(alias: string | UmbContextToken, instance: InstanceType): UmbContextProviderController; + InstanceType extends ResultType = ResultType, + >( + alias: string | UmbContextToken, + instance: InstanceType, + ): UmbContextProviderController; consumeContext( alias: string | UmbContextToken, - callback: UmbContextCallback + callback: UmbContextCallback, ): UmbContextConsumerController; hasController(controller: UmbController): boolean; getControllers(filterMethod: (ctrl: UmbController) => boolean): UmbController[]; @@ -86,15 +89,13 @@ export const UmbClassMixin = (superClass: T) => { * @return {UmbContextProviderController} Reference to a Context Provider Controller instance * @memberof UmbElementMixin */ - provideContext - < + provideContext< BaseType = unknown, ResultType extends BaseType = BaseType, - InstanceType extends ResultType = ResultType - > - ( + InstanceType extends ResultType = ResultType, + >( contextAlias: string | UmbContextToken, - instance: InstanceType + instance: InstanceType, ): UmbContextProviderController { return new UmbContextProviderController(this, contextAlias, instance); } @@ -108,8 +109,8 @@ export const UmbClassMixin = (superClass: T) => { */ consumeContext( contextAlias: string | UmbContextToken, - callback: UmbContextCallback - ): UmbContextConsumerController { + callback: UmbContextCallback, + ): UmbContextConsumerController { return new UmbContextConsumerController(this, contextAlias, callback); } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/workspace-action/workspace-action-base.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/workspace-action/workspace-action-base.ts index 1251b1c334..a8fb5daf1c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/workspace-action/workspace-action-base.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/workspace-action/workspace-action-base.ts @@ -1,23 +1,20 @@ import { UmbWorkspaceContextInterface, UMB_WORKSPACE_CONTEXT } from '../workspace-context/index.js'; -import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; -import { UmbContextConsumerController } from '@umbraco-cms/backoffice/context-api'; +import { UmbBaseController, type UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import type { UmbApi } from '@umbraco-cms/backoffice/extension-api'; -export interface UmbWorkspaceAction extends UmbApi { - host: UmbControllerHost; - workspaceContext?: WorkspaceType; +export interface UmbWorkspaceAction extends UmbApi { execute(): Promise; } -export abstract class UmbWorkspaceActionBase - implements UmbWorkspaceAction +export abstract class UmbWorkspaceActionBase extends UmbBaseController + implements UmbWorkspaceAction { - host: UmbControllerHost; workspaceContext?: WorkspaceContextType; constructor(host: UmbControllerHost) { - this.host = host; + super(host); - new UmbContextConsumerController(this.host, UMB_WORKSPACE_CONTEXT, (instance) => { + // TODO, we most likely should require a context token here in this type, and mane it specifically for workspace actions with context workspace request. + this.consumeContext(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 WorkspaceContextType; }); diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/document-workspace.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/document-workspace.element.ts index 3c6d303203..95b7d77d5e 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/document-workspace.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/document-workspace.element.ts @@ -54,6 +54,7 @@ export class UmbDocumentWorkspaceElement extends UmbLitElement { }, ]; + // TODO: We need to recreate when ID changed? new UmbExtensionsApiInitializer(this, umbExtensionsRegistry, 'workspaceContext', [this, this.#workspaceContext]); } diff --git a/src/Umbraco.Web.UI.Client/tsconfig.json b/src/Umbraco.Web.UI.Client/tsconfig.json index 289bf2ba2f..9aeafe340d 100644 --- a/src/Umbraco.Web.UI.Client/tsconfig.json +++ b/src/Umbraco.Web.UI.Client/tsconfig.json @@ -126,7 +126,7 @@ "@umbraco-cms/internal/test-utils": ["utils/test-utils.ts"] } }, - "include": ["src/**/*.ts", "apps/**/*.ts", "e2e/**/*.ts", "index.ts", "storybook/stories/**/*.ts"], + "include": ["src/**/*.ts", "apps/**/*.ts", "e2e/**/*.ts", "index.ts", "storybook/stories/**/*.ts", "examples/**/*.ts", ], "references": [ { "path": "./tsconfig.node.json"