From 052c0ccad6ad882cfb532cb9de367157bc18a0e3 Mon Sep 17 00:00:00 2001 From: Pavel Mikhalkevich Date: Sun, 12 Nov 2023 19:21:21 +0500 Subject: [PATCH 1/9] feat: add 'deriveOptions' to useChannel args --- .../react-hooks/src/AblyReactHooks.ts | 1 + src/platform/react-hooks/src/fakes/ably.ts | 5 ++ .../react-hooks/src/hooks/useChannel.test.tsx | 65 +++++++++++++++++++ .../react-hooks/src/hooks/useChannel.ts | 19 ++++-- 4 files changed, 85 insertions(+), 5 deletions(-) diff --git a/src/platform/react-hooks/src/AblyReactHooks.ts b/src/platform/react-hooks/src/AblyReactHooks.ts index 2742f29a7e..0b31381c87 100644 --- a/src/platform/react-hooks/src/AblyReactHooks.ts +++ b/src/platform/react-hooks/src/AblyReactHooks.ts @@ -3,6 +3,7 @@ import { Types } from '../../../../ably.js'; export type ChannelNameAndOptions = { channelName: string; options?: Types.ChannelOptions; + deriveOptions?: Types.DeriveOptions; id?: string; subscribeOnly?: boolean; skip?: boolean; diff --git a/src/platform/react-hooks/src/fakes/ably.ts b/src/platform/react-hooks/src/fakes/ably.ts index a94cf735f1..0823fe23b8 100644 --- a/src/platform/react-hooks/src/fakes/ably.ts +++ b/src/platform/react-hooks/src/fakes/ably.ts @@ -107,6 +107,11 @@ export class ClientChannelsCollection { return channelConnection; } } + + public getDerived(name: string, options: Types.DeriveOptions): ClientSingleChannelConnection { + options; + return this.get(name); + } } export class ClientSingleChannelConnection extends EventEmitter { diff --git a/src/platform/react-hooks/src/hooks/useChannel.test.tsx b/src/platform/react-hooks/src/hooks/useChannel.test.tsx index b499c05158..63652d06b7 100644 --- a/src/platform/react-hooks/src/hooks/useChannel.test.tsx +++ b/src/platform/react-hooks/src/hooks/useChannel.test.tsx @@ -176,6 +176,59 @@ describe('useChannel', () => { }); }); +describe('useChannel with deriveOptions', () => { + let channels: FakeAblyChannels; + let ablyClient: FakeAblySdk; + let anotherClient: FakeAblySdk; + + const Channels = { + tasks: 'tasks', + alerts: 'alerts', + }; + beforeEach(() => { + channels = new FakeAblyChannels([Channels.tasks, Channels.alerts]); + ablyClient = new FakeAblySdk().connectTo(channels); + anotherClient = new FakeAblySdk().connectTo(channels); + }); + + it('component updates when new message arrives', async () => { + renderInCtxProvider( + ablyClient, + + ); + await act(async () => { + await anotherClient.channels + .get('tasks') + .publish({ text: 'A new task for you', extras: { headers: { user: 'robert.pike@domain.io' } } }); + }); + + const messageUl = screen.getAllByRole('derived-channel-messages')[0]; + expect(messageUl.childElementCount).toBe(1); + expect(messageUl.children[0].innerHTML).toBe('A new task for you'); + }); + + it('component will not update if message filtered out', async () => { + renderInCtxProvider( + ablyClient, + + ); + await act(async () => { + await anotherClient.channels + .get('tasks') + .publish({ text: 'This one is for another Rob', extras: { headers: { user: 'robert.griesemer@domain.io' } } }); + }); + + const messageUl = screen.getAllByRole('derived-channel-messages')[0]; + expect(messageUl.childElementCount).toBe(0); + }); +}); + const UseChannelComponentMultipleClients = () => { const [messages, updateMessages] = useState([]); useChannel({ channelName: 'blah' }, (message) => { @@ -201,6 +254,18 @@ const UseChannelComponent = ({ skip }: { skip?: boolean }) => { return ; }; +const UseDerivedChannelComponent = ({ channelName, deriveOptions }) => { + const [messages, setMessages] = useState([]); + + useChannel({ channelName, deriveOptions }, (message) => { + setMessages((prev) => [...prev, message]); + }); + + const messagePreviews = messages.map((msg, index) =>
  • {msg.data.text}
  • ); + + return
      {messagePreviews}
    ; +}; + interface UseChannelStateErrorsComponentProps { onConnectionError?: (err: Types.ErrorInfo) => unknown; onChannelError?: (err: Types.ErrorInfo) => unknown; diff --git a/src/platform/react-hooks/src/hooks/useChannel.ts b/src/platform/react-hooks/src/hooks/useChannel.ts index d747281a7d..2321f5e3f7 100644 --- a/src/platform/react-hooks/src/hooks/useChannel.ts +++ b/src/platform/react-hooks/src/hooks/useChannel.ts @@ -37,18 +37,23 @@ export function useChannel( const ably = useAbly(channelHookOptions.id); - const { channelName, options: channelOptions, skip } = channelHookOptions; + const { channelName, options: channelOptions, deriveOptions, skip } = channelHookOptions; const channelEvent = typeof eventOrCallback === 'string' ? eventOrCallback : null; const ablyMessageCallback = typeof eventOrCallback === 'string' ? callback : eventOrCallback; + const deriveOptionsRef = useRef(deriveOptions); const channelOptionsRef = useRef(channelOptions); const ablyMessageCallbackRef = useRef(ablyMessageCallback); - const channel = useMemo( - () => ably.channels.get(channelName, channelOptionsWithAgent(channelOptionsRef.current)), - [ably, channelName] - ); + const channel = useMemo(() => { + const derived = deriveOptionsRef.current; + const withAgent = channelOptionsWithAgent(channelOptionsRef.current); + const channel = derived + ? ably.channels.getDerived(channelName, derived, withAgent) + : ably.channels.get(channelName, withAgent); + return channel; + }, [ably, channelName]); const { connectionError, channelError } = useStateErrors(channelHookOptions); @@ -59,6 +64,10 @@ export function useChannel( channelOptionsRef.current = channelOptions; }, [channel, channelOptions]); + useEffect(() => { + deriveOptionsRef.current = deriveOptions; + }, [deriveOptions]); + useEffect(() => { ablyMessageCallbackRef.current = ablyMessageCallback; }, [ablyMessageCallback]); From c910d7cc8357d92ea1f242cb21703efa26c1be4a Mon Sep 17 00:00:00 2001 From: Pavel Mikhalkevich Date: Sun, 12 Nov 2023 20:36:38 +0500 Subject: [PATCH 2/9] feat: adjust 'Channel' class in 'fake' ably --- src/platform/react-hooks/src/fakes/ably.ts | 46 +++++++++++++++-- .../react-hooks/src/hooks/useChannel.test.tsx | 51 ++++++++++++++++++- 2 files changed, 90 insertions(+), 7 deletions(-) diff --git a/src/platform/react-hooks/src/fakes/ably.ts b/src/platform/react-hooks/src/fakes/ably.ts index 0823fe23b8..6c298bfe9e 100644 --- a/src/platform/react-hooks/src/fakes/ably.ts +++ b/src/platform/react-hooks/src/fakes/ably.ts @@ -1,4 +1,5 @@ import { Types } from 'ably'; +import { search } from 'jmespath'; export class FakeAblySdk { public clientId: string; @@ -89,7 +90,7 @@ class Connection extends EventEmitter { export class ClientChannelsCollection { private client: FakeAblySdk; private channels: FakeAblyChannels; - private _channelConnections: Map; + private _channelConnections: Map; constructor(client: FakeAblySdk, channels: FakeAblyChannels) { this.client = client; @@ -108,9 +109,14 @@ export class ClientChannelsCollection { } } - public getDerived(name: string, options: Types.DeriveOptions): ClientSingleChannelConnection { - options; - return this.get(name); + public getDerived(name: string, options: Types.DeriveOptions): ClientSingleDerivedChannelConnection { + let channelConnection = this._channelConnections.get(name); + if (channelConnection) return channelConnection as ClientSingleDerivedChannelConnection; + + const channel = this.channels.get(name); + channelConnection = new ClientSingleDerivedChannelConnection(this.client, channel, options); + this._channelConnections.set(name, channelConnection); + return channelConnection; } } @@ -152,6 +158,32 @@ export class ClientSingleChannelConnection extends EventEmitter { } } +export class ClientSingleDerivedChannelConnection extends EventEmitter { + private client: FakeAblySdk; + private channel: Channel; + private deriveOpts: Types.DeriveOptions; + + constructor(client: FakeAblySdk, channel: Channel, deriveOptions?: Types.DeriveOptions) { + super(); + this.client = client; + this.channel = channel; + this.deriveOpts = deriveOptions; + } + + public async subscribe( + eventOrCallback: Types.messageCallback | string | Array, + listener?: Types.messageCallback + ) { + if (typeof eventOrCallback === 'function') eventOrCallback.deriveOptions = this.deriveOpts; + if (typeof listener === 'function') listener.deriveOpts = this.deriveOpts; + this.channel.subscribe(this.client.clientId, eventOrCallback, listener); + } + + public unsubscribe() { + this.channel.subscriptionsPerClient.delete(this.client.clientId); + } +} + export class ClientPresenceConnection { private client: FakeAblySdk; private presence: ChannelPresence; @@ -251,7 +283,11 @@ export class Channel { } for (const subscription of subs) { - subscription(messageEnvelope); + const filter = subscription.deriveOptions?.filter; + if (!filter) return subscription(messageEnvelope); + const headers = messageEnvelope.data?.extras?.headers; + const found = search({ headers }, filter); + if (found) subscription(messageEnvelope); } } diff --git a/src/platform/react-hooks/src/hooks/useChannel.test.tsx b/src/platform/react-hooks/src/hooks/useChannel.test.tsx index 63652d06b7..813a4f06fc 100644 --- a/src/platform/react-hooks/src/hooks/useChannel.test.tsx +++ b/src/platform/react-hooks/src/hooks/useChannel.test.tsx @@ -191,12 +191,25 @@ describe('useChannel with deriveOptions', () => { anotherClient = new FakeAblySdk().connectTo(channels); }); + it('component can use "useChannel" with "deriveOptions" and renders nothing by default', async () => { + renderInCtxProvider( + ablyClient, + + ); + const messageUl = screen.getAllByRole('derived-channel-messages')[0]; + + expect(messageUl.childElementCount).toBe(0); + }); + it('component updates when new message arrives', async () => { renderInCtxProvider( ablyClient, ); await act(async () => { @@ -215,7 +228,7 @@ describe('useChannel with deriveOptions', () => { ablyClient, ); await act(async () => { @@ -227,6 +240,40 @@ describe('useChannel with deriveOptions', () => { const messageUl = screen.getAllByRole('derived-channel-messages')[0]; expect(messageUl.childElementCount).toBe(0); }); + + it('component will update if some messages qualify', async () => { + renderInCtxProvider( + ablyClient, + + ); + await act(async () => { + const channel = anotherClient.channels.get('tasks'); + await channel.publish({ + text: 'This one is for another Rob', + extras: { headers: { user: 'robert.griesemer@domain.io' } }, + }); + await channel.publish({ + text: 'This one is for the whole domain', + extras: { headers: { company: 'domain' } }, + }); + await channel.publish({ + text: 'This one is for Ken', + extras: { headers: { user: 'ken.thompson@domain.io' } }, + }); + await channel.publish({ + text: 'This one is also a domain-wide fan-out', + extras: { headers: { company: 'domain' } }, + }); + }); + + const messageUl = screen.getAllByRole('derived-channel-messages')[0]; + expect(messageUl.childElementCount).toBe(2); + expect(messageUl.children[0].innerHTML).toBe('This one is for the whole domain'); + expect(messageUl.children[1].innerHTML).toBe('This one is also a domain-wide fan-out'); + }); }); const UseChannelComponentMultipleClients = () => { From cf939ad22a4f67d00d36186feb9cf222424679b4 Mon Sep 17 00:00:00 2001 From: Pavel Mikhalkevich Date: Sun, 12 Nov 2023 21:30:47 +0500 Subject: [PATCH 3/9] feat: add test from multiple clients --- .../react-hooks/src/hooks/useChannel.test.tsx | 84 +++++++++++++++---- 1 file changed, 70 insertions(+), 14 deletions(-) diff --git a/src/platform/react-hooks/src/hooks/useChannel.test.tsx b/src/platform/react-hooks/src/hooks/useChannel.test.tsx index 813a4f06fc..090a7f96ce 100644 --- a/src/platform/react-hooks/src/hooks/useChannel.test.tsx +++ b/src/platform/react-hooks/src/hooks/useChannel.test.tsx @@ -177,27 +177,27 @@ describe('useChannel', () => { }); describe('useChannel with deriveOptions', () => { - let channels: FakeAblyChannels; - let ablyClient: FakeAblySdk; - let anotherClient: FakeAblySdk; - const Channels = { tasks: 'tasks', alerts: 'alerts', }; + + let channels: FakeAblyChannels; + let ablyClient: FakeAblySdk; + let anotherClient: FakeAblySdk; + let yetAnotherClient: FakeAblySdk; + beforeEach(() => { channels = new FakeAblyChannels([Channels.tasks, Channels.alerts]); ablyClient = new FakeAblySdk().connectTo(channels); anotherClient = new FakeAblySdk().connectTo(channels); + yetAnotherClient = new FakeAblySdk().connectTo(channels); }); it('component can use "useChannel" with "deriveOptions" and renders nothing by default', async () => { renderInCtxProvider( ablyClient, - + ); const messageUl = screen.getAllByRole('derived-channel-messages')[0]; @@ -210,11 +210,11 @@ describe('useChannel with deriveOptions', () => { + /> ); await act(async () => { await anotherClient.channels - .get('tasks') + .get(Channels.tasks) .publish({ text: 'A new task for you', extras: { headers: { user: 'robert.pike@domain.io' } } }); }); @@ -229,11 +229,11 @@ describe('useChannel with deriveOptions', () => { + /> ); await act(async () => { await anotherClient.channels - .get('tasks') + .get(Channels.tasks) .publish({ text: 'This one is for another Rob', extras: { headers: { user: 'robert.griesemer@domain.io' } } }); }); @@ -247,10 +247,10 @@ describe('useChannel with deriveOptions', () => { + /> ); await act(async () => { - const channel = anotherClient.channels.get('tasks'); + const channel = anotherClient.channels.get(Channels.tasks); await channel.publish({ text: 'This one is for another Rob', extras: { headers: { user: 'robert.griesemer@domain.io' } }, @@ -274,6 +274,42 @@ describe('useChannel with deriveOptions', () => { expect(messageUl.children[0].innerHTML).toBe('This one is for the whole domain'); expect(messageUl.children[1].innerHTML).toBe('This one is also a domain-wide fan-out'); }); + + it('component can use "useChannel" with multiple clients', async () => { + const cliendId = 'client'; + const anotherClientId = 'anotherClient'; + + render( + + + + + + ); + + await act(async () => { + await yetAnotherClient.channels.get(Channels.tasks).publish({ + text: 'A task for Griesemer', + extras: { headers: { user: 'robert.griesemer@domain.io' } }, + }); + await yetAnotherClient.channels.get(Channels.alerts).publish({ + text: 'A company-wide alert', + extras: { headers: { company: 'domain' } }, + }); + }); + + const messageUl = screen.getAllByRole('derived-channel-messages')[0]; + + expect(messageUl.childElementCount).toBe(2); + expect(messageUl.children[0].innerHTML).toBe('A task for Griesemer'); + expect(messageUl.children[1].innerHTML).toBe('A company-wide alert'); + }); }); const UseChannelComponentMultipleClients = () => { @@ -301,6 +337,26 @@ const UseChannelComponent = ({ skip }: { skip?: boolean }) => { return
      {messagePreviews}
    ; }; +const UseDerivedChannelComponentMultipleClients = ({ + channelName, + clientId, + anotherClientId, + anotherChannelName, + deriveOptions, +}) => { + const [messages, setMessages] = useState([]); + useChannel({ id: clientId, channelName, deriveOptions }, (message) => { + setMessages((prev) => [...prev, message]); + }); + useChannel({ id: anotherClientId, channelName: anotherChannelName, deriveOptions }, (message) => { + setMessages((prev) => [...prev, message]); + }); + + const messagePreviews = messages.map((msg, index) =>
  • {msg.data.text}
  • ); + + return
      {messagePreviews}
    ; +}; + const UseDerivedChannelComponent = ({ channelName, deriveOptions }) => { const [messages, setMessages] = useState([]); From 51574e0bfe082e313585c29439fae3000f2b7fc9 Mon Sep 17 00:00:00 2001 From: Pavel Mikhalkevich Date: Sun, 12 Nov 2023 21:31:50 +0500 Subject: [PATCH 4/9] feat: add 'jmespath' lib typedefs --- package-lock.json | 13 +++++++++++++ package.json | 1 + 2 files changed, 14 insertions(+) diff --git a/package-lock.json b/package-lock.json index d88155956b..c58c1e93fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@ably/vcdiff-decoder": "1.0.4", "@testing-library/react": "^13.3.0", "@types/crypto-js": "^4.0.1", + "@types/jmespath": "^0.15.2", "@types/node": "^15.0.0", "@types/request": "^2.48.7", "@types/ws": "^8.2.0", @@ -1250,6 +1251,12 @@ "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz", "integrity": "sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ==" }, + "node_modules/@types/jmespath": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@types/jmespath/-/jmespath-0.15.2.tgz", + "integrity": "sha512-pegh49FtNsC389Flyo9y8AfkVIZn9MMPE9yJrO9svhq6Fks2MwymULWjZqySuxmctd3ZH4/n7Mr98D+1Qo5vGA==", + "dev": true + }, "node_modules/@types/json-schema": { "version": "7.0.9", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.9.tgz", @@ -15146,6 +15153,12 @@ "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz", "integrity": "sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ==" }, + "@types/jmespath": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@types/jmespath/-/jmespath-0.15.2.tgz", + "integrity": "sha512-pegh49FtNsC389Flyo9y8AfkVIZn9MMPE9yJrO9svhq6Fks2MwymULWjZqySuxmctd3ZH4/n7Mr98D+1Qo5vGA==", + "dev": true + }, "@types/json-schema": { "version": "7.0.9", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.9.tgz", diff --git a/package.json b/package.json index accfca59d6..4978380866 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@ably/vcdiff-decoder": "1.0.4", "@testing-library/react": "^13.3.0", "@types/crypto-js": "^4.0.1", + "@types/jmespath": "^0.15.2", "@types/node": "^15.0.0", "@types/request": "^2.48.7", "@types/ws": "^8.2.0", From 25b1c9731c361260abea79085ece3a98e9892291 Mon Sep 17 00:00:00 2001 From: Pavel Mikhalkevich Date: Mon, 13 Nov 2023 00:25:35 +0500 Subject: [PATCH 5/9] feat: add complete test suite for use derived channel --- .../react-hooks/src/hooks/useChannel.test.tsx | 248 ++++++++++++++++-- 1 file changed, 225 insertions(+), 23 deletions(-) diff --git a/src/platform/react-hooks/src/hooks/useChannel.test.tsx b/src/platform/react-hooks/src/hooks/useChannel.test.tsx index 090a7f96ce..7bba8f65ff 100644 --- a/src/platform/react-hooks/src/hooks/useChannel.test.tsx +++ b/src/platform/react-hooks/src/hooks/useChannel.test.tsx @@ -134,18 +134,10 @@ describe('useChannel', () => { it('should use the latest version of the message callback', async () => { let callbackCount = 0; - const TestComponent = () => { - const [count, setCount] = React.useState(0); - - useChannel('blah', () => { - callbackCount++; - setCount(count + 1); - }); - - return
    {count}
    ; - }; - - renderInCtxProvider(ablyClient, ); + renderInCtxProvider( + ablyClient, + callbackCount++} /> + ); await act(async () => { ablyClient.channels.get('blah').publish({ text: 'test message 1' }); @@ -241,7 +233,7 @@ describe('useChannel with deriveOptions', () => { expect(messageUl.childElementCount).toBe(0); }); - it('component will update if some messages qualify', async () => { + it('component will update with only those messages that qualify', async () => { renderInCtxProvider( ablyClient, { deriveOptions={{ filter: 'headers.user == `"robert.pike@domain.io"` || headers.company == `"domain"`' }} /> ); + await act(async () => { const channel = anotherClient.channels.get(Channels.tasks); await channel.publish({ @@ -287,7 +280,9 @@ describe('useChannel with deriveOptions', () => { channelName={Channels.tasks} anotherClientId={anotherClientId} anotherChannelName={Channels.alerts} - deriveOptions={{ filter: 'headers.user == `"robert.griesemer@domain.io"` || headers.company == `"domain"`' }} + deriveOptions={{ + filter: 'headers.user == `"robert.griesemer@domain.io"` || headers.company == `"domain"`', + }} /> @@ -310,6 +305,158 @@ describe('useChannel with deriveOptions', () => { expect(messageUl.children[0].innerHTML).toBe('A task for Griesemer'); expect(messageUl.children[1].innerHTML).toBe('A company-wide alert'); }); + + it('handles channel errors', async () => { + const onChannelError = vi.fn(); + const reason = { message: 'channel error occurred' }; + + renderInCtxProvider( + ablyClient, + + ); + + const channelErrorElem = screen.getByRole('channelError'); + expect(onChannelError).toHaveBeenCalledTimes(0); + expect(channelErrorElem.innerHTML).toEqual(''); + + act(() => ablyClient.channels.get(Channels.alerts).emit('failed', { reason })); + + expect(channelErrorElem.innerHTML).toEqual(reason.message); + expect(onChannelError).toHaveBeenCalledTimes(1); + expect(onChannelError).toHaveBeenCalledWith(reason); + }); + + it('handles connection errors', async () => { + const onConnectionError = vi.fn(); + const reason = { message: 'failed to establish connection' }; + + renderInCtxProvider( + ablyClient, + + ); + + const channelErrorElem = screen.getByRole('connectionError'); + expect(onConnectionError).toHaveBeenCalledTimes(0); + expect(channelErrorElem.innerHTML).toEqual(''); + + act(() => ablyClient.connection.emit('failed', { reason })); + + expect(channelErrorElem.innerHTML).toEqual(reason.message); + expect(onConnectionError).toHaveBeenCalledTimes(1); + expect(onConnectionError).toHaveBeenCalledWith(reason); + }); + + it('wildcard filter', async () => { + renderInCtxProvider( + ablyClient, + + ); + + await act(async () => { + const text = 'Will receive this text due to wildcard filter'; + await anotherClient.channels.get(Channels.alerts).publish({ text }); + }); + + const messageUl = screen.getAllByRole('derived-channel-messages')[0]; + expect(messageUl.childElementCount).toBe(1); + }); + + it('skip param', async () => { + renderInCtxProvider( + ablyClient, + + ); + + await act(async () => { + const text = 'Will skip due to "skip=true"'; + await anotherClient.channels.get(Channels.alerts).publish({ text }); + }); + + const messageUl = screen.getAllByRole('derived-channel-messages')[0]; + expect(messageUl.childElementCount).toBe(0); + }); + + it('should use the latest version of the message callback', async () => { + let callbackCount = 0; + + renderInCtxProvider( + ablyClient, + callbackCount++} + /> + ); + + await act(async () => { + const channel = anotherClient.channels.get(Channels.tasks); + await channel.publish({ + text: 'This one is for another Rob', + extras: { headers: { user: 'robert.griesemer@domain.io' } }, + }); + await channel.publish({ + text: 'This one is for the whole domain', + extras: { headers: { company: 'domain' } }, + }); + await channel.publish({ + text: 'This one is for Ken', + extras: { headers: { user: 'ken.thompson@domain.io' } }, + }); + await channel.publish({ + text: 'This one is also a domain-wide fan-out', + extras: { headers: { company: 'domain' } }, + }); + await channel.publish({ + text: 'This one for Mr.Pike will also get through...', + extras: { headers: { user: 'robert.pike@domain.io' } }, + }); + await channel.publish({ + text: '.... as well as this message', + extras: { headers: { user: 'robert.pike@domain.io' } }, + }); + }); + + expect(callbackCount).toBe(4); + expect(screen.getByRole('counter').innerHTML).toEqual(`${callbackCount}`); + }); + + it('should re-subscribe if event name has changed', async () => { + const channel = ablyClient.channels.get(Channels.alerts); + channel.subscribe = vi.fn(); + channel.unsubscribe = vi.fn(); + + const eventName = 'event1'; + const newEventName = 'event2'; + + renderInCtxProvider( + ablyClient, + + ); + + await waitFor(() => expect(channel.subscribe).toHaveBeenCalledWith(eventName, expect.any(Function))); + + await waitFor(() => expect(channel.unsubscribe).toHaveBeenCalledWith(eventName, expect.any(Function))); + + expect(channel.subscribe).toHaveBeenCalledWith(newEventName, expect.any(Function)); + }); }); const UseChannelComponentMultipleClients = () => { @@ -337,13 +484,21 @@ const UseChannelComponent = ({ skip }: { skip?: boolean }) => { return
      {messagePreviews}
    ; }; +interface UseDerivedChannelComponentMultipleClientsProps { + clientId: string; + channelName: string; + anotherClientId: string; + anotherChannelName: string; + deriveOptions: Types.DeriveOptions; +} + const UseDerivedChannelComponentMultipleClients = ({ channelName, clientId, anotherClientId, anotherChannelName, deriveOptions, -}) => { +}: UseDerivedChannelComponentMultipleClientsProps) => { const [messages, setMessages] = useState([]); useChannel({ id: clientId, channelName, deriveOptions }, (message) => { setMessages((prev) => [...prev, message]); @@ -357,10 +512,16 @@ const UseDerivedChannelComponentMultipleClients = ({ return
      {messagePreviews}
    ; }; -const UseDerivedChannelComponent = ({ channelName, deriveOptions }) => { +interface UseDerivedChannelComponentProps { + channelName: string; + deriveOptions: Types.DeriveOptions; + skip?: boolean; +} + +const UseDerivedChannelComponent = ({ channelName, deriveOptions, skip = false }: UseDerivedChannelComponentProps) => { const [messages, setMessages] = useState([]); - useChannel({ channelName, deriveOptions }, (message) => { + useChannel({ channelName, deriveOptions, skip }, (message) => { setMessages((prev) => [...prev, message]); }); @@ -372,10 +533,18 @@ const UseDerivedChannelComponent = ({ channelName, deriveOptions }) => { interface UseChannelStateErrorsComponentProps { onConnectionError?: (err: Types.ErrorInfo) => unknown; onChannelError?: (err: Types.ErrorInfo) => unknown; + channelName?: string; + deriveOptions?: Types.DeriveOptions; } -const UseChannelStateErrorsComponent = ({ onConnectionError, onChannelError }: UseChannelStateErrorsComponentProps) => { - const { connectionError, channelError } = useChannel({ channelName: 'blah', onConnectionError, onChannelError }); +const UseChannelStateErrorsComponent = ({ + onConnectionError, + onChannelError, + channelName = 'blah', + deriveOptions, +}: UseChannelStateErrorsComponentProps) => { + const opts = { channelName, deriveOptions, onConnectionError, onChannelError }; + const { connectionError, channelError } = useChannel(opts); return ( <> @@ -385,14 +554,47 @@ const UseChannelStateErrorsComponent = ({ onConnectionError, onChannelError }: U ); }; -const ChangingEventComponent = ({ newEventName }: { newEventName: string }) => { - const [eventName, setEventName] = useState('event1'); +interface LatestMessageCallbackComponentProps { + channelName: string; + deriveOptions?: Types.DeriveOptions; + callback: () => any; +} + +const LatestMessageCallbackComponent = ({ + channelName, + deriveOptions, + callback, +}: LatestMessageCallbackComponentProps) => { + const [count, setCount] = React.useState(0); + + useChannel({ channelName, deriveOptions }, () => { + callback(); + setCount((count) => count + 1); + }); + + return
    {count}
    ; +}; + +interface ChangingEventComponentProps { + newEventName: string; + channelName?: string; + deriveOptions?: Types.DeriveOptions; + eventName?: string; +} + +const ChangingEventComponent = ({ + channelName = 'blah', + eventName = 'event1', + newEventName, + deriveOptions, +}: ChangingEventComponentProps) => { + const [currentEventName, setCurrentEventName] = useState(eventName); - useChannel('blah', eventName, vi.fn()); + useChannel({ channelName, deriveOptions }, currentEventName, vi.fn()); useEffect(() => { const timeoutId = setTimeout(() => { - setEventName(newEventName); + setCurrentEventName(newEventName); }, 50); return () => clearTimeout(timeoutId); From 9bd2e77d0262ade81907d471d1c352120735b066 Mon Sep 17 00:00:00 2001 From: Pavel Mikhalkevich Date: Mon, 13 Nov 2023 23:48:41 +0500 Subject: [PATCH 6/9] feat: do not use async syntax for sync fake-ably ops --- .../react-hooks/src/hooks/useChannel.test.tsx | 48 ++++++++++--------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/src/platform/react-hooks/src/hooks/useChannel.test.tsx b/src/platform/react-hooks/src/hooks/useChannel.test.tsx index 7bba8f65ff..4cd2e7e3a1 100644 --- a/src/platform/react-hooks/src/hooks/useChannel.test.tsx +++ b/src/platform/react-hooks/src/hooks/useChannel.test.tsx @@ -204,8 +204,9 @@ describe('useChannel with deriveOptions', () => { deriveOptions={{ filter: 'headers.user == `"robert.pike@domain.io"`' }} /> ); - await act(async () => { - await anotherClient.channels + + act(() => { + anotherClient.channels .get(Channels.tasks) .publish({ text: 'A new task for you', extras: { headers: { user: 'robert.pike@domain.io' } } }); }); @@ -223,8 +224,9 @@ describe('useChannel with deriveOptions', () => { deriveOptions={{ filter: 'headers.user == `"robert.pike@domain.io"`' }} /> ); - await act(async () => { - await anotherClient.channels + + act(() => { + anotherClient.channels .get(Channels.tasks) .publish({ text: 'This one is for another Rob', extras: { headers: { user: 'robert.griesemer@domain.io' } } }); }); @@ -242,21 +244,21 @@ describe('useChannel with deriveOptions', () => { /> ); - await act(async () => { + act(() => { const channel = anotherClient.channels.get(Channels.tasks); - await channel.publish({ + channel.publish({ text: 'This one is for another Rob', extras: { headers: { user: 'robert.griesemer@domain.io' } }, }); - await channel.publish({ + channel.publish({ text: 'This one is for the whole domain', extras: { headers: { company: 'domain' } }, }); - await channel.publish({ + channel.publish({ text: 'This one is for Ken', extras: { headers: { user: 'ken.thompson@domain.io' } }, }); - await channel.publish({ + channel.publish({ text: 'This one is also a domain-wide fan-out', extras: { headers: { company: 'domain' } }, }); @@ -288,12 +290,12 @@ describe('useChannel with deriveOptions', () => { ); - await act(async () => { - await yetAnotherClient.channels.get(Channels.tasks).publish({ + act(() => { + yetAnotherClient.channels.get(Channels.tasks).publish({ text: 'A task for Griesemer', extras: { headers: { user: 'robert.griesemer@domain.io' } }, }); - await yetAnotherClient.channels.get(Channels.alerts).publish({ + yetAnotherClient.channels.get(Channels.alerts).publish({ text: 'A company-wide alert', extras: { headers: { company: 'domain' } }, }); @@ -361,9 +363,9 @@ describe('useChannel with deriveOptions', () => { >
    ); - await act(async () => { + act(() => { const text = 'Will receive this text due to wildcard filter'; - await anotherClient.channels.get(Channels.alerts).publish({ text }); + anotherClient.channels.get(Channels.alerts).publish({ text }); }); const messageUl = screen.getAllByRole('derived-channel-messages')[0]; @@ -380,9 +382,9 @@ describe('useChannel with deriveOptions', () => { >
    ); - await act(async () => { + act(() => { const text = 'Will skip due to "skip=true"'; - await anotherClient.channels.get(Channels.alerts).publish({ text }); + anotherClient.channels.get(Channels.alerts).publish({ text }); }); const messageUl = screen.getAllByRole('derived-channel-messages')[0]; @@ -401,29 +403,29 @@ describe('useChannel with deriveOptions', () => { /> ); - await act(async () => { + act(() => { const channel = anotherClient.channels.get(Channels.tasks); - await channel.publish({ + channel.publish({ text: 'This one is for another Rob', extras: { headers: { user: 'robert.griesemer@domain.io' } }, }); - await channel.publish({ + channel.publish({ text: 'This one is for the whole domain', extras: { headers: { company: 'domain' } }, }); - await channel.publish({ + channel.publish({ text: 'This one is for Ken', extras: { headers: { user: 'ken.thompson@domain.io' } }, }); - await channel.publish({ + channel.publish({ text: 'This one is also a domain-wide fan-out', extras: { headers: { company: 'domain' } }, }); - await channel.publish({ + channel.publish({ text: 'This one for Mr.Pike will also get through...', extras: { headers: { user: 'robert.pike@domain.io' } }, }); - await channel.publish({ + channel.publish({ text: '.... as well as this message', extras: { headers: { user: 'robert.pike@domain.io' } }, }); From 8f998d0fb22137d346237e4e35164c9342aabc75 Mon Sep 17 00:00:00 2001 From: Pavel Mikhalkevich Date: Tue, 14 Nov 2023 01:39:37 +0500 Subject: [PATCH 7/9] feat: add derived channel usage to 'sample-app' --- .../react-hooks/sample-app/src/App.tsx | 97 ++++++++++++++++++- 1 file changed, 95 insertions(+), 2 deletions(-) diff --git a/src/platform/react-hooks/sample-app/src/App.tsx b/src/platform/react-hooks/sample-app/src/App.tsx index fafebf8d3a..b60121fe5f 100644 --- a/src/platform/react-hooks/sample-app/src/App.tsx +++ b/src/platform/react-hooks/sample-app/src/App.tsx @@ -11,11 +11,36 @@ import './App.css'; function App() { const [messages, updateMessages] = useState([]); + const [derivedChannelMessages, updateDerivedChannelMessages] = useState([]); + const [frontOficeOnlyMessages, updateFrontOfficeOnlyMessages] = useState([]); + const [skip, setSkip] = useState(false); const { channel, ably } = useChannel({ channelName: 'your-channel-name', skip }, (message) => { updateMessages((prev) => [...prev, message]); }); + useChannel( + { + channelName: 'your-derived-channel-name', + deriveOptions: { filter: 'headers.email == `"rob.pike@domain.com"` || headers.company == `"domain"`' }, + }, + (message) => { + updateDerivedChannelMessages((prev) => [...prev, message]); + } + ); + + useChannel( + { + channelName: 'your-derived-channel-name', + deriveOptions: { filter: 'headers.role == `"front-office"` || headers.company == `"domain"`' }, + }, + (message) => { + updateFrontOfficeOnlyMessages((prev) => [...prev, message]); + } + ); + + const { channel: anotherChannelPublisher } = useChannel({ channelName: 'your-derived-channel-name' }); + const { presenceData, updateStatus } = usePresence( { channelName: 'your-channel-name', skip }, { foo: 'bar' }, @@ -42,7 +67,14 @@ function App() { setChannelStateReason(stateChange.reason ?? undefined); }); - const messagePreviews = messages.map((msg, index) =>
  • {msg.data.text}
  • ); + const messagePreviews = messages.map((message, idx) => ); + const derivedChannelMessagePreviews = derivedChannelMessages.map((message, idx) => ( + + )); + const frontOfficeMessagePreviews = frontOficeOnlyMessages.map((message, idx) => ( + + )); + const presentClients = presenceData.map((msg, index) => (
  • {msg.clientId}: {JSON.stringify(msg.data)} @@ -69,6 +101,57 @@ function App() { > Update status to hello + + +
    @@ -94,7 +177,13 @@ function App() {
    {ablyErr}

    Messages

    -
      {messagePreviews}
    + {
      {messagePreviews}
    } + +

    Derived Channel Messages

    +
      {derivedChannelMessagePreviews}
    + +

    Front Office Messages

    +
      {frontOfficeMessagePreviews}

    Present Clients

      {presentClients}
    @@ -103,6 +192,10 @@ function App() { ); } +function MessagePreview({ message }: { message: Types.Message }) { + return
  • {message.data.text}
  • ; +} + function ConnectionState() { const ably = useAbly(); const [connectionState, setConnectionState] = useState(ably.connection.state); From d8f3cd6d5738f08a95c2273c5414e78226af5fc0 Mon Sep 17 00:00:00 2001 From: Pavel Mikhalkevich Date: Thu, 16 Nov 2023 01:03:36 +0500 Subject: [PATCH 8/9] feat: add 'jmespath' as explicit dev-dep --- package-lock.json | 1 + package.json | 1 + 2 files changed, 2 insertions(+) diff --git a/package-lock.json b/package-lock.json index c58c1e93fe..4183a572d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,7 @@ "grunt-shell": "~1.1", "grunt-webpack": "^4.0.2", "hexy": "~0.2", + "jmespath": "^0.16.0", "jsdom": "^20.0.0", "kexec": "ably-forks/node-kexec#update-for-node-12", "minimist": "^1.2.5", diff --git a/package.json b/package.json index 4978380866..1381f78e41 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "grunt-shell": "~1.1", "grunt-webpack": "^4.0.2", "hexy": "~0.2", + "jmespath": "^0.16.0", "jsdom": "^20.0.0", "kexec": "ably-forks/node-kexec#update-for-node-12", "minimist": "^1.2.5", From 63adfc4000379f15a2f82c6bc00f2056ded48aaa Mon Sep 17 00:00:00 2001 From: Pavel Mikhalkevich Date: Thu, 16 Nov 2023 01:36:16 +0500 Subject: [PATCH 9/9] feat: add derived channel usage example to 'docs/react.md' --- docs/react.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/react.md b/docs/react.md index 2a10480638..0eb0223b32 100644 --- a/docs/react.md +++ b/docs/react.md @@ -139,6 +139,29 @@ const { channel } = useChannel({ channelName: "your-channel-name", options: { .. }); ``` +[Subscription filters](https://ably.com/docs/channels#filter-subscribe) are also supported: + +```javascript +const deriveOptions = { filter: 'headers.email == `"rob.pike@domain.com"` || headers.company == `"domain"`' } +const { channel } = useChannel({ channelName: "your-derived-channel-name", options: { ... }, deriveOptions }, (message) => { + ... +}); +``` + +Please note that attempts to publish to a derived channel (the one created or retrieved with a filter expression) will fail. In order to send messages to the channel called _"your-derived-channel-name"_ from the example above, you will need to create another channel instance without a filter expression. + +```javascript +const channelName = "your-derived-channel-name"; +const options = { ... }; +const deriveOptions = { filter: 'headers.email == `"rob.pike@domain.com"` || headers.company == `"domain"`' } +const callback = (message) => { ... }; + +const { channel: readOnlyChannelInstance } = useChannel({ channelName, options, deriveOptions }, callback); +const { channel: readWriteChannelInstance } = useChannel({ channelName, options }, callback); // NB! No 'deriveOptions' passed here + +readWriteChannelInstance.publish("test-message", { text: "message text" }); +``` + --- ### usePresence