Skip to content

Commit

Permalink
feat(framework): add strict event type checking (#10235)
Browse files Browse the repository at this point in the history
  • Loading branch information
pskelin authored Dec 3, 2024
1 parent 0b9d1a0 commit 4ff8ab7
Show file tree
Hide file tree
Showing 102 changed files with 1,006 additions and 1,243 deletions.
69 changes: 53 additions & 16 deletions docs/4-development/05-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,31 +6,32 @@ Components use `CustomEvent` to inform developers of important state changes in

## The `@event` Decorator

To define your own custom event, you need to use the `@event` decorator.
There are two `@event` decorators available with the following imports:
```ts
import event from "@ui5/webcomponents-base/dist/decorators/event-strict.js"; // recommended for new develompent
// or
import event from "@ui5/webcomponents-base/dist/decorators/event.js"; // deprecated
```

The `event` decorator is a class decorator that takes one required argument as a string to define the event name and an optional argument as an object literal to describe details of the custom element.
To define your own custom event, you need to use the `@event` decorator.

The details object allows developers to describe more information about the event.
The `event` decorator is a class decorator that takes one required argument as a string to define the event name

```ts
import UI5Element from "@ui5/webcomponents-base/dist/UI5Element.js";
import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js";
import event from "@ui5/webcomponents-base/dist/decorators/event.js";
import event from "@ui5/webcomponents-base/dist/decorators/event-strict.js";

@customElement("my-demo-component")
@event("change", {
detail: {
valid: { type: Boolean },
},
})
@event("change")
class MyDemoComponent extends UI5Element {}
```

**Note:** This decorator is used only to describe the events of the component and is not meant to create emitters.
**Note:** This decorator is used only to describe the events of the component and is not meant to create emitters. See `fireDecoratorEvent` below.

## Usage

As mentioned earlier, the `@event` decorator doesn't create event emitters. To notify developers of component changes, we have to fire events ourselves. This can be done using the `fireEvent` and the newer `fireDecoratorEvent` methods that comes from the `UI5Element` class. The difference between the methods is explained below.
As mentioned earlier, the `@event` decorator doesn't create event emitters. To notify developers of component changes, we have to fire events ourselves. This can be done using the `fireEventDecoratorEvent` and the deprecated `fireEvent` methods that come from the `UI5Element` class. The difference between the methods is explained below.

```ts
import UI5Element from "@ui5/webcomponents-base/dist/UI5Element.js";
Expand All @@ -53,7 +54,43 @@ class MyDemoComponent extends UI5Element {

**Note:** By default, the `fireDecoratorEvent` (and `fireEvent`) method returns a boolean value that helps you understand whether the event was canceled (i.e., if the `preventDefault` method was called).

## Event Detail
## `eventDetails` (recommended)
The `eventDetails` class field is used to describe the types of events that the component emits. The strict event decorator is using this information for type checking the names.

```ts
class MyComponent extends UI5Element {
eventDetails!: {
"selection-change": SelectionChangeDetails
"delete": void
}
}
```

This field doesn't have runtime semantics, it is only used to provide type information about the events that the component is firing and the corresponding types of the detail parameter.

### Extending the `eventDetails` with more events
If your component extends another component and you try to add new events, you will get a TypeScript error that the new events cannot be assigned to the same field in the base class

```ts
class TimeSelectionClocks extends TimePickerInternals {
eventDetails!: { // ts-error
"close-picker": void,
};

// Property 'eventDetails' in type 'TimeSelectionClocks' is not assignable to the same property in base type 'TimePickerInternals'.
```
In order to correctly extend the base class events, you need to add them as a type as well like this TimePickerInternals["eventDetails"]
```ts
class TimeSelectionClocks extends TimePickerInternals {
eventDetails!: TimePickerInternals["eventDetails"] & {
"close-picker": void,
};
}
```
## Event Detail (deprecated)
The `@event` decorator is generic and accepts a TypeScript type that describes its detail. This type is crucial for preventing incorrect detail data when the event is fired using `fireDecoratorEvent` and `fireEvent` methods (both generic) and for ensuring type safety when listening for the event, so you know what kind of detail data to expect.
Expand Down Expand Up @@ -83,7 +120,7 @@ class MyDemoComponent extends UI5Element {
value = "";

onNativeInputChange(e: Event) {
this.fireDecoratorEvent<MyDemoComponentChangeEventDetail>("change", {
this.fireEvent("change", {
valid: true,
});
}
Expand All @@ -92,7 +129,7 @@ class MyDemoComponent extends UI5Element {
export { MyDemoComponent };
```
## Event Configuration
## Event Configuration (both event decorators)
### Bubbling and Preventing
Expand Down Expand Up @@ -125,7 +162,7 @@ class MyDemoComponent extends UI5Element {
### The `fireDecoratorEvent` method
The method is available since version `v2.4.0` and it fires a custom event and gets the configuration for the event from the `@event` decorator. In case you rely on the decorator settings, you must use the `fireDecoratorEvent` method.
The method is available since version `v2.4.0` and it fires a custom event and gets the configuration for the event from the `@event` decorator. It also strictly checks the details parameter agains the `eventDetails` type for the same event name.
Keep in mind that `cancelable` and `bubbles` are `false` by default and you must explicitly enable them in the `@event` decorator if required.
Expand Down Expand Up @@ -154,7 +191,7 @@ this.fireDecoratorEvent("change");
this.fireDecoratorEvent("change");
```
**Note:** since `v2.4.0` it's recommended to describe the event in the `@event` decorator and use the `fireDecoratorEvent` method.
**Note:** since `v2.4.0` it's recommended to describe the event in the `@event` decorator and use the `fireDecoratorEvent` method.
### The `fireEvent` method
Expand Down
5 changes: 4 additions & 1 deletion packages/ai/src/Button.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import UI5Element from "@ui5/webcomponents-base/dist/UI5Element.js";
import { renderFinished } from "@ui5/webcomponents-base/dist/Render.js";
import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js";
import property from "@ui5/webcomponents-base/dist/decorators/property.js";
import event from "@ui5/webcomponents-base/dist/decorators/event.js";
import event from "@ui5/webcomponents-base/dist/decorators/event-strict.js";
import slot from "@ui5/webcomponents-base/dist/decorators/slot.js";
import litRender from "@ui5/webcomponents-base/dist/renderer/LitRenderer.js";
import MainButton from "@ui5/webcomponents/dist/Button.js";
Expand Down Expand Up @@ -63,6 +63,9 @@ import ButtonCss from "./generated/themes/Button.css.js";
bubbles: true,
})
class Button extends UI5Element {
eventDetails!: {
click: void,
}
/**
* Defines the component design.
* @default "Default"
Expand Down
7 changes: 6 additions & 1 deletion packages/ai/src/PromptInput.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import UI5Element from "@ui5/webcomponents-base/dist/UI5Element.js";
import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js";
import property from "@ui5/webcomponents-base/dist/decorators/property.js";
import event from "@ui5/webcomponents-base/dist/decorators/event.js";
import event from "@ui5/webcomponents-base/dist/decorators/event-strict.js";
import slot from "@ui5/webcomponents-base/dist/decorators/slot.js";
import i18n from "@ui5/webcomponents-base/dist/decorators/i18n.js";
import litRender from "@ui5/webcomponents-base/dist/renderer/LitRenderer.js";
Expand Down Expand Up @@ -87,6 +87,11 @@ import PromptInputCss from "./generated/themes/PromptInput.css.js";
bubbles: true,
})
class PromptInput extends UI5Element {
eventDetails!: {
submit: void;
input: void;
change: void;
}
/**
* Defines the value of the component.
*
Expand Down
15 changes: 11 additions & 4 deletions packages/base/src/UI5Element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,10 @@ function getPropertyDescriptor(proto: any, name: PropertyKey): PropertyDescripto
proto = Object.getPrototypeOf(proto);
} while (proto && proto !== HTMLElement.prototype);
}
export type NotEqual<X, Y> = true extends Equal<X, Y> ? false : true
export type Equal<X, Y> =
(<T>() => T extends X ? 1 : 2) extends
(<T>() => T extends Y ? 1 : 2) ? true : false

/**
* @class
Expand All @@ -148,6 +152,9 @@ function getPropertyDescriptor(proto: any, name: PropertyKey): PropertyDescripto
* @public
*/
abstract class UI5Element extends HTMLElement {
eventDetails!: NotEqual<typeof this, typeof UI5Element> extends true ? object : {
[k: string]: any
};
__id?: string;
_suppressInvalidation: boolean;
_changedState: Array<ChangeInfo>;
Expand Down Expand Up @@ -970,13 +977,13 @@ abstract class UI5Element extends HTMLElement {
* @param data - additional data for the event
* @returns false, if the event was cancelled (preventDefault called), true otherwise
*/
fireDecoratorEvent<T>(name: string, data?: T): boolean {
const eventData = this.getEventData(name);
fireDecoratorEvent<N extends keyof this["eventDetails"]>(name: N, data?: this["eventDetails"][N] | undefined): boolean {
const eventData = this.getEventData(name as string);
const cancellable = eventData ? eventData.cancelable : false;
const bubbles = eventData ? eventData.bubbles : false;

const eventResult = this._fireEvent(name, data, cancellable, bubbles);
const pascalCaseEventName = kebabToPascalCase(name);
const eventResult = this._fireEvent(name as string, data, cancellable, bubbles);
const pascalCaseEventName = kebabToPascalCase(name as string);

// pascal events are more convinient for native react usage
// live-change:
Expand Down
2 changes: 1 addition & 1 deletion packages/base/src/UI5ElementMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ type Property = {

type PropertyValue = boolean | number | string | object | undefined | null;

type EventData = Record<string, { detail: Record<string, object>, cancelable: boolean, bubbles: boolean }>;
type EventData = Record<string, { detail?: Record<string, object>, cancelable?: boolean, bubbles?: boolean }>;

type I18nBundleAccessorValue = {
bundleName: string,
Expand Down
2 changes: 2 additions & 0 deletions packages/base/src/decorators.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import customElement from "./decorators/customElement.js";
import event from "./decorators/event.js";
import eventStrict from "./decorators/event-strict.js";
import property from "./decorators/property.js";
import slot from "./decorators/slot.js";

export {
customElement,
event,
eventStrict,
property,
slot,
};
33 changes: 33 additions & 0 deletions packages/base/src/decorators/event-strict.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type UI5Element from "../UI5Element.js";

type EventDetailKeys<T extends typeof UI5Element> = keyof InstanceType<T>["eventDetails"];
type ExtractEventKeys<T extends typeof UI5Element> = EventDetailKeys<T> extends never ? "event name not found in `eventDetails` field" : EventDetailKeys<T>;

/**
* Returns an event class decorator.
*
* @param { string } name the event name
* @param { EventData } data the event data
* @returns { ClassDecorator }
*/
const event = <T extends typeof UI5Element, N extends ExtractEventKeys<T>>(name: N, data: { bubbles?: boolean, cancelable?: boolean } = {}): (target: T) => T | void => {
return (target: T) => {
if (!Object.prototype.hasOwnProperty.call(target, "metadata")) {
target.metadata = {};
}

const metadata = target.metadata;
if (!metadata.events) {
metadata.events = {};
}

const eventsMetadata = metadata.events;
if (!eventsMetadata[name as string]) {
data.bubbles = !!data.bubbles;
data.cancelable = !!data.cancelable;
eventsMetadata[name as string] = data;
}
};
};

export default event;
1 change: 1 addition & 0 deletions packages/base/src/decorators/event.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/**
* Returns an event class decorator.
*
* @deprecated Use `@ui5/webcomponents-base/dist/decorators/event-strict.js` instead.
* @param { string } name the event name
* @param { EventData } data the event data
* @returns { ClassDecorator }
Expand Down
64 changes: 64 additions & 0 deletions packages/base/test/types/event-details.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import UI5Element from "../../src/UI5Element";
import event from "../../src/decorators/event-strict";

// @ts-expect-error eventDetails is fully missing
@event("toggle")
class ComponentWithoutEventDetails extends UI5Element {
myHandler() {
// @ts-expect-error event details is fully missing
this.fireDecoratorEvent("toggle");
}
}

// correct event
@event("toggle")
@event("change")
// @ts-expect-error wrong event name
@event("not-found")
class ComponentWithEventDetails extends UI5Element {
eventDetails!: {
toggle: void
change: {
data: string
}
};

myHandler() {
// correct event
this.fireDecoratorEvent("toggle");

// @ts-expect-error wrong event name
this.fireDecoratorEvent("not-found");

// @ts-expect-error wrong data type
this.fireDecoratorEvent("change", { wrongData: "data" });
// @ts-expect-error wrong data
this.fireDecoratorEvent("toggle", {data: "data"});

// still no error if data is missing, will be implemented later
this.fireDecoratorEvent("change");

// correct data no error
this.fireDecoratorEvent("change", { data: "data" });

}
}

class BaseComponent extends UI5Element {
// no event details
}

@event("open")
// @ts-expect-error
@event("not-found")
class ChildComponent extends BaseComponent {
// Base component should provide an empty object, not ANY
eventDetails!: BaseComponent["eventDetails"] & {
open: void
}
handler() {
this.fireDecoratorEvent("open");
// @ts-expect-error
this.fireDecoratorEvent("not-found");
}
}
Loading

0 comments on commit 4ff8ab7

Please sign in to comment.