-
Notifications
You must be signed in to change notification settings - Fork 34
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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 <[email protected]>
- Loading branch information
1 parent
0161af2
commit e5ac52c
Showing
7 changed files
with
339 additions
and
37 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,109 @@ | ||
import type {Controller} from '@coveo/headless'; | ||
import type {PropertyValues, ReactiveElement} from 'lit'; | ||
import type {InitializableComponent} from './types'; | ||
|
||
type ControllerProperties<T> = { | ||
[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<Element extends ReactiveElement>( // TODO: check if can inject @state decorator | ||
controllerProperty: ControllerProperties<Element>, | ||
options?: { | ||
/** | ||
* Component's method to be called when state is updated. | ||
*/ | ||
onUpdateCallbackMethod?: string; | ||
} | ||
) { | ||
return < | ||
T extends Record<ControllerProperties<Element>, Controller> & | ||
Record<string, unknown>, | ||
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); | ||
}; | ||
}; | ||
}); | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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`<div>Content to render when bindings are present</div>`; | ||
* } | ||
* } | ||
* ``` | ||
* 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<Component, T> { | ||
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<T>); | ||
}; | ||
return descriptor; | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<Component, T> { | ||
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` <div class="text-error"> | ||
<p> | ||
<b>${this.nodeName.toLowerCase()} component error</b> | ||
</p> | ||
<p>Look at the developer console for more information.</p> | ||
</div>` as GenericRender<T>; | ||
} | ||
return originalMethod.call(this); | ||
}; | ||
return descriptor; | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<SpecificBindings extends AnyBindings>(element: Element) { | ||
return new Promise<SpecificBindings>((resolve, reject) => { | ||
const event = buildCustomEvent<InitializeEventHandler>( | ||
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 = | ||
() => | ||
<SpecificBindings extends AnyBindings>( | ||
proto: ReactiveElement, | ||
bindingsProperty: string | ||
) => { | ||
type InstanceType<SpecificBindings extends AnyBindings> = ReactiveElement & | ||
InitializableComponent<SpecificBindings>; | ||
|
||
const ctor = proto.constructor as typeof ReactiveElement; | ||
const host = { | ||
_instance: null as InstanceType<SpecificBindings> | null, | ||
get: () => host._instance, | ||
set: (instance: InstanceType<SpecificBindings>) => { | ||
host._instance = instance; | ||
}, | ||
}; | ||
|
||
let unsubscribeLanguage = () => {}; | ||
|
||
proto.addController({ | ||
hostConnected() { | ||
const instance = host.get(); | ||
if (!instance) { | ||
return; | ||
} | ||
|
||
fetchBindings<SpecificBindings>(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<SpecificBindings>); | ||
if (bindingsProperty !== 'bindings') { | ||
return console.error( | ||
`The InitializeBindings decorator should be used on a property called "bindings", and not "${bindingsProperty}"`, | ||
instance | ||
); | ||
} | ||
}); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<T extends TemplateResultType> = TemplateResult<T>; | ||
|
||
export type RenderGuardDecorator< | ||
Component, | ||
T extends TemplateResultType, | ||
Descriptor = TypedPropertyDescriptor<() => GenericRender<T>>, | ||
> = ( | ||
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; | ||
} |
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters