Extensions: Adds @provideContext and @consumeContext decorators for a better developer experience (#20510)

* feat: adds first draft of a context consume decorator

* feat: uses an options pattern

* feat: changes approach to use `addInitializer` and `queueMicroTask` instead

* feat: adds extra warning if context is consumed on disconnected controllers

* feat: example implementation of consume decorator

* feat: adds support for 'subscribe'

* feat: initial work on provide decorator

* docs: adds license to consume decorator

* feat: adds support for umbraco controllers with `hostConnected`

* feat: uses asPromise to handle one-time subscription instead

* test: adds unit tests for consume decorator

* feat: adds support for controllers through hostConnected injection

* feat: adds support for controllers through hostConnected injection

* test: adds unit tests for provide decorator

* docs: adds more documentation around usage and adds a few warnings in console when it detects wrong usage

* feat: removes unused controllerMap

* docs: adds wording on standard vs legacy decorators

* docs: clarifies usage around internal state

* feat: adds proper return types for decorators

* docs: adds more types

* feat: makes element optional

* feat: makes element optional

* feat: uses @consume in the log viewer to showcase

* chore: cleans up debug info

* feat: renames to `consumeContext` and `provideContext` to stay inline with our own methods

* chore: removes unneeded typings

* chore: removes not needed check

* chore: removes not needed check

* test: adds test for rendered value

* feat: splits up code into several smaller functions

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* docs: augments code example for creating a context

* Update src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/views/search/components/log-viewer-search-input.element.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Jacob Overgaard
2025-10-16 16:38:26 +02:00
committed by GitHub
parent 271edb5214
commit 4a504e8c95
19 changed files with 959 additions and 156 deletions

View File

@@ -0,0 +1,133 @@
import { UmbContextToken } from '../token/context-token.js';
import type { UmbContextMinimal } from '../types.js';
import { UmbContextProvider } from '../provide/context-provider.js';
import { consumeContext } from './context-consume.decorator.js';
import { aTimeout, elementUpdated, expect, fixture } from '@open-wc/testing';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
import { html, state } from '@umbraco-cms/backoffice/external/lit';
class UmbTestContextConsumerClass implements UmbContextMinimal {
public prop: string = 'value from provider';
getHostElement() {
return undefined as any;
}
}
const testToken = new UmbContextToken<UmbTestContextConsumerClass>('my-test-context');
class MyTestElement extends UmbLitElement {
@consumeContext({
context: testToken,
})
@state()
contextValue?: UmbTestContextConsumerClass;
override render() {
return html`<div>${this.contextValue?.prop ?? 'no context'}</div>`;
}
}
customElements.define('my-consume-test-element', MyTestElement);
describe('@consume decorator', () => {
let provider: UmbContextProvider;
let element: MyTestElement;
beforeEach(async () => {
provider = new UmbContextProvider(document.body, testToken, new UmbTestContextConsumerClass());
provider.hostConnected();
element = await fixture<MyTestElement>(`<my-consume-test-element></my-consume-test-element>`);
});
afterEach(() => {
provider.destroy();
(provider as any) = undefined;
});
it('should receive a context value when provided on the host', () => {
expect(element.contextValue).to.equal(provider.providerInstance());
expect(element.contextValue?.prop).to.equal('value from provider');
});
it('should render the value from the context', async () => {
expect(element).shadowDom.to.equal('<div>value from provider</div>');
});
it('should work when the decorator is used in a controller', async () => {
class MyController extends UmbControllerBase {
@consumeContext({ context: testToken })
contextValue?: UmbTestContextConsumerClass;
}
const controller = new MyController(element);
await elementUpdated(element);
expect(element.contextValue).to.equal(provider.providerInstance());
expect(controller.contextValue).to.equal(provider.providerInstance());
});
it('should have called the callback first', async () => {
let callbackCalled = false;
class MyCallbackTestElement extends UmbLitElement {
@consumeContext({
context: testToken,
callback: () => {
callbackCalled = true;
},
})
contextValue?: UmbTestContextConsumerClass;
}
customElements.define('my-callback-consume-test-element', MyCallbackTestElement);
const callbackElement = await fixture<MyCallbackTestElement>(
`<my-callback-consume-test-element></my-callback-consume-test-element>`,
);
await elementUpdated(callbackElement);
expect(callbackCalled).to.be.true;
expect(callbackElement.contextValue).to.equal(provider.providerInstance());
});
it('should update the context value when the provider instance changes', async () => {
const newProviderInstance = new UmbTestContextConsumerClass();
newProviderInstance.prop = 'new value from provider';
const newProvider = new UmbContextProvider(element, testToken, newProviderInstance);
newProvider.hostConnected();
await elementUpdated(element);
expect(element.contextValue).to.equal(newProvider.providerInstance());
expect(element.contextValue?.prop).to.equal(newProviderInstance.prop);
});
it('should be able to consume without subscribing', async () => {
class MyNoSubscribeTestController extends UmbControllerBase {
@consumeContext({ context: testToken, subscribe: false })
contextValue?: UmbTestContextConsumerClass;
}
const controller = new MyNoSubscribeTestController(element);
await aTimeout(0); // Wait a tick for promise to resolve
expect(controller.contextValue).to.equal(provider.providerInstance());
const newProviderInstance = new UmbTestContextConsumerClass();
newProviderInstance.prop = 'new value from provider';
const newProvider = new UmbContextProvider(element, testToken, newProviderInstance);
newProvider.hostConnected();
await aTimeout(0); // Wait a tick for promise to resolve
// Should still be the old value
expect(controller.contextValue).to.not.equal(newProvider.providerInstance());
expect(controller.contextValue?.prop).to.equal('value from provider');
});
});

View File

@@ -0,0 +1,289 @@
/*
* Portions of this code are adapted from @lit/context
* Copyright 2017 Google LLC
* SPDX-License-Identifier: BSD-3-Clause
*
* Original source: https://github.com/lit/lit/tree/main/packages/context
*
* @license BSD-3-Clause
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { UmbContextToken } from '../token/index.js';
import type { UmbContextMinimal } from '../types.js';
import { UmbContextConsumerController } from './context-consumer.controller.js';
import type { UmbContextCallback } from './context-request.event.js';
export interface UmbConsumeOptions<
BaseType extends UmbContextMinimal = UmbContextMinimal,
ResultType extends BaseType = BaseType,
> {
/**
* The context to consume, either as a string alias or an UmbContextToken.
*/
context: string | UmbContextToken<BaseType, ResultType>;
/**
* An optional callback that is invoked when the context value is set or changes.
* Note, the class instance is probably not fully constructed when this is first invoked.
* If you need to ensure the class is fully constructed, consider using a setter on the property instead.
*/
callback?: UmbContextCallback<ResultType>;
/**
* If true, the context consumer will stay active and invoke the callback on context changes.
* If false, the context consumer will use asPromise() to get the value once and then clean up.
* @default true
*/
subscribe?: boolean;
}
/**
* A property decorator that adds an UmbContextConsumerController to the component
* which will try and retrieve a value for the property via the Umbraco Context API.
*
* This decorator supports both modern "standard" decorators (Stage 3 TC39 proposal) and
* legacy TypeScript experimental decorators for backward compatibility.
*
* @param {UmbConsumeOptions} options Configuration object containing context, callback, and subscribe options
*
* @example
* ```ts
* import {consumeContext} from '@umbraco-cms/backoffice/context-api';
* import {UMB_WORKSPACE_CONTEXT} from './workspace.context-token.js';
*
* class MyElement extends UmbLitElement {
* // Standard decorators (with 'accessor' keyword) - Modern approach
* @consumeContext({context: UMB_WORKSPACE_CONTEXT})
* accessor workspaceContext?: UmbWorkspaceContext;
*
* // Legacy decorators (without 'accessor') - Works with @state/@property
* @consumeContext({context: UMB_USER_CONTEXT, subscribe: false})
* @state()
* currentUser?: UmbUserContext;
* }
* ```
* @returns {ConsumeDecorator<ResultType>} A property decorator function
*/
export function consumeContext<
BaseType extends UmbContextMinimal = UmbContextMinimal,
ResultType extends BaseType = BaseType,
>(options: UmbConsumeOptions<BaseType, ResultType>): ConsumeDecorator<ResultType> {
const { context, callback, subscribe = true } = options;
return ((protoOrTarget: any, nameOrContext: PropertyKey | ClassAccessorDecoratorContext<any, ResultType>) => {
if (typeof nameOrContext === 'object') {
setupStandardDecorator(protoOrTarget, nameOrContext, context, callback, subscribe);
return;
}
setupLegacyDecorator(protoOrTarget, nameOrContext as string, context, callback, subscribe);
}) as ConsumeDecorator<ResultType>;
}
/**
* Sets up a standard decorator (Stage 3 TC39 proposal) for auto-accessors.
* This branch is used when decorating with the 'accessor' keyword.
* Example: @consumeContext({context: TOKEN}) accessor myProp?: Type;
*
* The decorator receives a ClassAccessorDecoratorContext object which provides
* addInitializer() to run code during class construction.
*
* This is the modern, standardized decorator API that will be the standard
* when Lit 4.x is released.
*
* Note: Standard decorators currently don't work with @state()/@property()
* decorators, which is why we still need the legacy branch.
*/
function setupStandardDecorator<BaseType extends UmbContextMinimal, ResultType extends BaseType>(
protoOrTarget: any,
decoratorContext: ClassAccessorDecoratorContext<any, ResultType>,
context: string | UmbContextToken<BaseType, ResultType>,
callback: UmbContextCallback<ResultType> | undefined,
subscribe: boolean,
): void {
if (!('addInitializer' in decoratorContext)) {
console.warn(
'@consumeContext decorator: Standard decorator context does not support addInitializer. ' +
'This should not happen with modern decorators.',
);
return;
}
decoratorContext.addInitializer(function () {
queueMicrotask(() => {
if (subscribe) {
// Continuous subscription - stays active and updates property on context changes
new UmbContextConsumerController(this, context, (value) => {
protoOrTarget.set.call(this, value);
callback?.(value);
});
} else {
// One-time consumption - uses asPromise() to get the value once and then cleans up
const controller = new UmbContextConsumerController(this, context, callback);
controller.asPromise().then((value) => {
protoOrTarget.set.call(this, value);
});
}
});
});
}
/**
* Sets up a legacy decorator (TypeScript experimental) for regular properties.
* This branch is used when decorating without the 'accessor' keyword.
* Example: @consumeContext({context: TOKEN}) @state() myProp?: Type;
*
* The decorator receives:
* - protoOrTarget: The class prototype
* - propertyKey: The property name (string)
*
* This is the older TypeScript experimental decorator API, still widely used
* in Umbraco because it works with @state() and @property() decorators.
* The 'accessor' keyword is not compatible with these decorators yet.
*
* We support three initialization strategies:
* 1. addInitializer (if available, e.g., on LitElement classes)
* 2. hostConnected wrapper (for UmbController classes)
* 3. Warning (if neither is available)
*/
function setupLegacyDecorator<BaseType extends UmbContextMinimal, ResultType extends BaseType>(
protoOrTarget: any,
propertyKey: string,
context: string | UmbContextToken<BaseType, ResultType>,
callback: UmbContextCallback<ResultType> | undefined,
subscribe: boolean,
): void {
const constructor = protoOrTarget.constructor as any;
// Strategy 1: Use addInitializer if available (LitElement classes)
if (constructor.addInitializer) {
constructor.addInitializer((element: any): void => {
queueMicrotask(() => {
if (subscribe) {
// Continuous subscription
new UmbContextConsumerController(element, context, (value) => {
element[propertyKey] = value;
callback?.(value);
});
} else {
// One-time consumption using asPromise()
const controller = new UmbContextConsumerController(element, context, callback);
controller.asPromise().then((value) => {
element[propertyKey] = value;
});
}
});
});
return;
}
// Strategy 2: Wrap hostConnected for UmbController classes without addInitializer
if ('hostConnected' in protoOrTarget && typeof protoOrTarget.hostConnected === 'function') {
const originalHostConnected = protoOrTarget.hostConnected;
protoOrTarget.hostConnected = function (this: any) {
// Set up consumer once, using a flag to prevent multiple setups
if (!this.__consumeControllers) {
this.__consumeControllers = new Map();
}
if (!this.__consumeControllers.has(propertyKey)) {
if (subscribe) {
// Continuous subscription
const controller = new UmbContextConsumerController(this, context, (value) => {
this[propertyKey] = value;
callback?.(value);
});
this.__consumeControllers.set(propertyKey, controller);
} else {
// One-time consumption using asPromise()
const controller = new UmbContextConsumerController(this, context, callback);
controller.asPromise().then((value) => {
this[propertyKey] = value;
});
// Don't store in map since it cleans itself up
}
}
// Call original hostConnected if it exists
originalHostConnected?.call(this);
};
return;
}
// Strategy 3: No supported initialization method available
console.warn(
`@consumeContext applied to ${constructor.name}.${propertyKey} but neither addInitializer nor hostConnected is available. ` +
`Make sure the class extends UmbLitElement, UmbControllerBase, or implements UmbController with hostConnected.`,
);
}
/**
* Generates a public interface type that removes private and protected fields.
* This allows accepting otherwise incompatible versions of the type (e.g. from
* multiple copies of the same package in `node_modules`).
*/
type Interface<T> = {
[K in keyof T]: T[K];
};
declare class ReactiveElement {
static addInitializer?: (initializer: (instance: any) => void) => void;
}
declare class ReactiveController {
hostConnected?: () => void;
}
/**
* A type representing the base class of which the decorator should work
* requiring either addInitializer (UmbLitElement) or hostConnected (UmbController).
*/
type ReactiveEntity = ReactiveElement | ReactiveController;
type ConsumeDecorator<ValueType> = {
// legacy
<K extends PropertyKey, Proto extends Interface<ReactiveEntity>>(
protoOrDescriptor: Proto,
name?: K,
): FieldMustMatchProvidedType<Proto, K, ValueType>;
// standard
<C extends Interface<ReactiveEntity>, V extends ValueType>(
value: ClassAccessorDecoratorTarget<C, V>,
context: ClassAccessorDecoratorContext<C, V>,
): void;
};
// Note TypeScript requires the return type of a decorator to be `void | any`
type DecoratorReturn = void | any;
type FieldMustMatchProvidedType<Obj, Key extends PropertyKey, ProvidedType> =
// First we check whether the object has the property as a required field
Obj extends Record<Key, infer ConsumingType>
? // Ok, it does, just check whether it's ok to assign the
// provided type to the consuming field
[ProvidedType] extends [ConsumingType]
? DecoratorReturn
: {
message: 'provided type not assignable to consuming field';
provided: ProvidedType;
consuming: ConsumingType;
}
: // Next we check whether the object has the property as an optional field
Obj extends Partial<Record<Key, infer ConsumingType>>
? // Check assignability again. Note that we have to include undefined
// here on the consuming type because it's optional.
[ProvidedType] extends [ConsumingType | undefined]
? DecoratorReturn
: {
message: 'provided type not assignable to consuming field';
provided: ProvidedType;
consuming: ConsumingType | undefined;
}
: // Ok, the field isn't present, so either someone's using consume
// manually, i.e. not as a decorator (maybe don't do that! but if you do,
// you're on your own for your type checking, sorry), or the field is
// private, in which case we can't check it.
DecoratorReturn;

View File

@@ -191,13 +191,21 @@ export class UmbContextConsumer<
cancelAnimationFrame(this.#raf);
}
const hostElement = this._retrieveHost();
// Add connection check to prevent requesting on disconnected elements
if (hostElement && !hostElement.isConnected) {
console.warn('UmbContextConsumer: Attempting to request context on disconnected element', hostElement);
return;
}
const event = new UmbContextRequestEventImplementation(
this.#contextAlias,
this.#apiAlias,
this._onResponse,
this.#stopAtContextMatch,
);
(this.#skipHost ? this._retrieveHost()?.parentNode : this._retrieveHost())?.dispatchEvent(event);
(this.#skipHost ? hostElement?.parentNode : hostElement)?.dispatchEvent(event);
if (this.#promiseResolver && this.#promiseOptions?.preventTimeout !== true) {
this.#raf = requestAnimationFrame(() => {

View File

@@ -1,3 +1,4 @@
export * from './context-consume.decorator.js';
export * from './context-consumer.controller.js';
export * from './context-consumer.js';
export * from './context-request.event.js';

View File

@@ -0,0 +1,111 @@
import { UmbContextToken } from '../token/context-token.js';
import type { UmbContextMinimal } from '../types.js';
import { provideContext } from './context-provide.decorator.js';
import { aTimeout, elementUpdated, expect, fixture } from '@open-wc/testing';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
class UmbTestContextConsumerClass implements UmbContextMinimal {
public prop: string;
constructor(initialValue = 'value from provider') {
this.prop = initialValue;
}
getHostElement() {
return document.body;
}
}
const testToken = new UmbContextToken<UmbTestContextConsumerClass>('my-test-context', 'testApi');
class MyTestRootElement extends UmbLitElement {
@provideContext({ context: testToken })
providerInstance = new UmbTestContextConsumerClass();
}
customElements.define('my-provide-test-element', MyTestRootElement);
class MyTestElement extends UmbLitElement {
contextValue?: UmbTestContextConsumerClass;
constructor() {
super();
this.consumeContext(testToken, (value) => {
this.contextValue = value;
});
}
}
customElements.define('my-consume-test-element', MyTestElement);
describe('@provide decorator', () => {
let rootElement: MyTestRootElement;
let element: MyTestElement;
beforeEach(async () => {
rootElement = await fixture<MyTestRootElement>(
`<my-provide-test-element><my-consume-test-element></my-consume-test-element></my-provide-test-element>`,
);
element = rootElement.querySelector('my-consume-test-element') as MyTestElement;
});
afterEach(() => {});
it('should receive a context value when provided on the host', () => {
expect(element.contextValue).to.equal(rootElement.providerInstance);
});
it('should work when the decorator is used in a controller', async () => {
class MyController extends UmbControllerBase {
@provideContext({ context: testToken })
providerInstance = new UmbTestContextConsumerClass('new value');
}
const controller = new MyController(element);
await elementUpdated(element);
expect(element.contextValue).to.equal(controller.providerInstance);
expect(controller.providerInstance.prop).to.equal('new value');
});
it('should not update the instance when the property changes', async () => {
// we do not support setting a new value on a provided property
// as it would require a lot more logic to handle updating the context consumers
// So for now we just warn the user that this is not supported
// This might be revisited in the future if there is a need for it
const originalProviderInstance = rootElement.providerInstance;
const newProviderInstance = new UmbTestContextConsumerClass('new value from provider');
rootElement.providerInstance = newProviderInstance;
await aTimeout(0);
expect(element.contextValue).to.equal(originalProviderInstance);
expect(element.contextValue?.prop).to.equal(originalProviderInstance.prop);
});
it('should update the context value when the provider instance is replaced', async () => {
const newProviderInstance = new UmbTestContextConsumerClass();
newProviderInstance.prop = 'new value from provider';
class MyUpdateTestElement extends UmbLitElement {
@provideContext({ context: testToken })
providerInstance = newProviderInstance;
}
customElements.define('my-update-provide-test-element', MyUpdateTestElement);
const newProvider = await fixture<MyUpdateTestElement>(
`<my-update-provide-test-element><my-consume-test-element></my-consume-test-element></my-update-provide-test-element>`,
);
const element = newProvider.querySelector('my-consume-test-element') as MyTestElement;
await elementUpdated(element);
expect(element.contextValue).to.equal(newProviderInstance);
expect(element.contextValue?.prop).to.equal(newProviderInstance.prop);
});
});

View File

@@ -0,0 +1,258 @@
/*
* Portions of this code are adapted from @lit/context
* Copyright 2017 Google LLC
* SPDX-License-Identifier: BSD-3-Clause
*
* Original source: https://github.com/lit/lit/tree/main/packages/context
*
* @license BSD-3-Clause
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { UmbContextToken } from '../token/index.js';
import type { UmbContextMinimal } from '../types.js';
import { UmbContextProviderController } from './context-provider.controller.js';
export interface UmbProvideOptions<BaseType extends UmbContextMinimal, ResultType extends BaseType> {
context: string | UmbContextToken<BaseType, ResultType>;
}
/**
* A property decorator that creates an UmbContextProviderController to provide
* a context value to child elements via the Umbraco Context API.
*
* This decorator supports both modern "standard" decorators (Stage 3 TC39 proposal) and
* legacy TypeScript experimental decorators for backward compatibility.
*
* The provider is created once during initialization with the property's initial value.
* To update the provided value dynamically, keep a state inside the provided context instance
* and update that state as needed. The context instance itself should remain the same.
* You can use any of the Umb{*}State classes.
*
* @param {UmbProvideOptions} options Configuration object containing the context token
*
* @example
* ```ts
* import {provideContext} from '@umbraco-cms/backoffice/context-api';
* import {UMB_WORKSPACE_CONTEXT} from './workspace.context-token.js';
*
* class MyWorkspaceElement extends UmbLitElement {
* // Standard decorators - requires 'accessor' keyword
* @provideContext({context: UMB_WORKSPACE_CONTEXT})
* accessor workspaceContext = new UmbWorkspaceContext(this);
*
* // Legacy decorators - works without 'accessor'
* @provideContext({context: UMB_WORKSPACE_CONTEXT})
* workspaceContext = new UmbWorkspaceContext(this);
* }
* ```
*
* @example
* ```ts
* // For dynamic updates, store the state inside the context instance
* class MyContext extends UmbControllerBase {
* someProperty = new UmbStringState('initial value');
* }
*
* class MyElement extends UmbLitElement {
* @provideContext({context: MY_CONTEXT})
* private _myContext = new MyContext(this);
*
* updateValue(newValue: string) {
* this._myContext.someProperty.setValue(newValue);
* }
* }
* ```
*
* @returns {ProvideDecorator<InstanceType>} A property decorator function
*/
export function provideContext<
BaseType extends UmbContextMinimal = UmbContextMinimal,
ResultType extends BaseType = BaseType,
InstanceType extends ResultType = ResultType,
>(options: UmbProvideOptions<BaseType, ResultType>): ProvideDecorator<InstanceType> {
const { context } = options;
return ((
protoOrTarget: any,
nameOrContext: PropertyKey | ClassAccessorDecoratorContext<any, InstanceType>,
): void | any => {
if (typeof nameOrContext === 'object') {
return setupStandardDecorator(protoOrTarget, context);
}
setupLegacyDecorator(protoOrTarget, nameOrContext as string, context);
}) as ProvideDecorator<ResultType>;
}
/**
* Sets up a standard decorator (Stage 3 TC39 proposal) for auto-accessors.
* This branch is used when decorating with the 'accessor' keyword.
* Example: @provideContext({context: TOKEN}) accessor myProp = new MyContext();
*
* The decorator receives a ClassAccessorDecoratorContext object and returns
* an accessor descriptor that intercepts the property initialization.
*
* This is the modern, standardized decorator API that will be the standard
* when Lit 4.x is released.
*
* Note: Standard decorators currently don't work with @state()/@property()
* decorators, which is why we still need the legacy branch.
*/
function setupStandardDecorator<
BaseType extends UmbContextMinimal,
ResultType extends BaseType,
InstanceType extends ResultType,
>(protoOrTarget: any, context: string | UmbContextToken<BaseType, ResultType>) {
return {
get(this: any) {
return protoOrTarget.get.call(this);
},
set(this: any, value: InstanceType) {
return protoOrTarget.set.call(this, value);
},
init(this: any, value: InstanceType) {
// Defer controller creation to avoid timing issues with private fields
queueMicrotask(() => {
new UmbContextProviderController<BaseType, ResultType, InstanceType>(this, context, value);
});
return value;
},
};
}
/**
* Sets up a legacy decorator (TypeScript experimental) for regular properties.
* This branch is used when decorating without the 'accessor' keyword.
* Example: @provideContext({context: TOKEN}) myProp = new MyContext();
*
* The decorator receives:
* - protoOrTarget: The class prototype
* - propertyKey: The property name (string)
*
* This is the older TypeScript experimental decorator API, still widely used
* in Umbraco because it works with @state() and @property() decorators.
* The 'accessor' keyword is not compatible with these decorators yet.
*
* We support three initialization strategies:
* 1. addInitializer (if available, e.g., on LitElement classes)
* 2. hostConnected wrapper (for UmbController classes)
* 3. Warning (if neither is available)
*/
function setupLegacyDecorator<
BaseType extends UmbContextMinimal,
ResultType extends BaseType,
InstanceType extends ResultType,
>(protoOrTarget: any, propertyKey: string, context: string | UmbContextToken<BaseType, ResultType>): void {
const constructor = protoOrTarget.constructor as any;
// Strategy 1: Use addInitializer if available (LitElement classes)
if (constructor.addInitializer) {
constructor.addInitializer((element: any): void => {
// Defer controller creation to avoid timing issues with private fields
queueMicrotask(() => {
const initialValue = element[propertyKey];
new UmbContextProviderController<BaseType, ResultType, InstanceType>(element, context, initialValue);
});
});
return;
}
// Strategy 2: Wrap hostConnected for UmbController classes without addInitializer
if ('hostConnected' in protoOrTarget && typeof protoOrTarget.hostConnected === 'function') {
const originalHostConnected = protoOrTarget.hostConnected;
protoOrTarget.hostConnected = function (this: any) {
// Set up provider once, using a flag to prevent multiple setups
if (!this.__provideControllers) {
this.__provideControllers = new Map();
}
if (!this.__provideControllers.has(propertyKey)) {
const initialValue = this[propertyKey];
new UmbContextProviderController<BaseType, ResultType, InstanceType>(this, context, initialValue);
// Mark as set up to prevent duplicate providers
this.__provideControllers.set(propertyKey, true);
}
// Call original hostConnected if it exists
originalHostConnected?.call(this);
};
return;
}
// Strategy 3: No supported initialization method available
console.warn(
`@provideContext applied to ${constructor.name}.${propertyKey} but neither addInitializer nor hostConnected is available. ` +
`Make sure the class extends UmbLitElement, UmbControllerBase, or implements UmbController with hostConnected.`,
);
}
/**
* Generates a public interface type that removes private and protected fields.
* This allows accepting otherwise compatible versions of the type (e.g. from
* multiple copies of the same package in `node_modules`).
*/
type Interface<T> = {
[K in keyof T]: T[K];
};
declare class ReactiveElement {
static addInitializer?: (initializer: (instance: any) => void) => void;
}
declare class ReactiveController {
hostConnected?: () => void;
}
/**
* A type representing the base class of which the decorator should work
* requiring either addInitializer (UmbLitElement) or hostConnected (UmbController).
*/
type ReactiveEntity = ReactiveElement | ReactiveController;
type ProvideDecorator<ContextType> = {
// legacy
<K extends PropertyKey, Proto extends Interface<ReactiveEntity>>(
protoOrDescriptor: Proto,
name?: K,
): FieldMustMatchContextType<Proto, K, ContextType>;
// standard
<C extends Interface<ReactiveEntity>, V extends ContextType>(
value: ClassAccessorDecoratorTarget<C, V>,
context: ClassAccessorDecoratorContext<C, V>,
): void;
};
// Note TypeScript requires the return type of a decorator to be `void | any`
type DecoratorReturn = void | any;
type FieldMustMatchContextType<Obj, Key extends PropertyKey, ContextType> =
// First we check whether the object has the property as a required field
Obj extends Record<Key, infer ProvidingType>
? // Ok, it does, just check whether it's ok to assign the
// provided type to the consuming field
[ProvidingType] extends [ContextType]
? DecoratorReturn
: {
message: 'providing field not assignable to context';
context: ContextType;
provided: ProvidingType;
}
: // Next we check whether the object has the property as an optional field
Obj extends Partial<Record<Key, infer Providing>>
? // Check assignability again. Note that we have to include undefined
// here on the providing type because it's optional.
[Providing | undefined] extends [ContextType]
? DecoratorReturn
: {
message: 'providing field not assignable to context';
context: ContextType;
consuming: Providing | undefined;
}
: // Ok, the field isn't present, so either someone's using provide
// manually, i.e. not as a decorator (maybe don't do that! but if you do,
// you're on your own for your type checking, sorry), or the field is
// private, in which case we can't check it.
DecoratorReturn;

View File

@@ -1,3 +1,4 @@
export * from './context-provide.decorator.js';
export * from './context-boundary.js';
export * from './context-boundary.controller.js';
export * from './context-provide.event.js';

View File

@@ -44,7 +44,7 @@ class UmbLogViewerMessagesData extends UmbMockDBBase<LogMessageResponseModel> {
}
getLevelCount() {
const levels = this.data.map((log) => log.level ?? 'unknown');
const levels = this.data.map((log) => log.level?.toLowerCase() ?? 'unknown');
const counts = {};
levels.forEach((level: string) => {
//eslint-disable-next-line @typescript-eslint/ban-ts-comment

View File

@@ -1,10 +1,11 @@
import type { UmbLogViewerDateRange, UmbLogViewerWorkspaceContext } from '../workspace/logviewer-workspace.context.js';
import type { UmbLogViewerDateRange } from '../workspace/logviewer-workspace.context.js';
import { UMB_APP_LOG_VIEWER_CONTEXT } from '../workspace/logviewer-workspace.context-token.js';
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
import { css, html, customElement, property, state } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { query as getQuery, path, toQueryString } from '@umbraco-cms/backoffice/router';
import type { UUIInputEvent } from '@umbraco-cms/backoffice/external/uui';
import { consumeContext } from '@umbraco-cms/backoffice/context-api';
@customElement('umb-log-viewer-date-range-selector')
export class UmbLogViewerDateRangeSelectorElement extends UmbLitElement {
@@ -17,20 +18,20 @@ export class UmbLogViewerDateRangeSelectorElement extends UmbLitElement {
@property({ type: Boolean, reflect: true })
horizontal = false;
#logViewerContext?: UmbLogViewerWorkspaceContext;
#logViewerContext?: typeof UMB_APP_LOG_VIEWER_CONTEXT.TYPE;
constructor() {
super();
this.consumeContext(UMB_APP_LOG_VIEWER_CONTEXT, (instance) => {
this.#logViewerContext = instance;
this.#observeStuff();
});
@consumeContext({ context: UMB_APP_LOG_VIEWER_CONTEXT })
private set _logViewerContext(value) {
this.#logViewerContext = value;
this.#observeStuff();
}
private get _logViewerContext() {
return this.#logViewerContext;
}
#observeStuff() {
if (!this.#logViewerContext) return;
this.observe(
this.#logViewerContext.dateRange,
this._logViewerContext?.dateRange,
(dateRange: UmbLogViewerDateRange) => {
this._startDate = dateRange.startDate;
this._endDate = dateRange.endDate;
@@ -50,7 +51,7 @@ export class UmbLogViewerDateRangeSelectorElement extends UmbLitElement {
}
#updateFiltered() {
this.#logViewerContext?.setDateRange({ startDate: this._startDate, endDate: this._endDate });
this._logViewerContext?.setDateRange({ startDate: this._startDate, endDate: this._endDate });
const query = getQuery();
const qs = toQueryString({
@@ -71,7 +72,7 @@ export class UmbLogViewerDateRangeSelectorElement extends UmbLitElement {
id="start-date"
type="date"
label="From"
.max=${this.#logViewerContext?.today ?? ''}
.max=${this._logViewerContext?.today ?? ''}
.value=${this._startDate}></umb-input-date>
</div>
<div class="input-container">
@@ -82,7 +83,7 @@ export class UmbLogViewerDateRangeSelectorElement extends UmbLitElement {
type="date"
label="To"
.min=${this._startDate}
.max=${this.#logViewerContext?.today ?? ''}
.max=${this._logViewerContext?.today ?? ''}
.value=${this._endDate}></umb-input-date>
</div>
`;

View File

@@ -1,23 +1,26 @@
import type { UmbLogViewerWorkspaceContext } from '../../../logviewer-workspace.context.js';
import { UMB_APP_LOG_VIEWER_CONTEXT } from '../../../logviewer-workspace.context-token.js';
import { html, nothing, customElement, property, state } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import type { LoggerResponseModel } from '@umbraco-cms/backoffice/external/backend-api';
import { consumeContext } from '@umbraco-cms/backoffice/context-api';
@customElement('umb-log-viewer-log-level-overview')
export class UmbLogViewerLogLevelOverviewElement extends UmbLitElement {
#logViewerContext?: UmbLogViewerWorkspaceContext;
constructor() {
super();
this.consumeContext(UMB_APP_LOG_VIEWER_CONTEXT, (instance) => {
this.#logViewerContext = instance;
this.#logViewerContext?.getSavedSearches();
this.#observeLogLevels();
});
#logViewerContext?: typeof UMB_APP_LOG_VIEWER_CONTEXT.TYPE;
@consumeContext({ context: UMB_APP_LOG_VIEWER_CONTEXT })
private set _logViewerContext(value) {
this.#logViewerContext = value;
this.#logViewerContext?.getSavedSearches();
this.#observeLogLevels();
}
private get _logViewerContext() {
return this.#logViewerContext;
}
@state()
private _loggers: LoggerResponseModel[] = [];
/**
* The name of the logger to get the level for. Defaults to 'Global'.
* @memberof UmbLogViewerLogLevelOverviewElement
@@ -26,8 +29,7 @@ export class UmbLogViewerLogLevelOverviewElement extends UmbLitElement {
loggerName = 'Global';
#observeLogLevels() {
if (!this.#logViewerContext) return;
this.observe(this.#logViewerContext.loggers, (loggers) => {
this.observe(this._logViewerContext?.loggers, (loggers) => {
this._loggers = loggers ?? [];
});
}

View File

@@ -1,19 +1,21 @@
import type { UmbLogViewerWorkspaceContext } from '../../../logviewer-workspace.context.js';
import { UMB_APP_LOG_VIEWER_CONTEXT } from '../../../logviewer-workspace.context-token.js';
import { css, html, customElement, state } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import type { LogLevelCountsReponseModel } from '@umbraco-cms/backoffice/external/backend-api';
import { consumeContext } from '@umbraco-cms/backoffice/context-api';
@customElement('umb-log-viewer-log-types-chart')
export class UmbLogViewerLogTypesChartElement extends UmbLitElement {
#logViewerContext?: UmbLogViewerWorkspaceContext;
constructor() {
super();
this.consumeContext(UMB_APP_LOG_VIEWER_CONTEXT, (instance) => {
this.#logViewerContext = instance;
this.#logViewerContext?.getLogCount();
this.#observeStuff();
});
#logViewerContext?: typeof UMB_APP_LOG_VIEWER_CONTEXT.TYPE;
@consumeContext({ context: UMB_APP_LOG_VIEWER_CONTEXT })
private set _logViewerContext(value) {
this.#logViewerContext = value;
this.#logViewerContext?.getLogCount();
this.#observeStuff();
}
private get _logViewerContext() {
return this.#logViewerContext;
}
@state()
@@ -47,8 +49,7 @@ export class UmbLogViewerLogTypesChartElement extends UmbLitElement {
}
#observeStuff() {
if (!this.#logViewerContext) return;
this.observe(this.#logViewerContext.logCount, (logLevel) => {
this.observe(this._logViewerContext?.logCount, (logLevel) => {
this._logLevelCountResponse = logLevel ?? null;
this.setLogLevelCount();
});

View File

@@ -1,10 +1,10 @@
import type { UmbLogViewerWorkspaceContext } from '../../../logviewer-workspace.context.js';
import { UMB_APP_LOG_VIEWER_CONTEXT } from '../../../logviewer-workspace.context-token.js';
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
import { css, html, customElement, state, nothing } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import type { LogTemplateResponseModel } from '@umbraco-cms/backoffice/external/backend-api';
import type { UUIPaginationEvent } from '@umbraco-cms/backoffice/external/uui';
import { consumeContext } from '@umbraco-cms/backoffice/context-api';
@customElement('umb-log-viewer-message-templates-overview')
export class UmbLogViewerMessageTemplatesOverviewElement extends UmbLitElement {
@@ -17,19 +17,20 @@ export class UmbLogViewerMessageTemplatesOverviewElement extends UmbLitElement {
@state()
private _messageTemplates: Array<LogTemplateResponseModel> = [];
#logViewerContext?: UmbLogViewerWorkspaceContext;
constructor() {
super();
this.consumeContext(UMB_APP_LOG_VIEWER_CONTEXT, (instance) => {
this.#logViewerContext = instance;
this.#logViewerContext?.getMessageTemplates(0, this.#itemsPerPage);
this.#observeStuff();
});
#logViewerContext?: typeof UMB_APP_LOG_VIEWER_CONTEXT.TYPE;
@consumeContext({ context: UMB_APP_LOG_VIEWER_CONTEXT })
private set _logViewerContext(value) {
this.#logViewerContext = value;
this.#getMessageTemplates();
this.#observeStuff();
}
private get _logViewerContext() {
return this.#logViewerContext;
}
#observeStuff() {
if (!this.#logViewerContext) return;
this.observe(this.#logViewerContext.messageTemplates, (templates) => {
this.observe(this._logViewerContext?.messageTemplates, (templates) => {
this._messageTemplates = templates?.items ?? [];
this._total = templates?.total ?? 0;
});
@@ -37,7 +38,7 @@ export class UmbLogViewerMessageTemplatesOverviewElement extends UmbLitElement {
#getMessageTemplates() {
const skip = this.#currentPage * this.#itemsPerPage - this.#itemsPerPage;
this.#logViewerContext?.getMessageTemplates(skip, this.#itemsPerPage);
this._logViewerContext?.getMessageTemplates(skip, this.#itemsPerPage);
}
#onChangePage(event: UUIPaginationEvent) {

View File

@@ -1,10 +1,10 @@
import type { UmbLogViewerWorkspaceContext } from '../../../logviewer-workspace.context.js';
import { UMB_APP_LOG_VIEWER_CONTEXT } from '../../../logviewer-workspace.context-token.js';
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
import { css, html, customElement, state, nothing } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import type { SavedLogSearchResponseModel } from '@umbraco-cms/backoffice/external/backend-api';
import type { UUIPaginationEvent } from '@umbraco-cms/backoffice/external/uui';
import { consumeContext } from '@umbraco-cms/backoffice/context-api';
@customElement('umb-log-viewer-saved-searches-overview')
export class UmbLogViewerSavedSearchesOverviewElement extends UmbLitElement {
@@ -17,20 +17,20 @@ export class UmbLogViewerSavedSearchesOverviewElement extends UmbLitElement {
@state()
private _total = 0;
#logViewerContext?: UmbLogViewerWorkspaceContext;
#logViewerContext?: typeof UMB_APP_LOG_VIEWER_CONTEXT.TYPE;
constructor() {
super();
this.consumeContext(UMB_APP_LOG_VIEWER_CONTEXT, (instance) => {
this.#logViewerContext = instance;
this.#logViewerContext?.getSavedSearches({ skip: 0, take: this.#itemsPerPage });
this.#observeStuff();
});
@consumeContext({ context: UMB_APP_LOG_VIEWER_CONTEXT })
private set _logViewerContext(value) {
this.#logViewerContext = value;
this.#getSavedSearches();
this.#observeStuff();
}
private get _logViewerContext() {
return this.#logViewerContext;
}
#observeStuff() {
if (!this.#logViewerContext) return;
this.observe(this.#logViewerContext.savedSearches, (savedSearches) => {
this.observe(this._logViewerContext?.savedSearches, (savedSearches) => {
this._savedSearches = savedSearches?.items ?? [];
this._total = savedSearches?.total ?? 0;
});
@@ -38,7 +38,7 @@ export class UmbLogViewerSavedSearchesOverviewElement extends UmbLitElement {
#getSavedSearches() {
const skip = this.#currentPage * this.#itemsPerPage - this.#itemsPerPage;
this.#logViewerContext?.getSavedSearches({ skip, take: this.#itemsPerPage });
this._logViewerContext?.getSavedSearches({ skip, take: this.#itemsPerPage });
}
#onChangePage(event: UUIPaginationEvent) {

View File

@@ -1,7 +1,7 @@
import type { UmbLogViewerWorkspaceContext } from '../../logviewer-workspace.context.js';
import { UMB_APP_LOG_VIEWER_CONTEXT } from '../../logviewer-workspace.context-token.js';
import { css, html, customElement, state } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { consumeContext } from '@umbraco-cms/backoffice/context-api';
//TODO: add a disabled attribute to the show more button when the total number of items is correctly returned from the endpoint
@customElement('umb-log-viewer-overview-view')
@@ -12,28 +12,29 @@ export class UmbLogViewerOverviewViewElement extends UmbLitElement {
@state()
private _canShowLogs = false;
#logViewerContext?: UmbLogViewerWorkspaceContext;
constructor() {
super();
this.consumeContext(UMB_APP_LOG_VIEWER_CONTEXT, (instance) => {
this.#logViewerContext = instance;
this.#observeErrorCount();
this.#observeCanShowLogs();
this.#logViewerContext?.getLogLevels(0, 100);
});
#logViewerContext?: typeof UMB_APP_LOG_VIEWER_CONTEXT.TYPE;
@consumeContext({
context: UMB_APP_LOG_VIEWER_CONTEXT,
})
private set _logViewerContext(value) {
this.#logViewerContext = value;
this.#observeErrorCount();
this.#observeCanShowLogs();
value?.getLogLevels(0, 100);
}
private get _logViewerContext() {
return this.#logViewerContext;
}
#observeErrorCount() {
if (!this.#logViewerContext) return;
this.observe(this.#logViewerContext.logCount, (logLevelCount) => {
this.observe(this._logViewerContext?.logCount, (logLevelCount) => {
this._errorCount = logLevelCount?.error;
});
}
#observeCanShowLogs() {
if (!this.#logViewerContext) return;
this.observe(this.#logViewerContext.canShowLogs, (canShowLogs) => {
this.observe(this._logViewerContext?.canShowLogs, (canShowLogs) => {
this._canShowLogs = canShowLogs ?? false;
});
}

View File

@@ -1,4 +1,3 @@
import type { UmbLogViewerWorkspaceContext } from '../../../logviewer-workspace.context.js';
import { UMB_APP_LOG_VIEWER_CONTEXT } from '../../../logviewer-workspace.context-token.js';
import type { UUICheckboxElement } from '@umbraco-cms/backoffice/external/uui';
import { css, html, customElement, queryAll, state } from '@umbraco-cms/backoffice/external/lit';
@@ -6,6 +5,7 @@ import { debounce } from '@umbraco-cms/backoffice/utils';
import { LogLevelModel } from '@umbraco-cms/backoffice/external/backend-api';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { path, query, toQueryString } from '@umbraco-cms/backoffice/router';
import { consumeContext } from '@umbraco-cms/backoffice/context-api';
@customElement('umb-log-viewer-log-level-filter-menu')
export class UmbLogViewerLogLevelFilterMenuElement extends UmbLitElement {
@@ -15,27 +15,24 @@ export class UmbLogViewerLogLevelFilterMenuElement extends UmbLitElement {
@state()
private _logLevelFilter: LogLevelModel[] = [];
#logViewerContext?: UmbLogViewerWorkspaceContext;
#logViewerContext?: typeof UMB_APP_LOG_VIEWER_CONTEXT.TYPE;
constructor() {
super();
this.consumeContext(UMB_APP_LOG_VIEWER_CONTEXT, (instance) => {
this.#logViewerContext = instance;
this.#observeLogLevelFilter();
});
@consumeContext({ context: UMB_APP_LOG_VIEWER_CONTEXT })
private set _logViewerContext(value) {
this.#logViewerContext = value;
this.#observeLogLevelFilter();
}
private get _logViewerContext() {
return this.#logViewerContext;
}
#observeLogLevelFilter() {
if (!this.#logViewerContext) return;
this.observe(this.#logViewerContext.logLevelsFilter, (levelsFilter) => {
this.observe(this._logViewerContext?.logLevelsFilter, (levelsFilter) => {
this._logLevelFilter = levelsFilter ?? [];
});
}
#setLogLevel() {
if (!this.#logViewerContext) return;
const logLevels = Array.from(this._logLevelSelectorCheckboxes)
.filter((checkbox) => checkbox.checked)
.map((checkbox) => checkbox.value as LogLevelModel);

View File

@@ -1,10 +1,10 @@
import type { UmbLogViewerWorkspaceContext } from '../../../logviewer-workspace.context.js';
import { UMB_APP_LOG_VIEWER_CONTEXT } from '../../../logviewer-workspace.context-token.js';
import type { UUIScrollContainerElement, UUIPaginationElement } from '@umbraco-cms/backoffice/external/uui';
import { css, html, customElement, query, state } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import type { LogMessageResponseModel } from '@umbraco-cms/backoffice/external/backend-api';
import { DirectionModel } from '@umbraco-cms/backoffice/external/backend-api';
import { consumeContext } from '@umbraco-cms/backoffice/context-api';
@customElement('umb-log-viewer-messages-list')
export class UmbLogViewerMessagesListElement extends UmbLitElement {
@@ -23,46 +23,45 @@ export class UmbLogViewerMessagesListElement extends UmbLitElement {
@state()
private _isLoading = true;
#logViewerContext?: UmbLogViewerWorkspaceContext;
#logViewerContext?: typeof UMB_APP_LOG_VIEWER_CONTEXT.TYPE;
constructor() {
super();
this.consumeContext(UMB_APP_LOG_VIEWER_CONTEXT, (instance) => {
this.#logViewerContext = instance;
this.#observeLogs();
});
@consumeContext({ context: UMB_APP_LOG_VIEWER_CONTEXT })
private set _logViewerContext(value) {
this.#logViewerContext = value;
this.#observeLogs();
}
private get _logViewerContext() {
return this.#logViewerContext;
}
#observeLogs() {
if (!this.#logViewerContext) return;
this.observe(this.#logViewerContext.logs, (logs) => {
this.observe(this._logViewerContext?.logs, (logs) => {
this._logs = logs ?? [];
});
this.observe(this.#logViewerContext.isLoadingLogs, (isLoading) => {
this._isLoading = isLoading === null ? this._isLoading : isLoading;
this.observe(this._logViewerContext?.isLoadingLogs, (isLoading) => {
this._isLoading = isLoading ?? this._isLoading;
});
this.observe(this.#logViewerContext.logsTotal, (total) => {
this.observe(this._logViewerContext?.logsTotal, (total) => {
this._logsTotal = total ?? 0;
});
this.observe(this.#logViewerContext.sortingDirection, (direction) => {
this._sortingDirection = direction;
this.observe(this._logViewerContext?.sortingDirection, (direction) => {
this._sortingDirection = direction ?? this._sortingDirection;
});
}
#sortLogs() {
this.#logViewerContext?.toggleSortOrder();
this.#logViewerContext?.setCurrentPage(1);
this.#logViewerContext?.getLogs();
this._logViewerContext?.toggleSortOrder();
this._logViewerContext?.setCurrentPage(1);
this._logViewerContext?.getLogs();
}
#onPageChange(event: Event): void {
const current = (event.target as UUIPaginationElement).current;
this.#logViewerContext?.setCurrentPage(current);
this.#logViewerContext?.getLogs();
this._logViewerContext?.setCurrentPage(current);
this._logViewerContext?.getLogs();
this._logsScrollContainer.scrollTop = 0;
}

View File

@@ -1,12 +1,9 @@
import type {
UmbPoolingConfig,
UmbPoolingInterval,
UmbLogViewerWorkspaceContext,
} from '../../../logviewer-workspace.context.js';
import type { UmbPoolingConfig, UmbPoolingInterval } from '../../../logviewer-workspace.context.js';
import { UMB_APP_LOG_VIEWER_CONTEXT } from '../../../logviewer-workspace.context-token.js';
import { css, html, customElement, query, state } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import type { UmbDropdownElement } from '@umbraco-cms/backoffice/components';
import { consumeContext } from '@umbraco-cms/backoffice/context-api';
@customElement('umb-log-viewer-polling-button')
export class UmbLogViewerPollingButtonElement extends UmbLitElement {
@@ -18,30 +15,29 @@ export class UmbLogViewerPollingButtonElement extends UmbLitElement {
#pollingIntervals: UmbPoolingInterval[] = [2000, 5000, 10000, 20000, 30000];
#logViewerContext?: UmbLogViewerWorkspaceContext;
#logViewerContext?: typeof UMB_APP_LOG_VIEWER_CONTEXT.TYPE;
constructor() {
super();
this.consumeContext(UMB_APP_LOG_VIEWER_CONTEXT, (instance) => {
this.#logViewerContext = instance;
this.#observePoolingConfig();
});
@consumeContext({ context: UMB_APP_LOG_VIEWER_CONTEXT })
private set _logViewerContext(value) {
this.#logViewerContext = value;
this.#observePoolingConfig();
}
private get _logViewerContext() {
return this.#logViewerContext;
}
#observePoolingConfig() {
if (!this.#logViewerContext) return;
this.observe(this.#logViewerContext.polling, (poolingConfig) => {
this._poolingConfig = { ...poolingConfig };
this.observe(this._logViewerContext?.polling, (poolingConfig) => {
this._poolingConfig = poolingConfig ? { ...poolingConfig } : { enabled: false, interval: 0 };
});
}
#togglePolling() {
this.#logViewerContext?.togglePolling();
this._logViewerContext?.togglePolling();
}
#setPolingInterval = (interval: UmbPoolingInterval) => {
this.#logViewerContext?.setPollingInterval(interval);
this._logViewerContext?.setPollingInterval(interval);
this.#closePoolingPopover();
};

View File

@@ -1,4 +1,3 @@
import type { UmbLogViewerWorkspaceContext } from '../../../logviewer-workspace.context.js';
import { UMB_APP_LOG_VIEWER_CONTEXT } from '../../../logviewer-workspace.context-token.js';
import { UMB_LOG_VIEWER_SAVE_SEARCH_MODAL } from './log-viewer-search-input-modal.modal-token.js';
import { css, html, customElement, query, state } from '@umbraco-cms/backoffice/external/lit';
@@ -12,6 +11,7 @@ import type { UmbDropdownElement } from '@umbraco-cms/backoffice/components';
import type { UUIInputElement } from '@umbraco-cms/backoffice/external/uui';
import './log-viewer-search-input-modal.element.js';
import { consumeContext } from '@umbraco-cms/backoffice/context-api';
@customElement('umb-log-viewer-search-input')
export class UmbLogViewerSearchInputElement extends UmbLitElement {
@@ -33,15 +33,20 @@ export class UmbLogViewerSearchInputElement extends UmbLitElement {
// TODO: Revisit this code, to not use RxJS directly:
#inputQuery$ = new Subject<string>();
#logViewerContext?: UmbLogViewerWorkspaceContext;
#logViewerContext?: typeof UMB_APP_LOG_VIEWER_CONTEXT.TYPE;
@consumeContext({ context: UMB_APP_LOG_VIEWER_CONTEXT })
private set _logViewerContext(value) {
this.#logViewerContext = value;
this.#observeStuff();
this.#logViewerContext?.getSavedSearches();
}
private get _logViewerContext() {
return this.#logViewerContext;
}
constructor() {
super();
this.consumeContext(UMB_APP_LOG_VIEWER_CONTEXT, (instance) => {
this.#logViewerContext = instance;
this.#observeStuff();
this.#logViewerContext?.getSavedSearches();
});
this.#inputQuery$
.pipe(
@@ -49,7 +54,7 @@ export class UmbLogViewerSearchInputElement extends UmbLitElement {
debounceTime(250),
)
.subscribe((query) => {
this.#logViewerContext?.setFilterExpression(query);
this._logViewerContext?.setFilterExpression(query);
this.#persist(query);
this._isQuerySaved = this._savedSearches.some((search) => search.query === query);
this._showLoader = false;
@@ -57,15 +62,14 @@ export class UmbLogViewerSearchInputElement extends UmbLitElement {
}
#observeStuff() {
if (!this.#logViewerContext) return;
this.observe(this.#logViewerContext.savedSearches, (savedSearches) => {
this.observe(this._logViewerContext?.savedSearches, (savedSearches) => {
this._savedSearches = savedSearches?.items ?? [];
this._isQuerySaved = this._savedSearches.some((search) => search.query === this._inputQuery);
});
this.observe(this.#logViewerContext.filterExpression, (query) => {
this._inputQuery = query;
this._isQuerySaved = this._savedSearches.some((search) => search.query === query);
this.observe(this._logViewerContext?.filterExpression, (query) => {
this._inputQuery = query ?? '';
this._isQuerySaved = this._savedSearches.some((search) => search.query === this._inputQuery);
});
}
@@ -92,11 +96,11 @@ export class UmbLogViewerSearchInputElement extends UmbLitElement {
#clearQuery() {
this.#inputQuery$.next('');
this.#logViewerContext?.setFilterExpression('');
this._logViewerContext?.setFilterExpression('');
}
#saveSearch(savedSearch: SavedLogSearchResponseModel) {
this.#logViewerContext?.saveSearch(savedSearch);
this._logViewerContext?.saveSearch(savedSearch);
}
async #removeSearch(name: string) {
@@ -107,7 +111,7 @@ export class UmbLogViewerSearchInputElement extends UmbLitElement {
confirmLabel: 'Delete',
});
this.#logViewerContext?.removeSearch({ name });
this._logViewerContext?.removeSearch({ name });
//this.dispatchEvent(new UmbDeleteEvent());
}

View File

@@ -1,32 +1,32 @@
import type { UmbLogViewerWorkspaceContext } from '../../logviewer-workspace.context.js';
import { UMB_APP_LOG_VIEWER_CONTEXT } from '../../logviewer-workspace.context-token.js';
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
import { css, html, customElement, state } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import type { UmbObserverController } from '@umbraco-cms/backoffice/observable-api';
import { consumeContext } from '@umbraco-cms/backoffice/context-api';
@customElement('umb-log-viewer-search-view')
export class UmbLogViewerSearchViewElement extends UmbLitElement {
@state()
private _canShowLogs = true;
#logViewerContext?: UmbLogViewerWorkspaceContext;
#logViewerContext?: typeof UMB_APP_LOG_VIEWER_CONTEXT.TYPE;
@consumeContext({ context: UMB_APP_LOG_VIEWER_CONTEXT })
private set _logViewerContext(value) {
this.#logViewerContext = value;
this.#observeCanShowLogs();
}
private get _logViewerContext() {
return this.#logViewerContext;
}
#canShowLogsObserver?: UmbObserverController<boolean | null>;
constructor() {
super();
this.consumeContext(UMB_APP_LOG_VIEWER_CONTEXT, (instance) => {
this.#logViewerContext = instance;
this.#observeCanShowLogs();
});
}
#observeCanShowLogs() {
if (this.#canShowLogsObserver) this.#canShowLogsObserver.destroy();
if (!this.#logViewerContext) return;
this.#canShowLogsObserver = this.observe(this.#logViewerContext.canShowLogs, (canShowLogs) => {
this.#canShowLogsObserver = this.observe(this._logViewerContext?.canShowLogs, (canShowLogs) => {
this._canShowLogs = canShowLogs ?? this._canShowLogs;
});
}