From f3d9f586735f309e28367edec5d3729a75a3b6d6 Mon Sep 17 00:00:00 2001 From: cesarParra Date: Wed, 30 Oct 2024 17:06:21 -0400 Subject: [PATCH 1/9] Event bus store --- .gitignore | 1 + .../lwc/countChanger/countChanger.js-meta.xml | 4 +- .../lwc/countTracker/countTracker.js-meta.xml | 3 +- .../lwc/demoSignals/chat-data-source.js | 9 +++ .../demo-signals/lwc/demoSignals/counter.js | 2 +- .../lwc/demoSignals/demoSignals.js | 1 + .../controllers/classes/ChatController.cls | 9 +++ .../classes/ChatController.cls-meta.xml | 5 ++ .../lwc/chat/chat.html | 42 ++++++++++ .../lwc/chat/chat.js | 59 ++++++++++++++ .../lwc/chat/chat.js-meta.xml | 10 +++ .../lwc/serverFetcher/serverFetcher.js | 2 +- force-app/lwc/signals/use.js | 23 ++++++ src/global.d.ts | 79 +++++++++++++++++++ src/lwc/signals/use.ts | 30 +++++++ 15 files changed, 275 insertions(+), 4 deletions(-) create mode 100644 examples/demo-signals/lwc/demoSignals/chat-data-source.js create mode 100644 examples/real-time-through-event-bus/controllers/classes/ChatController.cls create mode 100644 examples/real-time-through-event-bus/controllers/classes/ChatController.cls-meta.xml create mode 100644 examples/real-time-through-event-bus/lwc/chat/chat.html create mode 100644 examples/real-time-through-event-bus/lwc/chat/chat.js create mode 100644 examples/real-time-through-event-bus/lwc/chat/chat.js-meta.xml create mode 100644 src/global.d.ts diff --git a/.gitignore b/.gitignore index 9fe7206..201514c 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,4 @@ $RECYCLE.BIN/ IlluminatedCloud/ .idea/ +/.localdev/ diff --git a/examples/counter/lwc/countChanger/countChanger.js-meta.xml b/examples/counter/lwc/countChanger/countChanger.js-meta.xml index c5a6356..0fdc56d 100644 --- a/examples/counter/lwc/countChanger/countChanger.js-meta.xml +++ b/examples/counter/lwc/countChanger/countChanger.js-meta.xml @@ -7,5 +7,7 @@ lightningCommunity__Default lightningCommunity__Page + lightning__HomePage + lightning__RecordPage - \ No newline at end of file + diff --git a/examples/counter/lwc/countTracker/countTracker.js-meta.xml b/examples/counter/lwc/countTracker/countTracker.js-meta.xml index e01574c..bc7ea48 100644 --- a/examples/counter/lwc/countTracker/countTracker.js-meta.xml +++ b/examples/counter/lwc/countTracker/countTracker.js-meta.xml @@ -7,5 +7,6 @@ lightningCommunity__Default lightningCommunity__Page + lightning__RecordPage - \ No newline at end of file + diff --git a/examples/demo-signals/lwc/demoSignals/chat-data-source.js b/examples/demo-signals/lwc/demoSignals/chat-data-source.js new file mode 100644 index 0000000..4776348 --- /dev/null +++ b/examples/demo-signals/lwc/demoSignals/chat-data-source.js @@ -0,0 +1,9 @@ +import { $signal, useEventBus } from "c/signals"; + +export const messageEvent = $signal(undefined, { + storage: useEventBus("/event/ChatMessage__e", (response) => ({ + message: response.Message__c, + sender: response.Sender__c, + time: response.Time__c + })) +}); diff --git a/examples/demo-signals/lwc/demoSignals/counter.js b/examples/demo-signals/lwc/demoSignals/counter.js index 931e145..3e9221e 100644 --- a/examples/demo-signals/lwc/demoSignals/counter.js +++ b/examples/demo-signals/lwc/demoSignals/counter.js @@ -1,4 +1,4 @@ -import { $signal, $effect, $computed, useLocalStorage, useEventListener } from "c/signals"; +import { $signal, $effect, $computed, useEventListener } from "c/signals"; // EXAMPLE OF DEFAULT COUNTER diff --git a/examples/demo-signals/lwc/demoSignals/demoSignals.js b/examples/demo-signals/lwc/demoSignals/demoSignals.js index e1fac1d..4917cf7 100644 --- a/examples/demo-signals/lwc/demoSignals/demoSignals.js +++ b/examples/demo-signals/lwc/demoSignals/demoSignals.js @@ -2,3 +2,4 @@ export * from "./counter"; export * from "./contact-info"; export * from "./apex-fetcher"; export * from "./shopping-cart"; +export * from "./chat-data-source"; diff --git a/examples/real-time-through-event-bus/controllers/classes/ChatController.cls b/examples/real-time-through-event-bus/controllers/classes/ChatController.cls new file mode 100644 index 0000000..22e2c70 --- /dev/null +++ b/examples/real-time-through-event-bus/controllers/classes/ChatController.cls @@ -0,0 +1,9 @@ +public with sharing class ChatController { + @AuraEnabled + public static void sendMessage(String message, String sender) { + EventBus.publish(new ChatMessage__e( + Message__c = message, + Sender__c = sender + )); + } +} diff --git a/examples/real-time-through-event-bus/controllers/classes/ChatController.cls-meta.xml b/examples/real-time-through-event-bus/controllers/classes/ChatController.cls-meta.xml new file mode 100644 index 0000000..df13efa --- /dev/null +++ b/examples/real-time-through-event-bus/controllers/classes/ChatController.cls-meta.xml @@ -0,0 +1,5 @@ + + + 60.0 + Active + diff --git a/examples/real-time-through-event-bus/lwc/chat/chat.html b/examples/real-time-through-event-bus/lwc/chat/chat.html new file mode 100644 index 0000000..306953c --- /dev/null +++ b/examples/real-time-through-event-bus/lwc/chat/chat.html @@ -0,0 +1,42 @@ + diff --git a/examples/real-time-through-event-bus/lwc/chat/chat.js b/examples/real-time-through-event-bus/lwc/chat/chat.js new file mode 100644 index 0000000..9e1e9d8 --- /dev/null +++ b/examples/real-time-through-event-bus/lwc/chat/chat.js @@ -0,0 +1,59 @@ +import { LightningElement } from "lwc"; +import { $effect } from "c/signals"; +import { messageEvent } from "c/demoSignals"; +import sendMessage from "@salesforce/apex/ChatController.sendMessage"; + +export default class Chat extends LightningElement { + sender = "User"; + message = ""; + + messages = []; + + connectedCallback() { + $effect(() => { + if (messageEvent.value) { + console.log("Message received in the component", messageEvent.value); + this.messages = [...this.messages, messageEvent.value]; + } + }); + } + + get formattedMessages() { + return this.messages.map((message) => { + return { + ...message, + listClasses: + message.sender === this.sender + ? "slds-chat-listitem slds-chat-listitem_outbound" + : "slds-chat-listitem slds-chat-listitem_inbound", + messageClasses: + message.sender === this.sender + ? "slds-chat-message__text slds-chat-message__text_outbound" + : "slds-chat-message__text slds-chat-message__text_inbound" + }; + }); + } + + handleNameChange(event) { + this.sender = event.detail.value; + } + + handleMessageChange(event) { + this.message = event.detail.value; + } + + get isMessageBoxDisabled() { + return !this.sender; + } + + get isSendDisabled() { + return !this.message || !this.sender; + } + + sendMessage() { + sendMessage({ + message: this.message, + sender: this.sender + }); + } +} diff --git a/examples/real-time-through-event-bus/lwc/chat/chat.js-meta.xml b/examples/real-time-through-event-bus/lwc/chat/chat.js-meta.xml new file mode 100644 index 0000000..034e3c7 --- /dev/null +++ b/examples/real-time-through-event-bus/lwc/chat/chat.js-meta.xml @@ -0,0 +1,10 @@ + + + 60.0 + Chat + true + Chat + + lightning__RecordPage + + diff --git a/examples/server-communication/lwc/serverFetcher/serverFetcher.js b/examples/server-communication/lwc/serverFetcher/serverFetcher.js index 4c2eb48..3ed29d6 100644 --- a/examples/server-communication/lwc/serverFetcher/serverFetcher.js +++ b/examples/server-communication/lwc/serverFetcher/serverFetcher.js @@ -4,4 +4,4 @@ import { fetchContacts } from "c/demoSignals"; export default class ServerFetcher extends LightningElement { contacts = $computed(() => (this.contacts = fetchContacts.value)).value; -} \ No newline at end of file +} diff --git a/force-app/lwc/signals/use.js b/force-app/lwc/signals/use.js index dd08143..4c3d02c 100644 --- a/force-app/lwc/signals/use.js +++ b/force-app/lwc/signals/use.js @@ -1,3 +1,4 @@ +import { subscribe } from "lightning/empApi"; export function createStorage(get, set, registerOnChange) { return { get, set, registerOnChange }; } @@ -104,3 +105,25 @@ export function useEventListener(type) { return createStorage(getter, setter, registerOnChange); }; } +// TODO: How to unsubscribe +export function useEventBus(channel, toValue) { + return function (value) { + let _value = value; + let _onChange; + subscribe(channel, -2, (response) => { + console.log("Received message", response); + _value = toValue(response?.data.payload); + _onChange?.(); + }); + function getter() { + return _value; + } + function setter(newValue) { + _value = newValue; + } + function registerOnChange(onChange) { + _onChange = onChange; + } + return createStorage(getter, setter, registerOnChange); + }; +} diff --git a/src/global.d.ts b/src/global.d.ts new file mode 100644 index 0000000..a522e49 --- /dev/null +++ b/src/global.d.ts @@ -0,0 +1,79 @@ +declare module "lightning/empApi" { + /** Response to a "subscribe" call. */ + export interface SubscribeResponse { + /** The value MUST be /meta/unsubscribe */ + channel: string; + /** A boolean indicating the success or failure of the subscribe */ + successful: boolean; + /** A channel name or a channel pattern or an array of channel names and channel patterns. */ + subscription: string; + /** A string with the description of the reason for the failure */ + error: string; + /** The client ID returned in the handshake response */ + clientId: string; + /** The same value as the request message ID */ + id: string; + } + + export interface Message> { + channel: string; + data: { + event: { + replayId: number; + }, + payload: T & { CreatedById: string; CreatedDate: string }, + schema: string; + }; + } + + /** + * Subscribes to a given channel and returns a promise that holds a subscription object, which you use to + * unsubscribe later. + * + * @param channel The channel name to subscribe to. + * @param replayId Indicates what point in the stream to replay events from. Specify -1 to get new events from the + * tip of the stream, -2 to replay from the last-saved event, or a specific event replay ID to get all saved and + * new events after that ID. + * @param onMessageCallback A callback function that's invoked for every event received. + */ + export function subscribe>(channel: string, replayId: number, onMessageCallback: (response?: Message) => void): Promise; + + /** Response to an "unsubscribe" call. */ + export interface UnsubscribeResponse { + /** The value MUST be /meta/unsubscribe */ + channel: string; + /** A boolean indicating the success or failure of the unsubscribe operation */ + successful: boolean; + /** A string with the description of the reason for the failure */ + error: string; + /** The client ID returned in the handshake response */ + clientId: string; + /** The same value as the request message ID */ + id: string; + } + + /** + * Unsubscribes from the channel using the given subscription object and returns a promise. The result of this + * operation is passed in to the callback function. The result object holds the successful boolean field which + * indicates whether the unsubscribe operation was successful. The result fields are based on the CometD protocol + * for the unsubscribe operation. + * + * @param subscription Subscription object that the subscribe call returned. + * @param callback A callback function that's called with a server response for the unsubscribe call. + */ + export function unsubscribe(subscription: object, callback?: (response?: unknown) => void): Promise; + + /** + * Registers a listener to errors that the server returns. + * + * @param callback A callback function that's called when an error response is received from the server for + * handshake, connect, subscribe, and unsubscribe meta channels. + */ + export function onError(callback: (error: unknown) => void): void; + + /** + * Returns a promise that holds a Boolean value. The value is true if the EmpJs Streaming API library can be used + * in this context; otherwise false. + */ + export function isEmpEnabled(): Promise; +} diff --git a/src/lwc/signals/use.ts b/src/lwc/signals/use.ts index fa21382..4bfe51c 100644 --- a/src/lwc/signals/use.ts +++ b/src/lwc/signals/use.ts @@ -1,3 +1,5 @@ +import { subscribe, type Message } from "lightning/empApi"; + export type State = { get: () => T; set: (newValue: T) => void; @@ -139,3 +141,31 @@ export function useEventListener( return createStorage(getter, setter, registerOnChange); }; } + +// TODO: How to unsubscribe +export function useEventBus(channel: string, toValue: (response?: unknown) => T) { + return function(value: T) { + let _value: T = value; + let _onChange: VoidFunction | undefined; + + subscribe(channel, -2, (response) => { + console.log("Received message", response); + _value = toValue(response?.data.payload); + _onChange?.(); + }); + + function getter() { + return _value; + } + + function setter(newValue: T) { + _value = newValue; + } + + function registerOnChange(onChange: VoidFunction) { + _onChange = onChange; + } + + return createStorage(getter, setter, registerOnChange); + } +} From db909ddcf6cc370637b2e8d494fa7a9d3f621734 Mon Sep 17 00:00:00 2001 From: cesarParra Date: Thu, 31 Oct 2024 06:36:47 -0400 Subject: [PATCH 2/9] Adding ChatMessage platform event to the examples folder --- .../ChatMessage__e/ChatMessage__e.object-meta.xml | 8 ++++++++ .../ChatMessage__e/fields/Message__c.field-meta.xml | 13 +++++++++++++ .../ChatMessage__e/fields/Sender__c.field-meta.xml | 13 +++++++++++++ .../ChatMessage__e/fields/Time__c.field-meta.xml | 12 ++++++++++++ 4 files changed, 46 insertions(+) create mode 100644 examples/main/default/objects/ChatMessage__e/ChatMessage__e.object-meta.xml create mode 100644 examples/main/default/objects/ChatMessage__e/fields/Message__c.field-meta.xml create mode 100644 examples/main/default/objects/ChatMessage__e/fields/Sender__c.field-meta.xml create mode 100644 examples/main/default/objects/ChatMessage__e/fields/Time__c.field-meta.xml diff --git a/examples/main/default/objects/ChatMessage__e/ChatMessage__e.object-meta.xml b/examples/main/default/objects/ChatMessage__e/ChatMessage__e.object-meta.xml new file mode 100644 index 0000000..705e337 --- /dev/null +++ b/examples/main/default/objects/ChatMessage__e/ChatMessage__e.object-meta.xml @@ -0,0 +1,8 @@ + + + Deployed + HighVolume + + ChatMessages + PublishImmediately + diff --git a/examples/main/default/objects/ChatMessage__e/fields/Message__c.field-meta.xml b/examples/main/default/objects/ChatMessage__e/fields/Message__c.field-meta.xml new file mode 100644 index 0000000..72065ab --- /dev/null +++ b/examples/main/default/objects/ChatMessage__e/fields/Message__c.field-meta.xml @@ -0,0 +1,13 @@ + + + Message__c + false + false + false + false + + 255 + true + Text + false + diff --git a/examples/main/default/objects/ChatMessage__e/fields/Sender__c.field-meta.xml b/examples/main/default/objects/ChatMessage__e/fields/Sender__c.field-meta.xml new file mode 100644 index 0000000..29f719a --- /dev/null +++ b/examples/main/default/objects/ChatMessage__e/fields/Sender__c.field-meta.xml @@ -0,0 +1,13 @@ + + + Sender__c + false + false + false + false + + 255 + true + Text + false + diff --git a/examples/main/default/objects/ChatMessage__e/fields/Time__c.field-meta.xml b/examples/main/default/objects/ChatMessage__e/fields/Time__c.field-meta.xml new file mode 100644 index 0000000..7bc9e89 --- /dev/null +++ b/examples/main/default/objects/ChatMessage__e/fields/Time__c.field-meta.xml @@ -0,0 +1,12 @@ + + + Time__c + NOW() + false + false + false + false + + true + DateTime + From 1ce0664a5769a1bb08f8f40df14a972a790b7cef Mon Sep 17 00:00:00 2001 From: cesarParra Date: Thu, 31 Oct 2024 07:10:48 -0400 Subject: [PATCH 3/9] Can unsubscribe --- .../lwc/demoSignals/chat-data-source.js | 14 +-- .../lwc/chat/chat.html | 85 ++++++++++--------- .../lwc/chat/chat.js | 6 ++ force-app/lwc/signals/use.js | 27 ++++-- src/global.d.ts | 22 +---- src/lwc/signals/use.ts | 30 +++++-- 6 files changed, 104 insertions(+), 80 deletions(-) diff --git a/examples/demo-signals/lwc/demoSignals/chat-data-source.js b/examples/demo-signals/lwc/demoSignals/chat-data-source.js index 4776348..93b8423 100644 --- a/examples/demo-signals/lwc/demoSignals/chat-data-source.js +++ b/examples/demo-signals/lwc/demoSignals/chat-data-source.js @@ -1,9 +1,13 @@ import { $signal, useEventBus } from "c/signals"; export const messageEvent = $signal(undefined, { - storage: useEventBus("/event/ChatMessage__e", (response) => ({ - message: response.Message__c, - sender: response.Sender__c, - time: response.Time__c - })) + storage: useEventBus( + "/event/ChatMessage__e", + ({ data }) => ({ + message: data.payload.Message__c, + sender: data.payload.Sender__c, + time: data.payload.Time__c + }), + -2 + ) }); diff --git a/examples/real-time-through-event-bus/lwc/chat/chat.html b/examples/real-time-through-event-bus/lwc/chat/chat.html index 306953c..8083b42 100644 --- a/examples/real-time-through-event-bus/lwc/chat/chat.html +++ b/examples/real-time-through-event-bus/lwc/chat/chat.html @@ -1,42 +1,49 @@ diff --git a/examples/real-time-through-event-bus/lwc/chat/chat.js b/examples/real-time-through-event-bus/lwc/chat/chat.js index 9e1e9d8..288954e 100644 --- a/examples/real-time-through-event-bus/lwc/chat/chat.js +++ b/examples/real-time-through-event-bus/lwc/chat/chat.js @@ -56,4 +56,10 @@ export default class Chat extends LightningElement { sender: this.sender }); } + + unsub() { + messageEvent.unsubscribe((res) => + console.log("Unsubscribed from message channel", res) + ); + } } diff --git a/force-app/lwc/signals/use.js b/force-app/lwc/signals/use.js index 4c3d02c..4605860 100644 --- a/force-app/lwc/signals/use.js +++ b/force-app/lwc/signals/use.js @@ -1,6 +1,6 @@ -import { subscribe } from "lightning/empApi"; -export function createStorage(get, set, registerOnChange) { - return { get, set, registerOnChange }; +import { subscribe, unsubscribe as empApiUnsubscribe } from "lightning/empApi"; +export function createStorage(get, set, registerOnChange, unsubscribe) { + return { get, set, registerOnChange, unsubscribe }; } export function useInMemoryStorage(value) { let _value = value; @@ -105,15 +105,21 @@ export function useEventListener(type) { return createStorage(getter, setter, registerOnChange); }; } -// TODO: How to unsubscribe -export function useEventBus(channel, toValue) { +// TODO: Document through JSDocs +// TODO: Document in README +// TODO: Pass a subscription callback +export function useEventBus(channel, toValue, replayId = -1) { return function (value) { let _value = value; let _onChange; - subscribe(channel, -2, (response) => { - console.log("Received message", response); - _value = toValue(response?.data.payload); + let subscription = {}; + // TODO: Check if the empApi is available + subscribe(channel, replayId, (response) => { + _value = toValue(response); _onChange?.(); + }).then((sub) => { + subscription = sub; + console.log(subscription); }); function getter() { return _value; @@ -124,6 +130,9 @@ export function useEventBus(channel, toValue) { function registerOnChange(onChange) { _onChange = onChange; } - return createStorage(getter, setter, registerOnChange); + function unsubscribe(callback) { + empApiUnsubscribe(subscription, callback); + } + return createStorage(getter, setter, registerOnChange, unsubscribe); }; } diff --git a/src/global.d.ts b/src/global.d.ts index a522e49..0a1ad41 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -1,28 +1,20 @@ declare module "lightning/empApi" { - /** Response to a "subscribe" call. */ export interface SubscribeResponse { - /** The value MUST be /meta/unsubscribe */ channel: string; - /** A boolean indicating the success or failure of the subscribe */ successful: boolean; - /** A channel name or a channel pattern or an array of channel names and channel patterns. */ subscription: string; - /** A string with the description of the reason for the failure */ error: string; - /** The client ID returned in the handshake response */ clientId: string; - /** The same value as the request message ID */ id: string; } - export interface Message> { + export interface Message { channel: string; data: { event: { replayId: number; }, - payload: T & { CreatedById: string; CreatedDate: string }, - schema: string; + payload: Record & { CreatedById: string; CreatedDate: string }, }; } @@ -36,19 +28,13 @@ declare module "lightning/empApi" { * new events after that ID. * @param onMessageCallback A callback function that's invoked for every event received. */ - export function subscribe>(channel: string, replayId: number, onMessageCallback: (response?: Message) => void): Promise; + export function subscribe(channel: string, replayId: number, onMessageCallback: (response?: Message) => void): Promise; - /** Response to an "unsubscribe" call. */ export interface UnsubscribeResponse { - /** The value MUST be /meta/unsubscribe */ channel: string; - /** A boolean indicating the success or failure of the unsubscribe operation */ successful: boolean; - /** A string with the description of the reason for the failure */ error: string; - /** The client ID returned in the handshake response */ clientId: string; - /** The same value as the request message ID */ id: string; } @@ -61,7 +47,7 @@ declare module "lightning/empApi" { * @param subscription Subscription object that the subscribe call returned. * @param callback A callback function that's called with a server response for the unsubscribe call. */ - export function unsubscribe(subscription: object, callback?: (response?: unknown) => void): Promise; + export function unsubscribe(subscription: object, callback?: (response?: UnsubscribeResponse) => void): Promise; /** * Registers a listener to errors that the server returns. diff --git a/src/lwc/signals/use.ts b/src/lwc/signals/use.ts index 4bfe51c..8123fef 100644 --- a/src/lwc/signals/use.ts +++ b/src/lwc/signals/use.ts @@ -1,17 +1,19 @@ -import { subscribe, type Message } from "lightning/empApi"; +import { Message, subscribe, unsubscribe as empApiUnsubscribe, UnsubscribeResponse } from "lightning/empApi"; export type State = { get: () => T; set: (newValue: T) => void; registerOnChange?: (f: VoidFunction) => void; + unsubscribe?: () => void; } export function createStorage( get: () => T, set: (newValue: T) => void, - registerOnChange?: (f: VoidFunction) => void + registerOnChange?: (f: VoidFunction) => void, + unsubscribe?: () => void ): State { - return { get, set, registerOnChange }; + return { get, set, registerOnChange, unsubscribe }; } export function useInMemoryStorage(value: T): State { @@ -142,16 +144,22 @@ export function useEventListener( }; } -// TODO: How to unsubscribe -export function useEventBus(channel: string, toValue: (response?: unknown) => T) { +// TODO: Document through JSDocs +// TODO: Document in README +// TODO: Pass a subscription callback +export function useEventBus(channel: string, toValue: (response?: Message) => T, replayId: number = -1) { return function(value: T) { let _value: T = value; let _onChange: VoidFunction | undefined; + let subscription = {}; - subscribe(channel, -2, (response) => { - console.log("Received message", response); - _value = toValue(response?.data.payload); + // TODO: Check if the empApi is available + subscribe(channel, replayId, (response?: Message) => { + _value = toValue(response); _onChange?.(); + }).then((sub) => { + subscription = sub; + console.log(subscription); }); function getter() { @@ -166,6 +174,10 @@ export function useEventBus(channel: string, toValue: (response?: unknown) => _onChange = onChange; } - return createStorage(getter, setter, registerOnChange); + function unsubscribe(callback?: (response?: UnsubscribeResponse) => void) { + empApiUnsubscribe(subscription, callback); + } + + return createStorage(getter, setter, registerOnChange, unsubscribe); } } From 24d8e0fe261d5e4f7f9fa8eafbc8183fdf803e9c Mon Sep 17 00:00:00 2001 From: cesarParra Date: Thu, 31 Oct 2024 07:19:38 -0400 Subject: [PATCH 4/9] Can pass a callback on subscribe --- .../lwc/demoSignals/chat-data-source.js | 5 ++- force-app/lwc/signals/use.js | 31 ++++++++++----- src/lwc/signals/use.ts | 38 +++++++++++++------ 3 files changed, 52 insertions(+), 22 deletions(-) diff --git a/examples/demo-signals/lwc/demoSignals/chat-data-source.js b/examples/demo-signals/lwc/demoSignals/chat-data-source.js index 93b8423..17203ef 100644 --- a/examples/demo-signals/lwc/demoSignals/chat-data-source.js +++ b/examples/demo-signals/lwc/demoSignals/chat-data-source.js @@ -8,6 +8,9 @@ export const messageEvent = $signal(undefined, { sender: data.payload.Sender__c, time: data.payload.Time__c }), - -2 + { + replayId: -2, + onSubscribe: (message) => console.log("Subscribed to message", message) + } ) }); diff --git a/force-app/lwc/signals/use.js b/force-app/lwc/signals/use.js index 4605860..f7379ed 100644 --- a/force-app/lwc/signals/use.js +++ b/force-app/lwc/signals/use.js @@ -1,4 +1,8 @@ -import { subscribe, unsubscribe as empApiUnsubscribe } from "lightning/empApi"; +import { + subscribe, + unsubscribe as empApiUnsubscribe, + isEmpEnabled +} from "lightning/empApi"; export function createStorage(get, set, registerOnChange, unsubscribe) { return { get, set, registerOnChange, unsubscribe }; } @@ -107,19 +111,26 @@ export function useEventListener(type) { } // TODO: Document through JSDocs // TODO: Document in README -// TODO: Pass a subscription callback -export function useEventBus(channel, toValue, replayId = -1) { +export function useEventBus(channel, toValue, options) { return function (value) { let _value = value; let _onChange; let subscription = {}; - // TODO: Check if the empApi is available - subscribe(channel, replayId, (response) => { - _value = toValue(response); - _onChange?.(); - }).then((sub) => { - subscription = sub; - console.log(subscription); + const replayId = options?.replayId ?? -1; + isEmpEnabled().then((enabled) => { + if (!enabled) { + console.error( + `EMP API is not enabled, cannot subscribe to channel ${channel}` + ); + return; + } + subscribe(channel, replayId, (response) => { + _value = toValue(response); + _onChange?.(); + }).then((sub) => { + subscription = sub; + options?.onSubscribe?.(sub); + }); }); function getter() { return _value; diff --git a/src/lwc/signals/use.ts b/src/lwc/signals/use.ts index 8123fef..731e2f2 100644 --- a/src/lwc/signals/use.ts +++ b/src/lwc/signals/use.ts @@ -1,4 +1,10 @@ -import { Message, subscribe, unsubscribe as empApiUnsubscribe, UnsubscribeResponse } from "lightning/empApi"; +import { + Message, + subscribe, + unsubscribe as empApiUnsubscribe, + UnsubscribeResponse, + isEmpEnabled, SubscribeResponse +} from "lightning/empApi"; export type State = { get: () => T; @@ -146,20 +152,30 @@ export function useEventListener( // TODO: Document through JSDocs // TODO: Document in README -// TODO: Pass a subscription callback -export function useEventBus(channel: string, toValue: (response?: Message) => T, replayId: number = -1) { +export function useEventBus(channel: string, toValue: (response?: Message) => T, options?: { + replayId?: number, + onSubscribe?: (response: SubscribeResponse) => void, +}) { return function(value: T) { let _value: T = value; let _onChange: VoidFunction | undefined; let subscription = {}; - // TODO: Check if the empApi is available - subscribe(channel, replayId, (response?: Message) => { - _value = toValue(response); - _onChange?.(); - }).then((sub) => { - subscription = sub; - console.log(subscription); + const replayId = options?.replayId ?? -1; + + isEmpEnabled().then((enabled) => { + if (!enabled) { + console.error(`EMP API is not enabled, cannot subscribe to channel ${channel}`); + return; + } + + subscribe(channel, replayId, (response?: Message) => { + _value = toValue(response); + _onChange?.(); + }).then((sub) => { + subscription = sub; + options?.onSubscribe?.(sub); + }); }); function getter() { @@ -179,5 +195,5 @@ export function useEventBus(channel: string, toValue: (response?: Message) => } return createStorage(getter, setter, registerOnChange, unsubscribe); - } + }; } From f58f4de7c4a53d5c020131ce121a5575c03e57a6 Mon Sep 17 00:00:00 2001 From: cesarParra Date: Thu, 31 Oct 2024 09:29:20 -0400 Subject: [PATCH 5/9] JSdocs --- force-app/lwc/signals/use.js | 49 ++++++++++++++++++++++++++++++++-- src/lwc/signals/use.ts | 51 ++++++++++++++++++++++++++++++++++-- 2 files changed, 96 insertions(+), 4 deletions(-) diff --git a/force-app/lwc/signals/use.js b/force-app/lwc/signals/use.js index f7379ed..6fcb424 100644 --- a/force-app/lwc/signals/use.js +++ b/force-app/lwc/signals/use.js @@ -1,7 +1,8 @@ import { subscribe, unsubscribe as empApiUnsubscribe, - isEmpEnabled + isEmpEnabled, + onError as empApiOnError } from "lightning/empApi"; export function createStorage(get, set, registerOnChange, unsubscribe) { return { get, set, registerOnChange, unsubscribe }; @@ -109,8 +110,49 @@ export function useEventListener(type) { return createStorage(getter, setter, registerOnChange); }; } -// TODO: Document through JSDocs // TODO: Document in README +/** + * Subscribes to the event bus channel (e.g. platform event, change data capture, etc.). + * Usage: + * Pass to the `storage` option of a signal, e.g.: + * ```javascript + * import { $signal, useEventBus } from "c/signals"; + * export const receivedEvent = $signal(undefined, { + * storage: useEventBus( + * "/event/PlatEvent__e", + * ({ data }) => ({ + * message: data.payload.Message__c, + * sender: data.payload.Sender__c, + * time: data.payload.Time__c + * }) + * ) + * }); + * ``` + * @param channel The event bus channel to subscribe to. + * @param toValue A function that converts the received message to the desired value. + * The passed in argument will be the message received from the event bus, which + * is of the following shape: + * ``` + * { + * channel: string; + * data: { + * event: { + * replayId: number; + * }, + * payload: Record & { CreatedById: string; CreatedDate: string }, + * }; + * } + * ``` + * + * The `payload` will contain the actual data of the event. For example, + * if using a platform event, this will contain the fields of the platform event. + * @param options (Optional) Additional options. + * @param options.replayId (Optional) The replay ID to start from. Defaults to -1. + * When -2 is passed, it will replay from the last saved event. + * @param options.onSubscribe (Optional) A callback function that's called when the subscription is successful. + * @param options.onError (Optional) A callback function that's called when an error response is received from the server for + * handshake, connect, subscribe, and unsubscribe meta channels. + */ export function useEventBus(channel, toValue, options) { return function (value) { let _value = value; @@ -131,6 +173,9 @@ export function useEventBus(channel, toValue, options) { subscription = sub; options?.onSubscribe?.(sub); }); + empApiOnError((error) => { + options?.onError?.(error); + }); }); function getter() { return _value; diff --git a/src/lwc/signals/use.ts b/src/lwc/signals/use.ts index 731e2f2..731c628 100644 --- a/src/lwc/signals/use.ts +++ b/src/lwc/signals/use.ts @@ -3,7 +3,8 @@ import { subscribe, unsubscribe as empApiUnsubscribe, UnsubscribeResponse, - isEmpEnabled, SubscribeResponse + isEmpEnabled, SubscribeResponse, + onError as empApiOnError } from "lightning/empApi"; export type State = { @@ -150,11 +151,53 @@ export function useEventListener( }; } -// TODO: Document through JSDocs // TODO: Document in README +/** + * Subscribes to the event bus channel (e.g. platform event, change data capture, etc.). + * Usage: + * Pass to the `storage` option of a signal, e.g.: + * ```javascript + * import { $signal, useEventBus } from "c/signals"; + * export const receivedEvent = $signal(undefined, { + * storage: useEventBus( + * "/event/PlatEvent__e", + * ({ data }) => ({ + * message: data.payload.Message__c, + * sender: data.payload.Sender__c, + * time: data.payload.Time__c + * }) + * ) + * }); + * ``` + * @param channel The event bus channel to subscribe to. + * @param toValue A function that converts the received message to the desired value. + * The passed in argument will be the message received from the event bus, which + * is of the following shape: + * ``` + * { + * channel: string; + * data: { + * event: { + * replayId: number; + * }, + * payload: Record & { CreatedById: string; CreatedDate: string }, + * }; + * } + * ``` + * + * The `payload` will contain the actual data of the event. For example, + * if using a platform event, this will contain the fields of the platform event. + * @param options (Optional) Additional options. + * @param options.replayId (Optional) The replay ID to start from. Defaults to -1. + * When -2 is passed, it will replay from the last saved event. + * @param options.onSubscribe (Optional) A callback function that's called when the subscription is successful. + * @param options.onError (Optional) A callback function that's called when an error response is received from the server for + * handshake, connect, subscribe, and unsubscribe meta channels. + */ export function useEventBus(channel: string, toValue: (response?: Message) => T, options?: { replayId?: number, onSubscribe?: (response: SubscribeResponse) => void, + onError?: (error: unknown) => void }) { return function(value: T) { let _value: T = value; @@ -176,6 +219,10 @@ export function useEventBus(channel: string, toValue: (response?: Message) => subscription = sub; options?.onSubscribe?.(sub); }); + + empApiOnError((error) => { + options?.onError?.(error); + }); }); function getter() { From 82b0b2262754e81ef2aef7af52945909b9d8d634 Mon Sep 17 00:00:00 2001 From: cesarParra Date: Thu, 31 Oct 2024 09:43:48 -0400 Subject: [PATCH 6/9] Docs --- README.md | 44 +++++++++++++++++++++++++++++++++++- force-app/lwc/signals/use.js | 1 - src/lwc/signals/use.ts | 1 - 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 7bbc674..e6ff63b 100644 --- a/README.md +++ b/README.md @@ -720,7 +720,8 @@ The following storage helpers are available by default: - `useEventListener(eventName: string)`: Dispatches a CustomEvent to the `window` object with the given event name whenever the signal changes. It also listens for events with the given name and updates the signal when the event is received. This is useful for when you want to communicate changes to components that for some reason don't - have access to the signal (for example, a component that cannot import the signal because it lives in a different namespace). + have access to the signal (for example, a component that cannot import the signal because it lives in a different + namespace). The event sent and expected to be received has the following format: @@ -754,6 +755,47 @@ The following storage helpers are available by default: } ``` +- `useEventBus(channel: string, toValue: (response?: object) => T, options: object)`: Subscribes to the event bus + channel (e.g. platform event, change data capture, etc.). + + - The `channel` parameter is the event bus channel to subscribe to. + - The `toValue` function is used to convert the response from the event bus to the desired value. + + ```javascript + import { $signal, useEventBus } from "c/signals"; + export const receivedEvent = $signal(undefined, { + storage: useEventBus("/event/PlatEvent__e", ({ data }) => ({ + message: data.payload.Message__c, + sender: data.payload.Sender__c, + time: data.payload.Time__c + })) + }); + ``` + + The passed in argument will be the message received from the event bus, which + is of the following shape: + + ```javascript + { + channel: string; + data: { + event: { + replayId: number; + }, + payload: object, + }; + } + ``` + + The `payload` key will contain the actual data of the event. For example, + if using a platform event, this will contain the fields of the platform event. + + - The `options` (optional) parameter is an object that can contain the following properties (all of them optional): + - `replayId` The replay ID to start from, defaults to -1. When -2 is passed, it will replay from the last saved event. + - `onSubscribe` A callback function called when the subscription is successful. + - `onError` A callback function called when an error response is received from the server for + handshake, connect, subscribe, and unsubscribe meta channels. + ### Creating a custom storage The `storage` option receives a function that defines the behavior for where the data should be stored. diff --git a/force-app/lwc/signals/use.js b/force-app/lwc/signals/use.js index 6fcb424..ab329d0 100644 --- a/force-app/lwc/signals/use.js +++ b/force-app/lwc/signals/use.js @@ -110,7 +110,6 @@ export function useEventListener(type) { return createStorage(getter, setter, registerOnChange); }; } -// TODO: Document in README /** * Subscribes to the event bus channel (e.g. platform event, change data capture, etc.). * Usage: diff --git a/src/lwc/signals/use.ts b/src/lwc/signals/use.ts index 731c628..d8284ad 100644 --- a/src/lwc/signals/use.ts +++ b/src/lwc/signals/use.ts @@ -151,7 +151,6 @@ export function useEventListener( }; } -// TODO: Document in README /** * Subscribes to the event bus channel (e.g. platform event, change data capture, etc.). * Usage: From 5f3850c781c08879d3eb20e70fbd470f4ecf65e0 Mon Sep 17 00:00:00 2001 From: cesarParra Date: Thu, 31 Oct 2024 11:36:32 -0400 Subject: [PATCH 7/9] Returning promise from unsubscribe --- force-app/lwc/signals/use.js | 2 +- src/lwc/signals/use.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/force-app/lwc/signals/use.js b/force-app/lwc/signals/use.js index ab329d0..42db942 100644 --- a/force-app/lwc/signals/use.js +++ b/force-app/lwc/signals/use.js @@ -186,7 +186,7 @@ export function useEventBus(channel, toValue, options) { _onChange = onChange; } function unsubscribe(callback) { - empApiUnsubscribe(subscription, callback); + return empApiUnsubscribe(subscription, callback); } return createStorage(getter, setter, registerOnChange, unsubscribe); }; diff --git a/src/lwc/signals/use.ts b/src/lwc/signals/use.ts index d8284ad..71812d1 100644 --- a/src/lwc/signals/use.ts +++ b/src/lwc/signals/use.ts @@ -237,7 +237,7 @@ export function useEventBus(channel: string, toValue: (response?: Message) => } function unsubscribe(callback?: (response?: UnsubscribeResponse) => void) { - empApiUnsubscribe(subscription, callback); + return empApiUnsubscribe(subscription, callback); } return createStorage(getter, setter, registerOnChange, unsubscribe); From 352c818f627809a2037ea2f89d7baffd38ab8c8d Mon Sep 17 00:00:00 2001 From: cesarParra Date: Thu, 31 Oct 2024 12:12:35 -0400 Subject: [PATCH 8/9] Test --- jest.config.ts | 3 +- src/__mocks__/lightning/empApi.ts | 25 +++++++++++++++ src/lwc/signals/__tests__/signals.test.ts | 37 ++++++++++++++++++++--- tsconfig.json | 2 +- 4 files changed, 61 insertions(+), 6 deletions(-) create mode 100644 src/__mocks__/lightning/empApi.ts diff --git a/jest.config.ts b/jest.config.ts index a8c0c61..7e0697e 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -12,7 +12,8 @@ const config: JestConfigWithTsJest = { }, extensionsToTreatAsEsm: [".ts"], moduleNameMapper: { - "^(\\.{1,2}/.*)\\.js$": "$1" + "^(\\.{1,2}/.*)\\.js$": "$1", + "lightning/empApi": "/src/__mocks__/lightning/empApi.ts", }, testEnvironment: "jsdom", }; diff --git a/src/__mocks__/lightning/empApi.ts b/src/__mocks__/lightning/empApi.ts new file mode 100644 index 0000000..4234a58 --- /dev/null +++ b/src/__mocks__/lightning/empApi.ts @@ -0,0 +1,25 @@ +const _channels: Record void }> = {}; + +export const subscribe = jest.fn((channel: string, replayId: number, onMessageCallback: (response?: unknown) => void) => { + _channels[channel] = { onMessageCallback }; + return Promise.resolve({ + id: "_" + Date.now(), + channel: channel, + replayId: replayId + }); +}); + +// A Jest-specific function for "publishing" your Platform Event +export const jestMockPublish = jest.fn((channel, message) => { + if ( + _channels[channel] && + _channels[channel].onMessageCallback instanceof Function + ) { + _channels[channel].onMessageCallback(message); + } + return Promise.resolve(true); +}); + +export const unsubscribe = jest.fn().mockResolvedValue({}); +export const onError = jest.fn().mockResolvedValue(jest.fn()); +export const isEmpEnabled = jest.fn().mockResolvedValue(true); diff --git a/src/lwc/signals/__tests__/signals.test.ts b/src/lwc/signals/__tests__/signals.test.ts index a293075..0985b30 100644 --- a/src/lwc/signals/__tests__/signals.test.ts +++ b/src/lwc/signals/__tests__/signals.test.ts @@ -1,5 +1,6 @@ import { $signal, $computed, $effect, $resource, Signal } from "../core"; -import { createStorage, useCookies, useLocalStorage, useSessionStorage } from "../use"; +import { createStorage, useCookies, useEventBus, useLocalStorage, useSessionStorage } from "../use"; +import { jestMockPublish } from "../../../__mocks__/lightning/empApi"; describe("signals", () => { describe("core functionality", () => { @@ -14,7 +15,7 @@ describe("signals", () => { expect(signal.value).toBe(1); }); - test('can debounce setting a signal value', async () => { + test("can debounce setting a signal value", async () => { const debouncedSignal = $signal(0, { debounce: 100 }); @@ -380,8 +381,8 @@ describe("signals", () => { const source = $signal("changed"); const { data: resource } = $resource(asyncFunction, () => ({ - source: source.value - }), + source: source.value + }), { initialValue: "initial", fetchWhen: () => false @@ -570,3 +571,31 @@ describe("storing values in cookies", () => { expect(signal.value).toBe(1); }); }); + +describe("when receiving a value from the empApi", () => { + it("should update the signal when the message is received", async () => { + function handleEvent(event?: { data: { payload: Record } }) { + return event?.data.payload.Message__c ?? ""; + } + + const signal = $signal("", { + storage: useEventBus("/event/TestChannel__e", handleEvent) + }); + + await new Promise(process.nextTick); + + expect(signal.value).toBe(""); + + await jestMockPublish("/event/TestChannel__e", { + data: { + payload: { + Message__c: "Hello World!" + } + } + }); + + await new Promise(process.nextTick); + + expect(signal.value).toBe("Hello World!"); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 0464ec8..2d0ac48 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,5 +9,5 @@ "forceConsistentCasingInFileNames": true, "moduleResolution": "Bundler" }, - "exclude": ["node_modules", "./*.ts", "src/**/*.test.ts"] + "exclude": ["node_modules", "./*.ts", "src/**/*.test.ts", "**/__mocks__/*"] } From 27ec70137a11c9f66502b8a2554e0f2d0b0525da Mon Sep 17 00:00:00 2001 From: cesarParra Date: Thu, 31 Oct 2024 12:15:16 -0400 Subject: [PATCH 9/9] Unsubscribing docs --- README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/README.md b/README.md index e6ff63b..872f6cd 100644 --- a/README.md +++ b/README.md @@ -796,6 +796,26 @@ The following storage helpers are available by default: - `onError` A callback function called when an error response is received from the server for handshake, connect, subscribe, and unsubscribe meta channels. + **Unsubscribing from the event bus** + + When using the `useEventBus` storage, the signal will hold a special function called `unsubscribe` that you can call + to unsubscribe from the event bus. + + ```javascript + import { $signal, useEventBus } from "c/signals"; + + const receivedEvent = $signal(undefined, { + storage: useEventBus("/event/PlatEvent__e", ({ data }) => ({ + message: data.payload.Message__c, + sender: data.payload.Sender__c, + time: data.payload.Time__c + })) + }); + + // Unsubscribe from the event bus + receivedEvent.unsubscribe(); + ``` + ### Creating a custom storage The `storage` option receives a function that defines the behavior for where the data should be stored.