Skip to content

Commit e5ac52c

Browse files
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]>
1 parent 0161af2 commit e5ac52c

7 files changed

+339
-37
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import type {Controller} from '@coveo/headless';
2+
import type {PropertyValues, ReactiveElement} from 'lit';
3+
import type {InitializableComponent} from './types';
4+
5+
type ControllerProperties<T> = {
6+
[K in keyof T]: T[K] extends Controller ? K : never;
7+
}[keyof T];
8+
9+
/**
10+
* Overrides the shouldUpdate method to prevent triggering an unnecessary updates when the controller state is not yet defined.
11+
*
12+
* This function wraps the original shouldUpdate method of a LitElement component. It ensures that the component
13+
* will only update if the original shouldUpdate method returns true and at least one of the changed properties
14+
* is not undefined.
15+
*
16+
* You can always define a custom shouldUpdate method in your component which will override this one.
17+
*
18+
* @param component - The LitElement component whose shouldUpdate method is being overridden.
19+
* @param shouldUpdate - The original shouldUpdate method of the component.
20+
*/
21+
function overrideShouldUpdate(
22+
component: ReactiveElement,
23+
shouldUpdate: (changedProperties: PropertyValues) => boolean
24+
) {
25+
// @ts-expect-error - shouldUpdate is a protected property
26+
component.shouldUpdate = function (changedProperties: PropertyValues) {
27+
return (
28+
shouldUpdate.call(this, changedProperties) &&
29+
[...changedProperties.values()].some((v) => v !== undefined)
30+
);
31+
};
32+
}
33+
34+
/**
35+
* 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).
36+
*
37+
* @example
38+
* ```ts
39+
* @bindStateToController('pager') @state() private pagerState!: PagerState;
40+
* ```
41+
*
42+
* For more information and examples, view the "Utilities" section of the readme.
43+
*
44+
* @param controllerProperty The controller property to subscribe to. The controller has to be created inside of the `initialize` method.
45+
* @param options The configurable `bindStateToController` options.
46+
* TODO: KIT-3822: add unit tests to this decorator
47+
*/
48+
export function bindStateToController<Element extends ReactiveElement>( // TODO: check if can inject @state decorator
49+
controllerProperty: ControllerProperties<Element>,
50+
options?: {
51+
/**
52+
* Component's method to be called when state is updated.
53+
*/
54+
onUpdateCallbackMethod?: string;
55+
}
56+
) {
57+
return <
58+
T extends Record<ControllerProperties<Element>, Controller> &
59+
Record<string, unknown>,
60+
Instance extends Element & T & InitializableComponent,
61+
K extends keyof Instance,
62+
>(
63+
proto: Element,
64+
stateProperty: K
65+
) => {
66+
const ctor = proto.constructor as typeof ReactiveElement;
67+
68+
ctor.addInitializer((instance) => {
69+
const component = instance as Instance;
70+
// @ts-expect-error - shouldUpdate is a protected property
71+
const {disconnectedCallback, initialize, shouldUpdate} = component;
72+
73+
overrideShouldUpdate(component, shouldUpdate);
74+
75+
component.initialize = function () {
76+
initialize && initialize.call(this);
77+
78+
if (!component[controllerProperty]) {
79+
return;
80+
}
81+
82+
if (
83+
options?.onUpdateCallbackMethod &&
84+
!component[options.onUpdateCallbackMethod]
85+
) {
86+
return console.error(
87+
`ControllerState: The onUpdateCallbackMethod property "${options.onUpdateCallbackMethod}" is not defined`,
88+
component
89+
);
90+
}
91+
92+
const controller = component[controllerProperty];
93+
const updateCallback = options?.onUpdateCallbackMethod
94+
? component[options.onUpdateCallbackMethod]
95+
: undefined;
96+
97+
const unsubscribeController = controller.subscribe(() => {
98+
component[stateProperty] = controller.state as Instance[K];
99+
typeof updateCallback === 'function' && updateCallback();
100+
});
101+
102+
component.disconnectedCallback = function () {
103+
!component.isConnected && unsubscribeController?.();
104+
disconnectedCallback && disconnectedCallback.call(component);
105+
};
106+
};
107+
});
108+
};
109+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import {html, LitElement, nothing} from 'lit';
2+
import type {TemplateResultType} from 'lit-html/directive-helpers.js';
3+
import type {Bindings} from '../components/search/atomic-search-interface/interfaces';
4+
import type {GenericRender, RenderGuardDecorator} from './types';
5+
6+
export interface LitElementWithBindings extends LitElement {
7+
bindings?: Bindings;
8+
}
9+
10+
/**
11+
* A decorator that guards the render method based on the presence of component bindings.
12+
*
13+
* This decorator is designed for LitElement components. It wraps the render method and checks for the `bindings` property
14+
* on the component. If the `bindings` property is not present or is false, the render method will return `nothing`.
15+
* If the `bindings` property is present and true, it calls the original render method.
16+
*
17+
* This decorator works in conjunction with the @initializeBindings decorator.
18+
*
19+
* @example
20+
* ```typescript
21+
* import { bindingGuard } from './decorators/binding-guard';
22+
* import { initializeBindings } from './decorators/initialize-bindings';
23+
*
24+
* class MyElement extends LitElement {
25+
* @initializeBindings() bindings!: Bindings;
26+
*
27+
* @bindingGuard()
28+
* render() {
29+
* return html`<div>Content to render when bindings are present</div>`;
30+
* }
31+
* }
32+
* ```
33+
* TODO: KIT-3822: add unit tests to this decorator
34+
* @throws {Error} If the decorator is used on a method other than the render method.
35+
*/
36+
export function bindingGuard<
37+
Component extends LitElementWithBindings,
38+
T extends TemplateResultType,
39+
>(): RenderGuardDecorator<Component, T> {
40+
return (_, __, descriptor) => {
41+
if (descriptor.value === undefined) {
42+
throw new Error(
43+
'@bindingGuard decorator can only be used on render method'
44+
);
45+
}
46+
const originalMethod = descriptor.value;
47+
descriptor.value = function (this: Component) {
48+
return this.bindings
49+
? originalMethod?.call(this)
50+
: (html`${nothing}` as GenericRender<T>);
51+
};
52+
return descriptor;
53+
};
54+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import {html, LitElement} from 'lit';
2+
import {TemplateResultType} from 'lit-html/directive-helpers.js';
3+
import {GenericRender, RenderGuardDecorator} from './types';
4+
5+
export interface LitElementWithError extends LitElement {
6+
error: Error;
7+
}
8+
9+
/**
10+
* A decorator that guards the render method of a LitElement component against errors.
11+
*
12+
* It wraps the render method and checks for an `error` property on the component.
13+
* If an error is present, it logs the error to the console and renders an error message.
14+
* Otherwise, it calls the original render method.
15+
*
16+
* @example
17+
* ```typescript
18+
* @errorGuard()
19+
* render() {
20+
* // ...
21+
* }
22+
* ```
23+
*
24+
* @returns A decorator function that wraps the render method with error handling logic.
25+
* @throws {Error} If the decorator is used on a method other than the render method.
26+
* TODO: KIT-3822: add unit tests to this decorator
27+
*/
28+
export function errorGuard<
29+
Component extends LitElementWithError,
30+
T extends TemplateResultType,
31+
>(): RenderGuardDecorator<Component, T> {
32+
return (_, __, descriptor) => {
33+
if (descriptor.value === undefined) {
34+
throw new Error(
35+
'@errorGuard decorator can only be used on render method'
36+
);
37+
}
38+
const originalMethod = descriptor.value;
39+
descriptor.value = function (this: Component) {
40+
if (this.error) {
41+
console.error(this.error, this);
42+
return html` <div class="text-error">
43+
<p>
44+
<b>${this.nodeName.toLowerCase()} component error</b>
45+
</p>
46+
<p>Look at the developer console for more information.</p>
47+
</div>` as GenericRender<T>;
48+
}
49+
return originalMethod.call(this);
50+
};
51+
return descriptor;
52+
};
53+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import type {ReactiveElement} from 'lit';
2+
import type {AnyBindings} from '../components/common/interface/bindings';
3+
import {closest} from '../utils/dom-utils';
4+
import {buildCustomEvent} from '../utils/event-utils';
5+
import {
6+
initializableElements,
7+
InitializeEventHandler,
8+
initializeEventName,
9+
MissingInterfaceParentError,
10+
} from '../utils/initialization-lit-stencil-common-utils';
11+
import type {InitializableComponent} from './types';
12+
13+
function fetchBindings<SpecificBindings extends AnyBindings>(element: Element) {
14+
return new Promise<SpecificBindings>((resolve, reject) => {
15+
const event = buildCustomEvent<InitializeEventHandler>(
16+
initializeEventName,
17+
(bindings: unknown) => resolve(bindings as SpecificBindings)
18+
);
19+
element.dispatchEvent(event);
20+
21+
if (!closest(element, initializableElements.join(', '))) {
22+
reject(new MissingInterfaceParentError(element.nodeName.toLowerCase()));
23+
}
24+
});
25+
}
26+
27+
/**
28+
* Retrieves `Bindings` or `CommerceBindings` on a configured parent interface.
29+
* @param event - The element on which to dispatch the event, which must be the child of a configured Atomic container element.
30+
* @returns A promise that resolves upon initialization of the parent container element, and rejects otherwise.
31+
* TODO: KIT-3822: add unit tests to this decorator
32+
*/
33+
export const initializeBindings =
34+
() =>
35+
<SpecificBindings extends AnyBindings>(
36+
proto: ReactiveElement,
37+
bindingsProperty: string
38+
) => {
39+
type InstanceType<SpecificBindings extends AnyBindings> = ReactiveElement &
40+
InitializableComponent<SpecificBindings>;
41+
42+
const ctor = proto.constructor as typeof ReactiveElement;
43+
const host = {
44+
_instance: null as InstanceType<SpecificBindings> | null,
45+
get: () => host._instance,
46+
set: (instance: InstanceType<SpecificBindings>) => {
47+
host._instance = instance;
48+
},
49+
};
50+
51+
let unsubscribeLanguage = () => {};
52+
53+
proto.addController({
54+
hostConnected() {
55+
const instance = host.get();
56+
if (!instance) {
57+
return;
58+
}
59+
60+
fetchBindings<SpecificBindings>(instance)
61+
.then((bindings) => {
62+
instance.bindings = bindings;
63+
64+
const updateLanguage = () => instance.requestUpdate();
65+
instance.bindings.i18n.on('languageChanged', updateLanguage);
66+
unsubscribeLanguage = () =>
67+
instance.bindings.i18n.off('languageChanged', updateLanguage);
68+
69+
instance.initialize?.();
70+
})
71+
.catch((error) => {
72+
instance.error = error;
73+
});
74+
},
75+
hostDisconnected() {
76+
unsubscribeLanguage();
77+
},
78+
});
79+
80+
ctor.addInitializer((instance) => {
81+
host.set(instance as InstanceType<SpecificBindings>);
82+
if (bindingsProperty !== 'bindings') {
83+
return console.error(
84+
`The InitializeBindings decorator should be used on a property called "bindings", and not "${bindingsProperty}"`,
85+
instance
86+
);
87+
}
88+
});
89+
};
+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import {TemplateResult} from 'lit-html';
2+
import {TemplateResultType} from 'lit-html/directive-helpers.js';
3+
import {AnyBindings} from '../components/common/interface/bindings';
4+
import {Bindings} from '../components/search/atomic-search-interface/interfaces';
5+
6+
export type GenericRender<T extends TemplateResultType> = TemplateResult<T>;
7+
8+
export type RenderGuardDecorator<
9+
Component,
10+
T extends TemplateResultType,
11+
Descriptor = TypedPropertyDescriptor<() => GenericRender<T>>,
12+
> = (
13+
target: Component,
14+
propertyKey: 'render',
15+
descriptor: Descriptor
16+
) => Descriptor;
17+
18+
/**
19+
* Necessary interface an Atomic Component must have to initialize itself correctly.
20+
*/
21+
export interface InitializableComponent<
22+
SpecificBindings extends AnyBindings = Bindings,
23+
> {
24+
/**
25+
* Bindings passed from the `AtomicSearchInterface` to its children components.
26+
*/
27+
bindings: SpecificBindings;
28+
/**
29+
* Method called right after the `bindings` property is defined. This is the method where Headless Framework controllers should be initialized.
30+
*/
31+
initialize?: () => void;
32+
error: Error;
33+
}

packages/atomic/src/utils/initialization-lit-utils.ts

-37
This file was deleted.

packages/atomic/src/utils/initialization-utils.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export {
5555

5656
/**
5757
* Necessary interface an Atomic Component must have to initialize itself correctly.
58+
* @deprecated To be used for Stencil components. For Lit components. use `InitializableComponent` from './decorators/types/'
5859
*/
5960
export interface InitializableComponent<
6061
SpecificBindings extends AnyBindings = Bindings,

0 commit comments

Comments
 (0)