Skip to content

Commit

Permalink
feat(atomic): add binding decorators (#4891)
Browse files Browse the repository at this point in the history
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
y-lakhdar and fbeaudoincoveo authored Jan 27, 2025
1 parent 0161af2 commit e5ac52c
Show file tree
Hide file tree
Showing 7 changed files with 339 additions and 37 deletions.
109 changes: 109 additions & 0 deletions packages/atomic/src/decorators/bind-state.ts
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);
};
};
});
};
}
54 changes: 54 additions & 0 deletions packages/atomic/src/decorators/binding-guard.ts
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;
};
}
53 changes: 53 additions & 0 deletions packages/atomic/src/decorators/error-guard.ts
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;
};
}
89 changes: 89 additions & 0 deletions packages/atomic/src/decorators/initialize-bindings.ts
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
);
}
});
};
33 changes: 33 additions & 0 deletions packages/atomic/src/decorators/types.ts
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;
}
37 changes: 0 additions & 37 deletions packages/atomic/src/utils/initialization-lit-utils.ts

This file was deleted.

1 change: 1 addition & 0 deletions packages/atomic/src/utils/initialization-utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down

0 comments on commit e5ac52c

Please sign in to comment.