
Umbraco Example Generator
- 280 installs
- 26 repo stars
- Updated August 1, 2026
- umbraco/umbraco-cms-backoffice-skills
Helps with ai & agent building tasks.
About
umbraco-example-generator is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- umbraco-example-generator
- AI & Agent Building
- AI-coding skill
Umbraco Example Generator by the numbers
- 280 all-time installs (skills.sh)
- +12 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #2,417 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/umbraco/umbraco-cms-backoffice-skills --skill umbraco-example-generatorAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 280 |
|---|---|
| repo stars | ★ 26 |
| Last updated | August 1, 2026 |
| Repository | umbraco/umbraco-cms-backoffice-skills ↗ |
What it does
Helps with ai & agent building tasks.
Files
Umbraco Example Generator
Generate complete, testable example extensions for the Umbraco backoffice and run them using the Umbraco source's dev infrastructure.
When to Use
- Creating demonstration extensions
- Building testable extension examples
- Rapid development with hot reload
- Testing extensions without .NET backend
Related Skills
- umbraco-unit-testing - Add unit tests to examples
- umbraco-mocked-backoffice - E2E testing patterns
- umbraco-backoffice - Extension type blueprints
---
Quick Start
1. Clone Umbraco source (one-time setup)
git clone https://github.com/umbraco/Umbraco-CMS
cd Umbraco-CMS/src/Umbraco.Web.UI.Client
npm install2. Create your extension folder
my-extension/
├── index.ts # REQUIRED - exports manifests
└── my-element.ts # Your element(s)3. Create index.ts (exports manifests)
import './my-element.js';
export const manifests = [
{
type: 'dashboard',
alias: 'My.Dashboard',
name: 'My Dashboard',
element: 'my-element',
meta: { label: 'My Dashboard', pathname: 'my-dashboard' },
conditions: [{ alias: 'Umb.Condition.SectionAlias', match: 'Umb.Section.Content' }]
}
];4. Create your element
// my-element.ts
import { LitElement, html, customElement } from '@umbraco-cms/backoffice/external/lit';
@customElement('my-element')
export class MyElement extends LitElement {
render() {
return html`<uui-box headline="Hello">It works!</uui-box>`;
}
}5. Run it
cd Umbraco-CMS/src/Umbraco.Web.UI.Client
VITE_EXTERNAL_EXTENSION=/full/path/to/my-extension npm run dev:externalOpen http://localhost:5173 - your extension appears in the Content section.
---
How It Works
The Umbraco source (Umbraco-CMS/src/Umbraco.Web.UI.Client) provides two ways to load extensions:
1. Internal Examples (npm run example)
Examples placed in the examples/ folder inside the Umbraco source.
cd Umbraco-CMS/src/Umbraco.Web.UI.Client
npm run example
# Select from list of examplesHow it works: Sets VITE_EXAMPLE_PATH and imports ./examples/{name}/index.ts
2. External Extensions (npm run dev:external)
Extensions from any location on your filesystem - perfect for developing packages.
cd Umbraco-CMS/src/Umbraco.Web.UI.Client
VITE_EXTERNAL_EXTENSION=/path/to/your/extension npm run dev:externalHow it works: 1. Sets VITE_UMBRACO_USE_MSW=on (mocked APIs) 2. Creates @external-extension alias pointing to your extension path 3. Imports @external-extension/index.ts and registers exports with umbExtensionsRegistry 4. Resolves @umbraco-cms/backoffice/* imports from the main project (avoids duplicate element registrations)
Extension Loading (index.ts)
// From Umbraco-CMS/src/Umbraco.Web.UI.Client/index.ts
if (import.meta.env.VITE_EXTERNAL_EXTENSION) {
const js = await import('@external-extension/index.ts');
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);
}
});
}
}Key point: Your index.ts must export manifests (arrays or objects) that get registered automatically.
---
Setup
Prerequisites
Clone and set up the Umbraco source:
git clone https://github.com/umbraco/Umbraco-CMS
cd Umbraco-CMS/src/Umbraco.Web.UI.Client
npm installExtension Structure
Your extension needs this minimal structure:
my-extension/
├── index.ts # Exports manifests array (REQUIRED)
├── my-element.ts # Your element(s)
├── my-context.ts # Context (if needed)
├── package.json # Optional - for IDE support and tests
├── tsconfig.json # Optional - for IDE support
└── README.md # DocumentationRequired: index.ts
Your index.ts must export manifests that will be registered:
import './my-dashboard.element.js';
export const manifests = [
{
type: 'dashboard',
alias: 'My.Dashboard',
name: 'My Dashboard',
element: 'my-dashboard',
weight: 100,
meta: {
label: 'My Dashboard',
pathname: 'my-dashboard'
},
conditions: [
{
alias: 'Umb.Condition.SectionAlias',
match: 'Umb.Section.Content'
}
]
}
];Optional: package.json (for IDE support)
{
"name": "my-extension",
"type": "module",
"devDependencies": {
"@umbraco-cms/backoffice": "^17.0.0",
"typescript": "~5.8.0"
}
}Important: The @umbraco-cms/backoffice dependency is only for IDE TypeScript support. At runtime, imports are resolved from the main Umbraco project.
---
Running Your Extension
Start the mocked backoffice
cd /path/to/Umbraco-CMS/src/Umbraco.Web.UI.Client
VITE_EXTERNAL_EXTENSION=/absolute/path/to/my-extension npm run dev:externalOpen in browser
Navigate to http://localhost:5173 - your extension is loaded automatically.
Hot reload
Changes to your extension files trigger hot reload - no restart needed.
---
Patterns
Basic Element
// my-dashboard.element.ts
import { LitElement, html, css, customElement } from '@umbraco-cms/backoffice/external/lit';
@customElement('my-dashboard')
export class MyDashboardElement extends LitElement {
static override styles = css`
:host {
display: block;
padding: var(--uui-size-layout-1);
}
`;
override render() {
return html`
<uui-box headline="My Extension">
<p>Running in the mocked backoffice!</p>
</uui-box>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'my-dashboard': MyDashboardElement;
}
}Element with Context
import { html, customElement, state } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { EXAMPLE_MY_CONTEXT } from './my-context.js';
@customElement('example-my-feature-view')
export class ExampleMyFeatureViewElement extends UmbLitElement {
@state()
private _value?: string;
constructor() {
super();
this.consumeContext(EXAMPLE_MY_CONTEXT, (context) => {
this.observe(context.value, (value) => {
this._value = value;
});
});
}
override render() {
return html`
<uui-box headline="My Feature Example">
<p>Current value: ${this._value ?? 'Loading...'}</p>
</uui-box>
`;
}
}
export default ExampleMyFeatureViewElement;
declare global {
interface HTMLElementTagNameMap {
'example-my-feature-view': ExampleMyFeatureViewElement;
}
}Context
import { UmbContextToken } from '@umbraco-cms/backoffice/context-api';
import { UmbContextBase } from '@umbraco-cms/backoffice/class-api';
import { UmbStringState } from '@umbraco-cms/backoffice/observable-api';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
export class ExampleMyContext extends UmbContextBase {
#value = new UmbStringState('initial');
readonly value = this.#value.asObservable();
constructor(host: UmbControllerHost) {
super(host, EXAMPLE_MY_CONTEXT);
}
setValue(value: string) {
this.#value.setValue(value);
}
getValue() {
return this.#value.getValue();
}
public override destroy(): void {
this.#value.destroy();
super.destroy();
}
}
export const EXAMPLE_MY_CONTEXT = new UmbContextToken<ExampleMyContext>(
'ExampleMyContext'
);
export { ExampleMyContext as api };---
Adding Tests
Unit Tests
Add unit tests using @open-wc/testing. See umbraco-unit-testing skill for full setup.
npm install --save-dev @open-wc/testing @web/test-runner @web/test-runner-playwrightE2E Tests (Playwright)
Add E2E tests that run against the mocked backoffice. See umbraco-mocked-backoffice skill for patterns.
npm install --save-dev @playwright/test
npx playwright install chromium---
Examples
Reference Example
Location: ./examples/workspace-feature-toggle/
A complete standalone example demonstrating:
- Workspace context with
UmbArrayState - Workspace view consuming context
- Workspace action executing context methods
- Workspace footer app showing summary
- 38 unit tests + 13 E2E tests
cd examples/workspace-feature-toggle
npm install
npm test # Unit tests
npm run test:e2e # E2E tests (requires mocked backoffice running)Official Umbraco Examples
Location: Umbraco-CMS/src/Umbraco.Web.UI.Client/examples/
27 official examples covering all extension types. Run any example:
cd Umbraco-CMS/src/Umbraco.Web.UI.Client
npm run example
# Select from list---
Naming Conventions
| Item | Convention | Example |
|---|---|---|
| Directory | kebab-case describing feature | workspace-context-counter |
| Alias prefix | example. | example.workspaceView.counter |
| Element prefix | example- | example-counter-view |
| Context token | EXAMPLE_ + SCREAMING_CASE | EXAMPLE_COUNTER_CONTEXT |
---
Troubleshooting
Extension not appearing
1. Check index.ts exports a manifests array 2. Verify the path in VITE_EXTERNAL_EXTENSION is absolute 3. Check browser console for 📦 Loading external extension from: message 4. Ensure condition matches the section you're viewing
Import errors
Imports should use @umbraco-cms/backoffice/*. The Vite plugin resolves these from the main project.
"CustomElementRegistry" already defined
Your extension's node_modules is being used instead of the main project's. The external-extension-resolver plugin should handle this, but ensure:
- You're using
npm run dev:external - Imports use
@umbraco-cms/backoffice/*not relative paths to node_modules
Changes not hot reloading
Ensure the file is within the path specified by VITE_EXTERNAL_EXTENSION. Only files in that directory tree are watched.
import { EXAMPLE_FEATURE_TOGGLE_CONTEXT } from './feature-toggle-context.js';
import { UmbWorkspaceActionBase, type UmbWorkspaceAction } from '@umbraco-cms/backoffice/workspace';
export class ExampleFeatureToggleAction extends UmbWorkspaceActionBase implements UmbWorkspaceAction {
override async execute() {
const context = await this.getContext(EXAMPLE_FEATURE_TOGGLE_CONTEXT);
if (!context) {
throw new Error('Could not get the feature toggle context');
}
context.toggleAll();
}
}
export const api = ExampleFeatureToggleAction;
import { ExampleFeatureToggleContext, EXAMPLE_FEATURE_TOGGLE_CONTEXT } from './feature-toggle-context.js';
import { ExampleFeatureToggleViewElement } from './feature-toggle-view.element.js';
import { ExampleFeatureToggleFooterElement } from './feature-toggle-footer.element.js';
import { expect, fixture, defineCE } from '@open-wc/testing';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { html } from '@umbraco-cms/backoffice/external/lit';
class TestHostElement extends UmbLitElement {}
const testHostElement = defineCE(TestHostElement);
describe('ExampleFeatureToggleContext', () => {
let element: UmbLitElement;
let context: ExampleFeatureToggleContext;
beforeEach(async () => {
element = await fixture(`<${testHostElement}></${testHostElement}>`);
context = new ExampleFeatureToggleContext(element);
});
describe('Initialization', () => {
it('initializes with default features', (done) => {
context.features.subscribe((features) => {
expect(features).to.be.an('array');
expect(features.length).to.equal(3);
done();
});
});
it('has correct default feature IDs', (done) => {
context.features.subscribe((features) => {
const ids = features.map((f) => f.id);
expect(ids).to.include('dark-mode');
expect(ids).to.include('auto-save');
expect(ids).to.include('preview-mode');
done();
});
});
it('has auto-save enabled by default', (done) => {
context.features.subscribe((features) => {
const autoSave = features.find((f) => f.id === 'auto-save');
expect(autoSave?.enabled).to.be.true;
done();
});
});
it('has dark-mode disabled by default', (done) => {
context.features.subscribe((features) => {
const darkMode = features.find((f) => f.id === 'dark-mode');
expect(darkMode?.enabled).to.be.false;
done();
});
});
});
describe('Derived state', () => {
it('calculates initial active count correctly', (done) => {
context.activeCount.subscribe((count) => {
expect(count).to.equal(1);
done();
});
});
it('allEnabled is false when not all features enabled', (done) => {
context.allEnabled.subscribe((allEnabled) => {
expect(allEnabled).to.be.false;
done();
});
});
it('allDisabled is false when any feature enabled', (done) => {
context.allDisabled.subscribe((allDisabled) => {
expect(allDisabled).to.be.false;
done();
});
});
});
describe('Toggle functionality', () => {
it('toggles a disabled feature to enabled', (done) => {
let callCount = 0;
context.features.subscribe((features) => {
callCount++;
if (callCount === 1) {
const darkMode = features.find((f) => f.id === 'dark-mode');
expect(darkMode?.enabled).to.be.false;
context.toggle('dark-mode');
} else if (callCount === 2) {
const darkMode = features.find((f) => f.id === 'dark-mode');
expect(darkMode?.enabled).to.be.true;
done();
}
});
});
it('toggles an enabled feature to disabled', (done) => {
let callCount = 0;
context.features.subscribe((features) => {
callCount++;
if (callCount === 1) {
const autoSave = features.find((f) => f.id === 'auto-save');
expect(autoSave?.enabled).to.be.true;
context.toggle('auto-save');
} else if (callCount === 2) {
const autoSave = features.find((f) => f.id === 'auto-save');
expect(autoSave?.enabled).to.be.false;
done();
}
});
});
it('updates active count when toggling', (done) => {
let callCount = 0;
context.activeCount.subscribe((count) => {
callCount++;
if (callCount === 1) {
expect(count).to.equal(1);
context.toggle('dark-mode');
} else if (callCount === 2) {
expect(count).to.equal(2);
done();
}
});
});
it('does nothing for non-existent feature ID', (done) => {
let callCount = 0;
context.features.subscribe(() => {
callCount++;
if (callCount === 1) {
context.toggle('non-existent');
setTimeout(() => {
expect(callCount).to.equal(1);
done();
}, 50);
}
});
});
});
describe('Enable and Disable', () => {
it('enables a specific feature', (done) => {
let callCount = 0;
context.features.subscribe((features) => {
callCount++;
if (callCount === 1) {
context.enable('dark-mode');
} else if (callCount === 2) {
const darkMode = features.find((f) => f.id === 'dark-mode');
expect(darkMode?.enabled).to.be.true;
done();
}
});
});
it('disables a specific feature', (done) => {
let callCount = 0;
context.features.subscribe((features) => {
callCount++;
if (callCount === 1) {
context.disable('auto-save');
} else if (callCount === 2) {
const autoSave = features.find((f) => f.id === 'auto-save');
expect(autoSave?.enabled).to.be.false;
done();
}
});
});
it('enable does not emit if already enabled', (done) => {
let callCount = 0;
context.features.subscribe(() => {
callCount++;
if (callCount === 1) {
context.enable('auto-save');
setTimeout(() => {
expect(callCount).to.equal(1);
done();
}, 50);
}
});
});
});
describe('Enable All and Disable All', () => {
it('enables all features', (done) => {
let callCount = 0;
context.features.subscribe((features) => {
callCount++;
if (callCount === 1) {
context.enableAll();
} else if (callCount === 2) {
const allEnabled = features.every((f) => f.enabled);
expect(allEnabled).to.be.true;
done();
}
});
});
it('disables all features', (done) => {
let callCount = 0;
context.features.subscribe((features) => {
callCount++;
if (callCount === 1) {
context.disableAll();
} else if (callCount === 2) {
const allDisabled = features.every((f) => !f.enabled);
expect(allDisabled).to.be.true;
done();
}
});
});
it('sets allEnabled to true after enableAll', (done) => {
let callCount = 0;
context.allEnabled.subscribe((allEnabled) => {
callCount++;
if (callCount === 1) {
expect(allEnabled).to.be.false;
context.enableAll();
} else if (callCount === 2) {
expect(allEnabled).to.be.true;
done();
}
});
});
it('sets allDisabled to true after disableAll', (done) => {
let callCount = 0;
context.allDisabled.subscribe((allDisabled) => {
callCount++;
if (callCount === 1) {
expect(allDisabled).to.be.false;
context.disableAll();
} else if (callCount === 2) {
expect(allDisabled).to.be.true;
done();
}
});
});
});
describe('Toggle All', () => {
it('enables all when some are disabled', (done) => {
let callCount = 0;
context.activeCount.subscribe((count) => {
callCount++;
if (callCount === 1) {
expect(count).to.equal(1);
context.toggleAll();
} else if (callCount === 2) {
expect(count).to.equal(3);
done();
}
});
});
it('disables all when all are enabled', (done) => {
let callCount = 0;
context.activeCount.subscribe((count) => {
callCount++;
if (callCount === 1) {
context.enableAll();
} else if (callCount === 2) {
expect(count).to.equal(3);
context.toggleAll();
} else if (callCount === 3) {
expect(count).to.equal(0);
done();
}
});
});
});
describe('Synchronous methods', () => {
it('isEnabled returns correct state', () => {
expect(context.isEnabled('auto-save')).to.be.true;
expect(context.isEnabled('dark-mode')).to.be.false;
});
it('getActiveCount returns correct count', () => {
expect(context.getActiveCount()).to.equal(1);
});
});
describe('Reset', () => {
it('resets features to default state', (done) => {
let callCount = 0;
context.features.subscribe((features) => {
callCount++;
if (callCount === 1) {
context.enableAll();
} else if (callCount === 2) {
expect(features.every((f) => f.enabled)).to.be.true;
context.reset();
} else if (callCount === 3) {
const autoSave = features.find((f) => f.id === 'auto-save');
const darkMode = features.find((f) => f.id === 'dark-mode');
expect(autoSave?.enabled).to.be.true;
expect(darkMode?.enabled).to.be.false;
done();
}
});
});
});
describe('Context integration', () => {
it('provides context that can be consumed by other components', () => {
expect(EXAMPLE_FEATURE_TOGGLE_CONTEXT).to.not.be.undefined;
});
});
});
describe('ExampleFeatureToggleViewElement', () => {
let element: ExampleFeatureToggleViewElement;
let context: ExampleFeatureToggleContext;
let hostElement: UmbLitElement;
beforeEach(async () => {
hostElement = await fixture(`<${testHostElement}></${testHostElement}>`);
context = new ExampleFeatureToggleContext(hostElement);
element = await fixture(html`<example-feature-toggle-view></example-feature-toggle-view>`, {
parentNode: hostElement,
});
await element.updateComplete;
});
describe('Feature display', () => {
it('shows initial feature count', async () => {
await element.updateComplete;
const displayText = element.shadowRoot?.textContent;
expect(displayText).to.include('1 of 3 features enabled');
});
it('reflects feature changes when enableAll called', async () => {
context.enableAll();
await element.updateComplete;
const displayText = element.shadowRoot?.textContent;
expect(displayText).to.include('3 of 3 features enabled');
});
it('reflects feature changes when disableAll called', async () => {
context.disableAll();
await element.updateComplete;
const displayText = element.shadowRoot?.textContent;
expect(displayText).to.include('0 of 3 features enabled');
});
it('reflects feature changes when individual feature toggled', async () => {
context.toggle('dark-mode');
await element.updateComplete;
const displayText = element.shadowRoot?.textContent;
expect(displayText).to.include('2 of 3 features enabled');
});
});
describe('UI interactions', () => {
it('clicking Enable All button enables all features', async () => {
const enableAllButton = element.shadowRoot?.querySelector('uui-button[look="secondary"]') as HTMLElement;
expect(enableAllButton).to.exist;
enableAllButton.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await element.updateComplete;
const displayText = element.shadowRoot?.textContent;
expect(displayText).to.include('3 of 3 features enabled');
});
it('clicking Disable All button disables all features', async () => {
// First enable all
context.enableAll();
await element.updateComplete;
// Find Disable All button (second secondary button)
const buttons = element.shadowRoot?.querySelectorAll('uui-button[look="secondary"]');
const disableAllButton = buttons?.[1] as HTMLElement;
expect(disableAllButton).to.exist;
disableAllButton.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await element.updateComplete;
const displayText = element.shadowRoot?.textContent;
expect(displayText).to.include('0 of 3 features enabled');
});
it('clicking Reset button restores default state', async () => {
// First enable all
context.enableAll();
await element.updateComplete;
expect(element.shadowRoot?.textContent).to.include('3 of 3 features enabled');
// Click Reset button
const resetButton = element.shadowRoot?.querySelector('uui-button[look="outline"]') as HTMLElement;
expect(resetButton).to.exist;
resetButton.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await element.updateComplete;
const displayText = element.shadowRoot?.textContent;
expect(displayText).to.include('1 of 3 features enabled');
});
it('clicking a toggle switches the feature state', async () => {
// Find the first toggle (dark-mode, which is disabled by default)
const toggles = element.shadowRoot?.querySelectorAll('uui-toggle');
const darkModeToggle = toggles?.[0] as HTMLElement;
expect(darkModeToggle).to.exist;
// Initially 1 of 3 enabled
expect(element.shadowRoot?.textContent).to.include('1 of 3 features enabled');
// UUI toggle needs a change event dispatched
darkModeToggle.dispatchEvent(new Event('change', { bubbles: true }));
await element.updateComplete;
// Now 2 of 3 enabled
expect(element.shadowRoot?.textContent).to.include('2 of 3 features enabled');
});
it('clicking multiple toggles updates count correctly', async () => {
const toggles = element.shadowRoot?.querySelectorAll('uui-toggle');
// Click dark-mode toggle (enable it)
(toggles?.[0] as HTMLElement).dispatchEvent(new Event('change', { bubbles: true }));
await element.updateComplete;
expect(element.shadowRoot?.textContent).to.include('2 of 3 features enabled');
// Click preview-mode toggle (enable it)
(toggles?.[2] as HTMLElement).dispatchEvent(new Event('change', { bubbles: true }));
await element.updateComplete;
expect(element.shadowRoot?.textContent).to.include('3 of 3 features enabled');
// Click auto-save toggle (disable it)
(toggles?.[1] as HTMLElement).dispatchEvent(new Event('change', { bubbles: true }));
await element.updateComplete;
expect(element.shadowRoot?.textContent).to.include('2 of 3 features enabled');
});
});
});
describe('ExampleFeatureToggleFooterElement', () => {
let element: ExampleFeatureToggleFooterElement;
let context: ExampleFeatureToggleContext;
let hostElement: UmbLitElement;
beforeEach(async () => {
hostElement = await fixture(`<${testHostElement}></${testHostElement}>`);
context = new ExampleFeatureToggleContext(hostElement);
element = await fixture(html`<example-feature-toggle-footer></example-feature-toggle-footer>`, {
parentNode: hostElement,
});
await element.updateComplete;
});
describe('Status display', () => {
it('shows initial active count', async () => {
await element.updateComplete;
const displayText = element.shadowRoot?.textContent;
expect(displayText).to.include('1 feature active');
});
it('reflects count changes when features enabled', async () => {
context.enableAll();
await element.updateComplete;
const displayText = element.shadowRoot?.textContent;
expect(displayText).to.include('3 features active');
expect(displayText).to.include('(all enabled)');
});
it('reflects count changes when features disabled', async () => {
context.disableAll();
await element.updateComplete;
const displayText = element.shadowRoot?.textContent;
expect(displayText).to.include('0 features active');
});
it('uses singular form for single feature', async () => {
await element.updateComplete;
const displayText = element.shadowRoot?.textContent;
expect(displayText).to.include('1 feature active');
});
it('uses plural form for multiple features', async () => {
context.enableAll();
await element.updateComplete;
const displayText = element.shadowRoot?.textContent;
expect(displayText).to.include('3 features active');
});
});
});
import { UmbContextToken } from '@umbraco-cms/backoffice/context-api';
import { UmbContextBase } from '@umbraco-cms/backoffice/class-api';
import { UmbArrayState } from '@umbraco-cms/backoffice/observable-api';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
export interface Feature {
id: string;
name: string;
description: string;
enabled: boolean;
}
const DEFAULT_FEATURES: Feature[] = [
{
id: 'dark-mode',
name: 'Dark Mode',
description: 'Enable dark theme for this document',
enabled: false,
},
{
id: 'auto-save',
name: 'Auto Save',
description: 'Automatically save changes every 30 seconds',
enabled: true,
},
{
id: 'preview-mode',
name: 'Preview Mode',
description: 'Show live preview panel',
enabled: false,
},
];
export class ExampleFeatureToggleContext extends UmbContextBase {
#features = new UmbArrayState<Feature>(DEFAULT_FEATURES, (x) => x.id);
readonly features = this.#features.asObservable();
readonly activeCount = this.#features.asObservablePart((features) =>
features.filter((f) => f.enabled).length
);
readonly allEnabled = this.#features.asObservablePart((features) =>
features.length > 0 && features.every((f) => f.enabled)
);
readonly allDisabled = this.#features.asObservablePart((features) =>
features.every((f) => !f.enabled)
);
constructor(host: UmbControllerHost) {
super(host, EXAMPLE_FEATURE_TOGGLE_CONTEXT);
}
toggle(featureId: string): void {
const features = this.#features.getValue();
const feature = features.find((f) => f.id === featureId);
if (feature) {
this.#features.updateOne(featureId, {
...feature,
enabled: !feature.enabled,
});
}
}
enable(featureId: string): void {
const features = this.#features.getValue();
const feature = features.find((f) => f.id === featureId);
if (feature && !feature.enabled) {
this.#features.updateOne(featureId, {
...feature,
enabled: true,
});
}
}
disable(featureId: string): void {
const features = this.#features.getValue();
const feature = features.find((f) => f.id === featureId);
if (feature && feature.enabled) {
this.#features.updateOne(featureId, {
...feature,
enabled: false,
});
}
}
enableAll(): void {
const features = this.#features.getValue();
const updated = features.map((f) => ({ ...f, enabled: true }));
this.#features.setValue(updated);
}
disableAll(): void {
const features = this.#features.getValue();
const updated = features.map((f) => ({ ...f, enabled: false }));
this.#features.setValue(updated);
}
toggleAll(): void {
const features = this.#features.getValue();
const allEnabled = features.every((f) => f.enabled);
if (allEnabled) {
this.disableAll();
} else {
this.enableAll();
}
}
isEnabled(featureId: string): boolean {
const features = this.#features.getValue();
const feature = features.find((f) => f.id === featureId);
return feature?.enabled ?? false;
}
getActiveCount(): number {
return this.#features.getValue().filter((f) => f.enabled).length;
}
reset(): void {
this.#features.setValue([...DEFAULT_FEATURES]);
}
}
export const api = ExampleFeatureToggleContext;
export const EXAMPLE_FEATURE_TOGGLE_CONTEXT = new UmbContextToken<ExampleFeatureToggleContext>(
'UmbWorkspaceContext',
'example.workspaceContext.featureToggle',
);
import { EXAMPLE_FEATURE_TOGGLE_CONTEXT } from './feature-toggle-context.js';
import { customElement, html, state, LitElement } from '@umbraco-cms/backoffice/external/lit';
import { UmbElementMixin } from '@umbraco-cms/backoffice/element-api';
@customElement('example-feature-toggle-footer')
export class ExampleFeatureToggleFooterElement extends UmbElementMixin(LitElement) {
@state()
private _activeCount = 0;
@state()
private _allEnabled = false;
constructor() {
super();
this.#observeContext();
}
async #observeContext() {
const context = await this.getContext(EXAMPLE_FEATURE_TOGGLE_CONTEXT);
if (!context) return;
this.observe(context.activeCount, (count) => {
this._activeCount = count;
});
this.observe(context.allEnabled, (allEnabled) => {
this._allEnabled = allEnabled;
});
}
override render() {
return html`
<span>
${this._activeCount} feature${this._activeCount !== 1 ? 's' : ''} active
${this._allEnabled ? '(all enabled)' : ''}
</span>
`;
}
}
export default ExampleFeatureToggleFooterElement;
declare global {
interface HTMLElementTagNameMap {
'example-feature-toggle-footer': ExampleFeatureToggleFooterElement;
}
}
import { EXAMPLE_FEATURE_TOGGLE_CONTEXT, type Feature } from './feature-toggle-context.js';
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
import { css, html, customElement, state, repeat, LitElement } from '@umbraco-cms/backoffice/external/lit';
import { UmbElementMixin } from '@umbraco-cms/backoffice/element-api';
@customElement('example-feature-toggle-view')
export class ExampleFeatureToggleViewElement extends UmbElementMixin(LitElement) {
#context?: typeof EXAMPLE_FEATURE_TOGGLE_CONTEXT.TYPE;
@state()
private _features: Feature[] = [];
@state()
private _activeCount = 0;
constructor() {
super();
this.consumeContext(EXAMPLE_FEATURE_TOGGLE_CONTEXT, (context) => {
this.#context = context;
this.#observeFeatures();
});
}
#observeFeatures(): void {
if (!this.#context) return;
this.observe(this.#context.features, (features) => {
this._features = features;
});
this.observe(this.#context.activeCount, (count) => {
this._activeCount = count;
});
}
#onToggle(featureId: string) {
this.#context?.toggle(featureId);
}
#onEnableAll() {
this.#context?.enableAll();
}
#onDisableAll() {
this.#context?.disableAll();
}
#onReset() {
this.#context?.reset();
}
override render() {
return html`
<uui-box headline="Feature Toggles">
<div class="header">
<span class="count">${this._activeCount} of ${this._features.length} features enabled</span>
<div class="actions">
<uui-button look="secondary" @click=${this.#onEnableAll}>Enable All</uui-button>
<uui-button look="secondary" @click=${this.#onDisableAll}>Disable All</uui-button>
<uui-button look="outline" @click=${this.#onReset}>Reset</uui-button>
</div>
</div>
<div class="feature-list">
${repeat(
this._features,
(feature) => feature.id,
(feature) => html`
<div class="feature-item">
<uui-toggle
.checked=${feature.enabled}
@change=${() => this.#onToggle(feature.id)}
>
<div class="feature-info">
<strong>${feature.name}</strong>
<span class="description">${feature.description}</span>
</div>
</uui-toggle>
</div>
`
)}
</div>
</uui-box>
`;
}
static override styles = [
UmbTextStyles,
css`
:host {
display: block;
padding: var(--uui-size-layout-1);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--uui-size-space-5);
padding-bottom: var(--uui-size-space-4);
border-bottom: 1px solid var(--uui-color-border);
}
.count {
font-size: var(--uui-type-small-size);
color: var(--uui-color-text-alt);
}
.actions {
display: flex;
gap: var(--uui-size-space-2);
}
.feature-list {
display: flex;
flex-direction: column;
gap: var(--uui-size-space-4);
}
.feature-item {
padding: var(--uui-size-space-4);
background: var(--uui-color-surface-alt);
border-radius: var(--uui-border-radius);
}
.feature-info {
display: flex;
flex-direction: column;
gap: var(--uui-size-space-1);
}
.description {
font-size: var(--uui-type-small-size);
color: var(--uui-color-text-alt);
}
`,
];
}
export default ExampleFeatureToggleViewElement;
declare global {
interface HTMLElementTagNameMap {
'example-feature-toggle-view': ExampleFeatureToggleViewElement;
}
}
import { UMB_WORKSPACE_CONDITION_ALIAS } from '@umbraco-cms/backoffice/workspace';
export const manifests = [
{
type: 'workspaceContext',
name: 'Example Feature Toggle Workspace Context',
alias: 'example.workspaceContext.featureToggle',
api: () => import('./feature-toggle-context.js'),
conditions: [
{
alias: UMB_WORKSPACE_CONDITION_ALIAS,
match: 'Umb.Workspace.Document',
},
],
},
{
type: 'workspaceAction',
kind: 'default',
name: 'Example Toggle All Features Action',
alias: 'example.workspaceAction.toggleAllFeatures',
weight: 900,
api: () => import('./feature-toggle-action.js'),
meta: {
label: 'Toggle All Features',
look: 'secondary',
},
conditions: [
{
alias: UMB_WORKSPACE_CONDITION_ALIAS,
match: 'Umb.Workspace.Document',
},
],
},
{
type: 'workspaceView',
name: 'Example Feature Toggle Workspace View',
alias: 'example.workspaceView.featureToggle',
element: () => import('./feature-toggle-view.element.js'),
weight: 800,
meta: {
label: 'Feature Toggles',
pathname: 'feature-toggles',
icon: 'icon-settings',
},
conditions: [
{
alias: UMB_WORKSPACE_CONDITION_ALIAS,
match: 'Umb.Workspace.Document',
},
],
},
{
type: 'workspaceFooterApp',
alias: 'example.workspaceFooterApp.featureToggleStatus',
name: 'Feature Toggle Status Footer App',
element: () => import('./feature-toggle-footer.element.js'),
weight: 800,
conditions: [
{
alias: UMB_WORKSPACE_CONDITION_ALIAS,
match: 'Umb.Workspace.Document',
},
],
},
];
{
"name": "@example/workspace-feature-toggle",
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "tsc",
"test": "web-test-runner",
"test:headed": "HEADLESS=false web-test-runner --watch",
"test:watch": "web-test-runner --watch",
"test:e2e": "npx playwright test --config=tests/playwright.config.ts",
"test:e2e:headed": "npx playwright test --config=tests/playwright.config.ts --headed",
"test:e2e:ui": "npx playwright test --config=tests/playwright.config.ts --ui"
},
"devDependencies": {
"@open-wc/testing": "^4.0.0",
"@playwright/test": "^1.57.0",
"@types/mocha": "^10.0.0",
"@types/node": "^22.0.0",
"@umbraco-cms/backoffice": "^17.0.0",
"@web/dev-server-esbuild": "^1.0.0",
"@web/test-runner": "^0.20.0",
"@web/test-runner-playwright": "^0.11.0",
"typescript": "~5.7.0"
}
}
Feature Toggle Example
A complete, testable example demonstrating workspace context patterns in Umbraco backoffice.
Tests
- 38 unit tests - Context and element tests using @open-wc/testing
- 13 E2E tests - Playwright tests against the mocked backoffice (MSW mode)
Extension types included
- workspaceContext - Manages feature toggle state with array and derived observables
- workspaceView - Displays feature toggles and allows user interaction
- workspaceAction - Toggles all features with a single button
- workspaceFooterApp - Shows active feature count in footer
How it works
The ExampleFeatureToggleContext manages an array of features using UmbArrayState. It exposes:
features- Observable array of all featuresactiveCount- Derived observable counting enabled featuresallEnabled/allDisabled- Derived boolean observables
Other extensions consume this context to display and modify state.
Setup
npm install
npx playwright install chromiumRunning Unit Tests
npm testRunning E2E Tests (MSW Mode)
The E2E tests run against the mocked Umbraco backoffice - no .NET backend required.
1. Start the mocked backoffice
cd /path/to/Umbraco-CMS/src/Umbraco.Web.UI.Client
VITE_EXTERNAL_EXTENSION=/path/to/this/folder npm run dev:external2. Run Playwright tests
# Headless
npm run test:e2e
# With browser visible
npm run test:e2e:headed
# Interactive UI mode
npm run test:e2e:uiKey patterns demonstrated
Context with array state
#features = new UmbArrayState<Feature>(DEFAULT_FEATURES, (x) => x.id);
readonly features = this.#features.asObservable();
readonly activeCount = this.#features.asObservablePart((features) =>
features.filter((f) => f.enabled).length
);Testing observables with done() callback
it('initializes with default features', (done) => {
context.features.subscribe((features) => {
expect(features.length).to.equal(3);
done();
});
});Testing state changes with call count
it('toggles feature', (done) => {
let callCount = 0;
context.features.subscribe((features) => {
callCount++;
if (callCount === 1) {
context.toggle('dark-mode');
} else if (callCount === 2) {
expect(features.find(f => f.id === 'dark-mode')?.enabled).to.be.true;
done();
}
});
});Testing elements with context
beforeEach(async () => {
hostElement = await fixture(`<${testHostElement}></${testHostElement}>`);
context = new ExampleFeatureToggleContext(hostElement);
element = await fixture(html`<example-feature-toggle-view></example-feature-toggle-view>`, {
parentNode: hostElement,
});
});import { test, expect, type Page } from '@playwright/test';
/**
* E2E Tests for Workspace Feature Toggle Extension
*
* These tests verify the feature toggle functionality in the Document workspace.
* Tests run against the mocked Umbraco backoffice (MSW mode).
*
* The extension provides:
* - workspaceView: "Feature Toggles" tab with settings icon
* - workspaceAction: "Toggle All Features" button
* - workspaceFooterApp: Shows active feature count
* - workspaceContext: Manages feature toggle state
*
* Prerequisites:
* - The mocked backoffice must be running with the extension loaded:
* cd /path/to/Umbraco.Web.UI.Client
* VITE_EXTERNAL_EXTENSION=/path/to/workspace-feature-toggle npm run dev:external
*/
// Helper to open a document in the workspace
async function openDocument(page: Page) {
// Go directly to a document workspace URL
// The MSW mode has a document at this URL
await page.goto('/section/content/workspace/document/edit/the-simplest-document-id');
await page.waitForLoadState('domcontentloaded');
// Wait for workspace to load
await page.waitForSelector('umb-workspace-editor', { timeout: 30000 });
// Wait for the Feature Toggles tab to appear (our extension)
await page.waitForSelector('uui-tab:has-text("Feature Toggles")', { timeout: 15000 });
}
test.describe('Feature Toggle Workspace Extension', () => {
test.beforeEach(async ({ page }) => {
await openDocument(page);
});
test('should display Feature Toggles tab in document workspace', async ({ page }) => {
// Look for the Feature Toggles tab in the workspace header tabs
const featureTogglesTab = page.locator('uui-tab').filter({ hasText: 'Feature Toggles' });
await expect(featureTogglesTab).toBeVisible({ timeout: 15000 });
});
test('should display Toggle All Features action button', async ({ page }) => {
// The workspace action button should be visible
const toggleAllButton = page.getByRole('button', { name: 'Toggle All Features' });
await expect(toggleAllButton).toBeVisible({ timeout: 15000 });
});
test('should display footer app with feature count', async ({ page }) => {
// The footer app shows active feature count
const footer = page.locator('example-feature-toggle-footer');
await expect(footer).toBeVisible({ timeout: 15000 });
// Default state: 1 feature active (Auto Save is enabled by default)
await expect(footer).toContainText('1 feature active');
});
});
test.describe('Feature Toggle View', () => {
test.beforeEach(async ({ page }) => {
await openDocument(page);
// Navigate to Feature Toggles view
const featureTogglesTab = page.locator('uui-tab').filter({ hasText: 'Feature Toggles' });
await featureTogglesTab.click();
// Wait for the view to render
await page.waitForSelector('example-feature-toggle-view', { timeout: 15000 });
});
test('should display all three default features', async ({ page }) => {
const view = page.locator('example-feature-toggle-view');
// Verify all features are displayed
await expect(view.getByText('Dark Mode')).toBeVisible();
await expect(view.getByText('Auto Save')).toBeVisible();
await expect(view.getByText('Preview Mode')).toBeVisible();
// Verify descriptions
await expect(view.getByText('Enable dark theme for this document')).toBeVisible();
await expect(view.getByText('Automatically save changes every 30 seconds')).toBeVisible();
await expect(view.getByText('Show live preview panel')).toBeVisible();
});
test('should show correct initial count (1 of 3 enabled)', async ({ page }) => {
const view = page.locator('example-feature-toggle-view');
// Auto Save is enabled by default
await expect(view.getByText('1 of 3 features enabled')).toBeVisible();
});
test('should toggle individual feature', async ({ page }) => {
const view = page.locator('example-feature-toggle-view');
// Find and click Dark Mode toggle
const darkModeItem = view.locator('.feature-item').filter({ hasText: 'Dark Mode' });
const toggle = darkModeItem.locator('uui-toggle');
await toggle.click();
// Count should now be 2 of 3
await expect(view.getByText('2 of 3 features enabled')).toBeVisible();
// Toggle back
await toggle.click();
await expect(view.getByText('1 of 3 features enabled')).toBeVisible();
});
test('should enable all features when clicking Enable All', async ({ page }) => {
const view = page.locator('example-feature-toggle-view');
// Click Enable All
await view.getByRole('button', { name: 'Enable All' }).click();
// All 3 should be enabled
await expect(view.getByText('3 of 3 features enabled')).toBeVisible();
// Footer should update
const footer = page.locator('example-feature-toggle-footer');
await expect(footer).toContainText('3 features active');
await expect(footer).toContainText('(all enabled)');
});
test('should disable all features when clicking Disable All', async ({ page }) => {
const view = page.locator('example-feature-toggle-view');
// First enable all
await view.getByRole('button', { name: 'Enable All' }).click();
await expect(view.getByText('3 of 3 features enabled')).toBeVisible();
// Then disable all
await view.getByRole('button', { name: 'Disable All' }).click();
// All should be disabled
await expect(view.getByText('0 of 3 features enabled')).toBeVisible();
// Footer should update
const footer = page.locator('example-feature-toggle-footer');
await expect(footer).toContainText('0 features active');
});
test('should reset to default state', async ({ page }) => {
const view = page.locator('example-feature-toggle-view');
// Enable all first
await view.getByRole('button', { name: 'Enable All' }).click();
await expect(view.getByText('3 of 3 features enabled')).toBeVisible();
// Click Reset
await view.getByRole('button', { name: 'Reset' }).click();
// Should be back to default (1 of 3)
await expect(view.getByText('1 of 3 features enabled')).toBeVisible();
});
});
test.describe('Toggle All Features Action', () => {
test.beforeEach(async ({ page }) => {
await openDocument(page);
});
test('should enable all when some are disabled', async ({ page }) => {
// Click Toggle All Features button
const toggleAllButton = page.getByRole('button', { name: 'Toggle All Features' });
await toggleAllButton.click();
// Navigate to view to verify
const featureTogglesTab = page.locator('uui-tab').filter({ hasText: 'Feature Toggles' });
await featureTogglesTab.click();
const view = page.locator('example-feature-toggle-view');
await expect(view.getByText('3 of 3 features enabled')).toBeVisible();
});
test('should disable all when all are enabled', async ({ page }) => {
// First enable all via the view
const featureTogglesTab = page.locator('uui-tab').filter({ hasText: 'Feature Toggles' });
await featureTogglesTab.click();
const view = page.locator('example-feature-toggle-view');
await view.getByRole('button', { name: 'Enable All' }).click();
await expect(view.getByText('3 of 3 features enabled')).toBeVisible();
// Click Toggle All Features button (should disable all)
const toggleAllButton = page.getByRole('button', { name: 'Toggle All Features' });
await toggleAllButton.click();
// Verify all are disabled
await expect(view.getByText('0 of 3 features enabled')).toBeVisible();
});
});
test.describe('Footer App Updates', () => {
test.beforeEach(async ({ page }) => {
await openDocument(page);
});
test('should update in real-time as features change', async ({ page }) => {
const footer = page.locator('example-feature-toggle-footer');
// Initial state
await expect(footer).toContainText('1 feature active');
// Open view and enable all
const featureTogglesTab = page.locator('uui-tab').filter({ hasText: 'Feature Toggles' });
await featureTogglesTab.click();
const view = page.locator('example-feature-toggle-view');
await view.getByRole('button', { name: 'Enable All' }).click();
// Footer should update
await expect(footer).toContainText('3 features active');
await expect(footer).toContainText('(all enabled)');
// Disable one feature
const darkModeItem = view.locator('.feature-item').filter({ hasText: 'Dark Mode' });
await darkModeItem.locator('uui-toggle').click();
// Footer should update
await expect(footer).toContainText('2 features active');
await expect(footer).not.toContainText('(all enabled)');
});
test('should use correct singular/plural grammar', async ({ page }) => {
const footer = page.locator('example-feature-toggle-footer');
// Default is 1 - singular
await expect(footer).toContainText('1 feature active');
// Open view
const featureTogglesTab = page.locator('uui-tab').filter({ hasText: 'Feature Toggles' });
await featureTogglesTab.click();
const view = page.locator('example-feature-toggle-view');
// Enable another - plural
const darkModeItem = view.locator('.feature-item').filter({ hasText: 'Dark Mode' });
await darkModeItem.locator('uui-toggle').click();
await expect(footer).toContainText('2 features active');
// Disable all - plural (0 features)
await view.getByRole('button', { name: 'Disable All' }).click();
await expect(footer).toContainText('0 features active');
});
});
import { defineConfig, devices } from '@playwright/test';
import { fileURLToPath } from 'url';
import { dirname, resolve } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Path to this extension (parent of tests directory)
const EXTENSION_PATH = resolve(__dirname, '..');
// Path to Umbraco.Web.UI.Client - use environment variable or default location
// Set UMBRACO_CLIENT_PATH if your Umbraco-CMS is in a different location
const UMBRACO_CLIENT_PATH = process.env.UMBRACO_CLIENT_PATH ||
'/Users/philw/Projects/Umbraco-CMS/src/Umbraco.Web.UI.Client';
// Use port 5174 to avoid conflict with other dev servers
const DEV_SERVER_PORT = 5174;
/**
* Playwright Configuration for Workspace Feature Toggle Mocked Tests
*
* Tests run against the mocked Umbraco backoffice (MSW mode).
* No authentication is needed as MSW mode bypasses auth.
*
* The webServer config automatically starts the dev server with this extension loaded.
*/
export default defineConfig({
testDir: '.',
timeout: 60000,
expect: {
timeout: 15000,
},
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1,
reporter: [['html', { outputFolder: '../playwright-report' }], ['list']],
outputDir: '../test-results',
// Automatically start the mocked backoffice dev server with this extension
webServer: {
command: `VITE_EXTERNAL_EXTENSION=${EXTENSION_PATH} npm run dev:external -- --port ${DEV_SERVER_PORT}`,
cwd: UMBRACO_CLIENT_PATH,
port: DEV_SERVER_PORT,
reuseExistingServer: !process.env.CI,
timeout: 120000,
},
use: {
baseURL: `http://localhost:${DEV_SERVER_PORT}`,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
actionTimeout: 15000,
},
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
},
},
],
});
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"experimentalDecorators": true,
"useDefineForClassFields": false,
"skipLibCheck": true,
"noEmit": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": true
},
"include": ["*.ts", "tests/*.ts"]
}
import { esbuildPlugin } from '@web/dev-server-esbuild';
import { playwrightLauncher } from '@web/test-runner-playwright';
const headless = process.env.HEADLESS !== 'false';
const slowMo = headless ? 0 : 1000; // Slow down headed tests for visibility (1 second per action)
export default {
files: ['*.test.ts'],
nodeResolve: true,
browsers: [playwrightLauncher({ product: 'chromium', launchOptions: { headless, slowMo } })],
plugins: [
esbuildPlugin({ ts: true, tsconfig: './tsconfig.json' }),
],
testRunnerHtml: (testFramework) =>
`<html>
<head>
<script type="module" src="${testFramework}"></script>
</head>
<body></body>
</html>`,
};