diff --git a/src/Umbraco.Web.UI.Client/src/libs/context-api/consume/context-consume.decorator.test.ts b/src/Umbraco.Web.UI.Client/src/libs/context-api/consume/context-consume.decorator.test.ts new file mode 100644 index 000000000000..a61cb08b1ebe --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/libs/context-api/consume/context-consume.decorator.test.ts @@ -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('my-test-context'); + +class MyTestElement extends UmbLitElement { + @consumeContext({ + context: testToken, + }) + @state() + contextValue?: UmbTestContextConsumerClass; + + override render() { + return html`
${this.contextValue?.prop ?? 'no context'}
`; + } +} + +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(``); + }); + + 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('
value from provider
'); + }); + + 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( + ``, + ); + + 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'); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/libs/context-api/consume/context-consume.decorator.ts b/src/Umbraco.Web.UI.Client/src/libs/context-api/consume/context-consume.decorator.ts new file mode 100644 index 000000000000..717c460d93f9 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/libs/context-api/consume/context-consume.decorator.ts @@ -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; + + /** + * 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; + + /** + * 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} A property decorator function + */ +export function consumeContext< + BaseType extends UmbContextMinimal = UmbContextMinimal, + ResultType extends BaseType = BaseType, +>(options: UmbConsumeOptions): ConsumeDecorator { + const { context, callback, subscribe = true } = options; + + return ((protoOrTarget: any, nameOrContext: PropertyKey | ClassAccessorDecoratorContext) => { + if (typeof nameOrContext === 'object') { + setupStandardDecorator(protoOrTarget, nameOrContext, context, callback, subscribe); + return; + } + + setupLegacyDecorator(protoOrTarget, nameOrContext as string, context, callback, subscribe); + }) as ConsumeDecorator; +} + +/** + * 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( + protoOrTarget: any, + decoratorContext: ClassAccessorDecoratorContext, + context: string | UmbContextToken, + callback: UmbContextCallback | 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( + protoOrTarget: any, + propertyKey: string, + context: string | UmbContextToken, + callback: UmbContextCallback | 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 = { + [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 = { + // legacy + >( + protoOrDescriptor: Proto, + name?: K, + ): FieldMustMatchProvidedType; + + // standard + , V extends ValueType>( + value: ClassAccessorDecoratorTarget, + context: ClassAccessorDecoratorContext, + ): void; +}; + +// Note TypeScript requires the return type of a decorator to be `void | any` +type DecoratorReturn = void | any; + +type FieldMustMatchProvidedType = + // First we check whether the object has the property as a required field + Obj extends Record + ? // 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> + ? // 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; diff --git a/src/Umbraco.Web.UI.Client/src/libs/context-api/consume/context-consumer.ts b/src/Umbraco.Web.UI.Client/src/libs/context-api/consume/context-consumer.ts index bcc6ded36097..4372b6937792 100644 --- a/src/Umbraco.Web.UI.Client/src/libs/context-api/consume/context-consumer.ts +++ b/src/Umbraco.Web.UI.Client/src/libs/context-api/consume/context-consumer.ts @@ -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(() => { diff --git a/src/Umbraco.Web.UI.Client/src/libs/context-api/consume/index.ts b/src/Umbraco.Web.UI.Client/src/libs/context-api/consume/index.ts index bc1302658ce4..0f2ec13c2050 100644 --- a/src/Umbraco.Web.UI.Client/src/libs/context-api/consume/index.ts +++ b/src/Umbraco.Web.UI.Client/src/libs/context-api/consume/index.ts @@ -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'; diff --git a/src/Umbraco.Web.UI.Client/src/libs/context-api/provide/context-provide.decorator.test.ts b/src/Umbraco.Web.UI.Client/src/libs/context-api/provide/context-provide.decorator.test.ts new file mode 100644 index 000000000000..9965e827237b --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/libs/context-api/provide/context-provide.decorator.test.ts @@ -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('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( + ``, + ); + 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( + ``, + ); + 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); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/libs/context-api/provide/context-provide.decorator.ts b/src/Umbraco.Web.UI.Client/src/libs/context-api/provide/context-provide.decorator.ts new file mode 100644 index 000000000000..621009856209 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/libs/context-api/provide/context-provide.decorator.ts @@ -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 { + context: string | UmbContextToken; +} + +/** + * 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} A property decorator function + */ +export function provideContext< + BaseType extends UmbContextMinimal = UmbContextMinimal, + ResultType extends BaseType = BaseType, + InstanceType extends ResultType = ResultType, +>(options: UmbProvideOptions): ProvideDecorator { + const { context } = options; + + return (( + protoOrTarget: any, + nameOrContext: PropertyKey | ClassAccessorDecoratorContext, + ): void | any => { + if (typeof nameOrContext === 'object') { + return setupStandardDecorator(protoOrTarget, context); + } + + setupLegacyDecorator(protoOrTarget, nameOrContext as string, context); + }) as ProvideDecorator; +} + +/** + * 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) { + 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(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): 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(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(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 = { + [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 = { + // legacy + >( + protoOrDescriptor: Proto, + name?: K, + ): FieldMustMatchContextType; + + // standard + , V extends ContextType>( + value: ClassAccessorDecoratorTarget, + context: ClassAccessorDecoratorContext, + ): void; +}; + +// Note TypeScript requires the return type of a decorator to be `void | any` +type DecoratorReturn = void | any; + +type FieldMustMatchContextType = + // First we check whether the object has the property as a required field + Obj extends Record + ? // 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> + ? // 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; diff --git a/src/Umbraco.Web.UI.Client/src/libs/context-api/provide/index.ts b/src/Umbraco.Web.UI.Client/src/libs/context-api/provide/index.ts index 3690c440fd8e..ecad303f1961 100644 --- a/src/Umbraco.Web.UI.Client/src/libs/context-api/provide/index.ts +++ b/src/Umbraco.Web.UI.Client/src/libs/context-api/provide/index.ts @@ -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'; diff --git a/src/Umbraco.Web.UI.Client/src/mocks/data/log-viewer.data.ts b/src/Umbraco.Web.UI.Client/src/mocks/data/log-viewer.data.ts index fa179f05e791..96872fa203a9 100644 --- a/src/Umbraco.Web.UI.Client/src/mocks/data/log-viewer.data.ts +++ b/src/Umbraco.Web.UI.Client/src/mocks/data/log-viewer.data.ts @@ -44,7 +44,7 @@ class UmbLogViewerMessagesData extends UmbMockDBBase { } 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 diff --git a/src/Umbraco.Web.UI.Client/src/packages/log-viewer/components/log-viewer-date-range-selector.element.ts b/src/Umbraco.Web.UI.Client/src/packages/log-viewer/components/log-viewer-date-range-selector.element.ts index e07e40afd4e4..71d6c6c8440e 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/log-viewer/components/log-viewer-date-range-selector.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/log-viewer/components/log-viewer-date-range-selector.element.ts @@ -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}>
@@ -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}>
`; diff --git a/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/views/overview/components/log-viewer-log-level-overview.element.ts b/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/views/overview/components/log-viewer-log-level-overview.element.ts index ee03bf2a66d3..4fff8dedcc6a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/views/overview/components/log-viewer-log-level-overview.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/views/overview/components/log-viewer-log-level-overview.element.ts @@ -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 ?? []; }); } diff --git a/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/views/overview/components/log-viewer-log-types-chart.element.ts b/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/views/overview/components/log-viewer-log-types-chart.element.ts index fbc61d9b126f..c314fa23e6c2 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/views/overview/components/log-viewer-log-types-chart.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/views/overview/components/log-viewer-log-types-chart.element.ts @@ -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(); }); diff --git a/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/views/overview/components/log-viewer-message-templates-overview.element.ts b/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/views/overview/components/log-viewer-message-templates-overview.element.ts index 41dfc298c6c6..35c00a5fe501 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/views/overview/components/log-viewer-message-templates-overview.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/views/overview/components/log-viewer-message-templates-overview.element.ts @@ -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 = []; - #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) { diff --git a/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/views/overview/components/log-viewer-saved-searches-overview.element.ts b/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/views/overview/components/log-viewer-saved-searches-overview.element.ts index beb1753f6191..226a3676f06b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/views/overview/components/log-viewer-saved-searches-overview.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/views/overview/components/log-viewer-saved-searches-overview.element.ts @@ -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) { diff --git a/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/views/overview/log-overview-view.element.ts b/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/views/overview/log-overview-view.element.ts index 19ebd64195c0..1c0ec1b9711b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/views/overview/log-overview-view.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/views/overview/log-overview-view.element.ts @@ -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; }); } diff --git a/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/views/search/components/log-viewer-log-level-filter-menu.element.ts b/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/views/search/components/log-viewer-log-level-filter-menu.element.ts index 1db43e7f7e1a..f2a69a2d9be8 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/views/search/components/log-viewer-log-level-filter-menu.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/views/search/components/log-viewer-log-level-filter-menu.element.ts @@ -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); diff --git a/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/views/search/components/log-viewer-messages-list.element.ts b/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/views/search/components/log-viewer-messages-list.element.ts index c404cc622ec8..3e1bed642ee1 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/views/search/components/log-viewer-messages-list.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/views/search/components/log-viewer-messages-list.element.ts @@ -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; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/views/search/components/log-viewer-polling-button.element.ts b/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/views/search/components/log-viewer-polling-button.element.ts index 5bef958d4504..6955c832285e 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/views/search/components/log-viewer-polling-button.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/views/search/components/log-viewer-polling-button.element.ts @@ -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(); }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/views/search/components/log-viewer-search-input.element.ts b/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/views/search/components/log-viewer-search-input.element.ts index 55ad9b74e79c..e4563d976123 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/views/search/components/log-viewer-search-input.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/views/search/components/log-viewer-search-input.element.ts @@ -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(); - #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()); } diff --git a/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/views/search/log-search-view.element.ts b/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/views/search/log-search-view.element.ts index b7e9e1d1c123..317711c2e51a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/views/search/log-search-view.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/views/search/log-search-view.element.ts @@ -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; - #canShowLogsObserver?: UmbObserverController; - - constructor() { - super(); - this.consumeContext(UMB_APP_LOG_VIEWER_CONTEXT, (instance) => { - this.#logViewerContext = instance; - this.#observeCanShowLogs(); - }); + @consumeContext({ context: UMB_APP_LOG_VIEWER_CONTEXT }) + private set _logViewerContext(value) { + this.#logViewerContext = value; + this.#observeCanShowLogs(); } + private get _logViewerContext() { + return this.#logViewerContext; + } + + #canShowLogsObserver?: UmbObserverController; #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; }); }