From e5ac52cd19d004cf611304dc435679bb882b33fb Mon Sep 17 00:00:00 2001 From: ylakhdar Date: Mon, 27 Jan 2025 11:01:51 -0500 Subject: [PATCH] feat(atomic): add binding decorators (#4891) Refactors the logic from the `src/utils/initialization-utils.tsx` file by breaking it into smaller, independent pieces. The goal is to replace the large, monolithic `InitializeBindings` utility with more modular and maintainable decorators. ## New Decorators ### `@initializeBindings` Replaces the previous `@InitializeBindings` decorator used in Stencil components. It initializes the bindings for a component. ### `@bindStateToController` Replaces the previous `@BindStateToController` decorator used in Stencil components, linking component state to a controller. ### `@bindingGuard` Guards the `render` method, ensuring it is only executed when component bindings are initialized. ### `@errorGuard` Guards the `render` method against errors. ## Usage Example ```typescript @customElement('my-component') export class MyComponent extends LitElement implements InitializableComponent { @initializeBindings() bindings!: Bindings; @state() public error!: Error; @errorGuard() @bindingGuard() public render() { return html``; } } ``` ### Other Changes * Removed the loadFocusVisiblePolyfill logic from the initializeBindings decorator as it is no longer needed. * Dropped usage of the data-atomic-rendered and data-atomic-loaded attributes, which were previously tied to the polyfill logic but are now unused. https://coveord.atlassian.net/browse/KIT-3818 --------- Co-authored-by: Frederic Beaudoin --- packages/atomic/src/decorators/bind-state.ts | 109 ++++++++++++++++++ .../atomic/src/decorators/binding-guard.ts | 54 +++++++++ packages/atomic/src/decorators/error-guard.ts | 53 +++++++++ .../src/decorators/initialize-bindings.ts | 89 ++++++++++++++ packages/atomic/src/decorators/types.ts | 33 ++++++ .../src/utils/initialization-lit-utils.ts | 37 ------ .../atomic/src/utils/initialization-utils.tsx | 1 + 7 files changed, 339 insertions(+), 37 deletions(-) create mode 100644 packages/atomic/src/decorators/bind-state.ts create mode 100644 packages/atomic/src/decorators/binding-guard.ts create mode 100644 packages/atomic/src/decorators/error-guard.ts create mode 100644 packages/atomic/src/decorators/initialize-bindings.ts create mode 100644 packages/atomic/src/decorators/types.ts delete mode 100644 packages/atomic/src/utils/initialization-lit-utils.ts diff --git a/packages/atomic/src/decorators/bind-state.ts b/packages/atomic/src/decorators/bind-state.ts new file mode 100644 index 00000000000..ae9b3518421 --- /dev/null +++ b/packages/atomic/src/decorators/bind-state.ts @@ -0,0 +1,109 @@ +import type {Controller} from '@coveo/headless'; +import type {PropertyValues, ReactiveElement} from 'lit'; +import type {InitializableComponent} from './types'; + +type ControllerProperties = { + [K in keyof T]: T[K] extends Controller ? K : never; +}[keyof T]; + +/** + * Overrides the shouldUpdate method to prevent triggering an unnecessary updates when the controller state is not yet defined. + * + * This function wraps the original shouldUpdate method of a LitElement component. It ensures that the component + * will only update if the original shouldUpdate method returns true and at least one of the changed properties + * is not undefined. + * + * You can always define a custom shouldUpdate method in your component which will override this one. + * + * @param component - The LitElement component whose shouldUpdate method is being overridden. + * @param shouldUpdate - The original shouldUpdate method of the component. + */ +function overrideShouldUpdate( + component: ReactiveElement, + shouldUpdate: (changedProperties: PropertyValues) => boolean +) { + // @ts-expect-error - shouldUpdate is a protected property + component.shouldUpdate = function (changedProperties: PropertyValues) { + return ( + shouldUpdate.call(this, changedProperties) && + [...changedProperties.values()].some((v) => v !== undefined) + ); + }; +} + +/** + * A decorator that allows the Lit component state property to automatically get updates from a [Coveo Headless controller](https://docs.coveo.com/en/headless/latest/usage/#use-headless-controllers). + * + * @example + * ```ts + * @bindStateToController('pager') @state() private pagerState!: PagerState; + * ``` + * + * For more information and examples, view the "Utilities" section of the readme. + * + * @param controllerProperty The controller property to subscribe to. The controller has to be created inside of the `initialize` method. + * @param options The configurable `bindStateToController` options. + * TODO: KIT-3822: add unit tests to this decorator + */ +export function bindStateToController( // TODO: check if can inject @state decorator + controllerProperty: ControllerProperties, + options?: { + /** + * Component's method to be called when state is updated. + */ + onUpdateCallbackMethod?: string; + } +) { + return < + T extends Record, Controller> & + Record, + Instance extends Element & T & InitializableComponent, + K extends keyof Instance, + >( + proto: Element, + stateProperty: K + ) => { + const ctor = proto.constructor as typeof ReactiveElement; + + ctor.addInitializer((instance) => { + const component = instance as Instance; + // @ts-expect-error - shouldUpdate is a protected property + const {disconnectedCallback, initialize, shouldUpdate} = component; + + overrideShouldUpdate(component, shouldUpdate); + + component.initialize = function () { + initialize && initialize.call(this); + + if (!component[controllerProperty]) { + return; + } + + if ( + options?.onUpdateCallbackMethod && + !component[options.onUpdateCallbackMethod] + ) { + return console.error( + `ControllerState: The onUpdateCallbackMethod property "${options.onUpdateCallbackMethod}" is not defined`, + component + ); + } + + const controller = component[controllerProperty]; + const updateCallback = options?.onUpdateCallbackMethod + ? component[options.onUpdateCallbackMethod] + : undefined; + + const unsubscribeController = controller.subscribe(() => { + component[stateProperty] = controller.state as Instance[K]; + typeof updateCallback === 'function' && updateCallback(); + }); + + component.disconnectedCallback = function () { + !component.isConnected && unsubscribeController?.(); + disconnectedCallback && disconnectedCallback.call(component); + }; + }; + }); + }; +} diff --git a/packages/atomic/src/decorators/binding-guard.ts b/packages/atomic/src/decorators/binding-guard.ts new file mode 100644 index 00000000000..a32298c5939 --- /dev/null +++ b/packages/atomic/src/decorators/binding-guard.ts @@ -0,0 +1,54 @@ +import {html, LitElement, nothing} from 'lit'; +import type {TemplateResultType} from 'lit-html/directive-helpers.js'; +import type {Bindings} from '../components/search/atomic-search-interface/interfaces'; +import type {GenericRender, RenderGuardDecorator} from './types'; + +export interface LitElementWithBindings extends LitElement { + bindings?: Bindings; +} + +/** + * A decorator that guards the render method based on the presence of component bindings. + * + * This decorator is designed for LitElement components. It wraps the render method and checks for the `bindings` property + * on the component. If the `bindings` property is not present or is false, the render method will return `nothing`. + * If the `bindings` property is present and true, it calls the original render method. + * + * This decorator works in conjunction with the @initializeBindings decorator. + * + * @example + * ```typescript + * import { bindingGuard } from './decorators/binding-guard'; + * import { initializeBindings } from './decorators/initialize-bindings'; + * + * class MyElement extends LitElement { + * @initializeBindings() bindings!: Bindings; + * + * @bindingGuard() + * render() { + * return html`
Content to render when bindings are present
`; + * } + * } + * ``` + * TODO: KIT-3822: add unit tests to this decorator + * @throws {Error} If the decorator is used on a method other than the render method. + */ +export function bindingGuard< + Component extends LitElementWithBindings, + T extends TemplateResultType, +>(): RenderGuardDecorator { + return (_, __, descriptor) => { + if (descriptor.value === undefined) { + throw new Error( + '@bindingGuard decorator can only be used on render method' + ); + } + const originalMethod = descriptor.value; + descriptor.value = function (this: Component) { + return this.bindings + ? originalMethod?.call(this) + : (html`${nothing}` as GenericRender); + }; + return descriptor; + }; +} diff --git a/packages/atomic/src/decorators/error-guard.ts b/packages/atomic/src/decorators/error-guard.ts new file mode 100644 index 00000000000..47045a02f71 --- /dev/null +++ b/packages/atomic/src/decorators/error-guard.ts @@ -0,0 +1,53 @@ +import {html, LitElement} from 'lit'; +import {TemplateResultType} from 'lit-html/directive-helpers.js'; +import {GenericRender, RenderGuardDecorator} from './types'; + +export interface LitElementWithError extends LitElement { + error: Error; +} + +/** + * A decorator that guards the render method of a LitElement component against errors. + * + * It wraps the render method and checks for an `error` property on the component. + * If an error is present, it logs the error to the console and renders an error message. + * Otherwise, it calls the original render method. + * + * @example + * ```typescript + * @errorGuard() + * render() { + * // ... + * } + * ``` + * + * @returns A decorator function that wraps the render method with error handling logic. + * @throws {Error} If the decorator is used on a method other than the render method. + * TODO: KIT-3822: add unit tests to this decorator + */ +export function errorGuard< + Component extends LitElementWithError, + T extends TemplateResultType, +>(): RenderGuardDecorator { + return (_, __, descriptor) => { + if (descriptor.value === undefined) { + throw new Error( + '@errorGuard decorator can only be used on render method' + ); + } + const originalMethod = descriptor.value; + descriptor.value = function (this: Component) { + if (this.error) { + console.error(this.error, this); + return html`
+

+ ${this.nodeName.toLowerCase()} component error +

+

Look at the developer console for more information.

+
` as GenericRender; + } + return originalMethod.call(this); + }; + return descriptor; + }; +} diff --git a/packages/atomic/src/decorators/initialize-bindings.ts b/packages/atomic/src/decorators/initialize-bindings.ts new file mode 100644 index 00000000000..536a7f61cbe --- /dev/null +++ b/packages/atomic/src/decorators/initialize-bindings.ts @@ -0,0 +1,89 @@ +import type {ReactiveElement} from 'lit'; +import type {AnyBindings} from '../components/common/interface/bindings'; +import {closest} from '../utils/dom-utils'; +import {buildCustomEvent} from '../utils/event-utils'; +import { + initializableElements, + InitializeEventHandler, + initializeEventName, + MissingInterfaceParentError, +} from '../utils/initialization-lit-stencil-common-utils'; +import type {InitializableComponent} from './types'; + +function fetchBindings(element: Element) { + return new Promise((resolve, reject) => { + const event = buildCustomEvent( + initializeEventName, + (bindings: unknown) => resolve(bindings as SpecificBindings) + ); + element.dispatchEvent(event); + + if (!closest(element, initializableElements.join(', '))) { + reject(new MissingInterfaceParentError(element.nodeName.toLowerCase())); + } + }); +} + +/** + * Retrieves `Bindings` or `CommerceBindings` on a configured parent interface. + * @param event - The element on which to dispatch the event, which must be the child of a configured Atomic container element. + * @returns A promise that resolves upon initialization of the parent container element, and rejects otherwise. + * TODO: KIT-3822: add unit tests to this decorator + */ +export const initializeBindings = + () => + ( + proto: ReactiveElement, + bindingsProperty: string + ) => { + type InstanceType = ReactiveElement & + InitializableComponent; + + const ctor = proto.constructor as typeof ReactiveElement; + const host = { + _instance: null as InstanceType | null, + get: () => host._instance, + set: (instance: InstanceType) => { + host._instance = instance; + }, + }; + + let unsubscribeLanguage = () => {}; + + proto.addController({ + hostConnected() { + const instance = host.get(); + if (!instance) { + return; + } + + fetchBindings(instance) + .then((bindings) => { + instance.bindings = bindings; + + const updateLanguage = () => instance.requestUpdate(); + instance.bindings.i18n.on('languageChanged', updateLanguage); + unsubscribeLanguage = () => + instance.bindings.i18n.off('languageChanged', updateLanguage); + + instance.initialize?.(); + }) + .catch((error) => { + instance.error = error; + }); + }, + hostDisconnected() { + unsubscribeLanguage(); + }, + }); + + ctor.addInitializer((instance) => { + host.set(instance as InstanceType); + if (bindingsProperty !== 'bindings') { + return console.error( + `The InitializeBindings decorator should be used on a property called "bindings", and not "${bindingsProperty}"`, + instance + ); + } + }); + }; diff --git a/packages/atomic/src/decorators/types.ts b/packages/atomic/src/decorators/types.ts new file mode 100644 index 00000000000..ffe8e0c973f --- /dev/null +++ b/packages/atomic/src/decorators/types.ts @@ -0,0 +1,33 @@ +import {TemplateResult} from 'lit-html'; +import {TemplateResultType} from 'lit-html/directive-helpers.js'; +import {AnyBindings} from '../components/common/interface/bindings'; +import {Bindings} from '../components/search/atomic-search-interface/interfaces'; + +export type GenericRender = TemplateResult; + +export type RenderGuardDecorator< + Component, + T extends TemplateResultType, + Descriptor = TypedPropertyDescriptor<() => GenericRender>, +> = ( + target: Component, + propertyKey: 'render', + descriptor: Descriptor +) => Descriptor; + +/** + * Necessary interface an Atomic Component must have to initialize itself correctly. + */ +export interface InitializableComponent< + SpecificBindings extends AnyBindings = Bindings, +> { + /** + * Bindings passed from the `AtomicSearchInterface` to its children components. + */ + bindings: SpecificBindings; + /** + * Method called right after the `bindings` property is defined. This is the method where Headless Framework controllers should be initialized. + */ + initialize?: () => void; + error: Error; +} diff --git a/packages/atomic/src/utils/initialization-lit-utils.ts b/packages/atomic/src/utils/initialization-lit-utils.ts deleted file mode 100644 index 1c58bef2960..00000000000 --- a/packages/atomic/src/utils/initialization-lit-utils.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type {AnyBindings} from '../components/common/interface/bindings'; -import type {Bindings} from '../components/search/atomic-search-interface/interfaces'; -import {closest} from './dom-utils.js'; -import {buildCustomEvent} from './event-utils.js'; -import { - initializeEventName, - initializableElements, - InitializeEventHandler, - MissingInterfaceParentError, -} from './initialization-lit-stencil-common-utils.js'; - -export { - initializableElements, - InitializeEventHandler, - MissingInterfaceParentError, -} from './initialization-lit-stencil-common-utils.js'; - -/** - * Retrieves `Bindings` or `CommerceBindings` on a configured parent interface. - * @param event - The element on which to dispatch the event, which must be the child of a configured Atomic container element. - * @returns A promise that resolves upon initialization of the parent container element, and rejects otherwise. - */ -export function initializeBindings< - SpecificBindings extends AnyBindings = Bindings, ->(element: Element) { - return new Promise((resolve, reject) => { - const event = buildCustomEvent( - initializeEventName, - (bindings: unknown) => resolve(bindings as SpecificBindings) - ); - element.dispatchEvent(event); - - if (!closest(element, initializableElements.join(', '))) { - reject(new MissingInterfaceParentError(element.nodeName.toLowerCase())); - } - }); -} diff --git a/packages/atomic/src/utils/initialization-utils.tsx b/packages/atomic/src/utils/initialization-utils.tsx index 365c0a68f40..713cc1e1a93 100644 --- a/packages/atomic/src/utils/initialization-utils.tsx +++ b/packages/atomic/src/utils/initialization-utils.tsx @@ -55,6 +55,7 @@ export { /** * Necessary interface an Atomic Component must have to initialize itself correctly. + * @deprecated To be used for Stencil components. For Lit components. use `InitializableComponent` from './decorators/types/' */ export interface InitializableComponent< SpecificBindings extends AnyBindings = Bindings,