diff --git a/app/assets/javascripts/lib/utils/communication.js b/app/assets/javascripts/lib/utils/communication.js new file mode 100644 index 0000000000000000000000000000000000000000..3af2afe5a101cf0bbdb7f40fe419ea4a33d1c7b5 --- /dev/null +++ b/app/assets/javascripts/lib/utils/communication.js @@ -0,0 +1,38 @@ +/** + * @module communication + */ + +/** + * [RxJS Observable Subject]{@link https://rxjs.dev/api/index/class/Subject} + * @typedef {Object} RxSubject + */ + +import { Subject } from 'rxjs'; + +const channels = {}; + +/** + * Open a channel on the communication harness + * @param {Object} [options] + * @param {String} [options.name] - The name of the channel to retrieve or create + * @returns {RxSubject} The requested channel, if it exists, or else a new channel + */ +export function openChannel({ name }) { + let channel = channels[name]; + + if (!channel) { + channel = new Subject(); + channels[name] = channel; + } + + return channel; +} + +/** + * Remove references to a channel from the communication harness + * @param {Object} [options] + * @param {String} [options.name] - The name of the channel to flush (forget/dereference) + */ +export function flushChannel({ name }) { + channels[name] = null; +} diff --git a/package.json b/package.json index 73778428426c4adfe6dfb6316a977b58d7d90a42..34d728b454d93362f21a2a8efcb6d4ad5607ed96 100644 --- a/package.json +++ b/package.json @@ -168,6 +168,7 @@ "rehype-raw": "^6.1.1", "remark-parse": "^10.0.1", "remark-rehype": "^10.1.0", + "rxjs": "7.5.5", "scrollparent": "^2.0.1", "select2": "3.5.2-browserify", "smooshpack": "^0.0.62", diff --git a/spec/frontend/lib/utils/communication_spec.js b/spec/frontend/lib/utils/communication_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..f58ea78656aae073d92779d8e787894ede98f913 --- /dev/null +++ b/spec/frontend/lib/utils/communication_spec.js @@ -0,0 +1,117 @@ +import { openChannel, flushChannel } from '~/lib/utils/communication'; + +describe('communication', () => { + describe('openChannel', () => { + let channel; + + beforeEach(() => { + channel = openChannel({ name: 'channel' }); + }); + + afterEach(() => { + flushChannel({ name: 'channel' }); + }); + + it("should return a 'nextable' object", () => { + expect(channel.next).toBeInstanceOf(Function); + expect(channel.error).toBeInstanceOf(Function); + expect(channel.complete).toBeInstanceOf(Function); + }); + + it('should return an observable Subject', () => { + expect(channel.subscribe).toBeInstanceOf(Function); + expect(channel.thrownError).toBeNull(); + expect(channel.closed).toBe(false); + expect(channel.observed).toBe(false); + }); + + it('should always return the same channel for a given name', () => { + expect(channel).toBe(openChannel({ name: 'channel' })); + }); + }); + + describe('flushChannel', () => { + it('should not return the same channel if the channel is flushed between opens', () => { + const channel = openChannel({ name: 'channel' }); + + flushChannel({ name: 'channel' }); + + expect(channel).not.toBe(openChannel({ name: 'channel' })); + }); + }); + + describe('channel', () => { + let channel; + + beforeEach(() => { + channel = openChannel({ name: 'channel' }); + }); + + afterEach(() => { + flushChannel({ name: 'channel' }); + }); + + it('should send events to observers', () => { + const events = []; + + channel.subscribe((event) => { + events.push(event.message); + }); + + channel.next({ message: 'sent' }); + + expect(events).toEqual(['sent']); + }); + + it('should pass on a completion to observers', () => { + channel.subscribe({ + complete: () => expect(true).toBe(true), // We just want to test that this runs + }); + + channel.complete(); + }); + + it('should not send events after completing', () => { + const events = []; + + channel.subscribe({ next: (event) => events.push(event) }); + channel.complete(); + + channel.next({ name: 'sent' }); + + expect(events).toEqual([]); + }); + + it('should pass on an error to observers', async () => { + let caught; + + channel.subscribe({ + error: (e) => { + caught = e; + }, + }); + + channel.error('broke'); + + expect(caught).toBe('broke'); + }); + + it('should not send events after erroring', () => { + const events = []; + + channel.subscribe({ + next: (e) => events.push(e), + error: () => { + /* swallow errors */ + }, + }); + + channel.next('a'); + channel.next('b'); + channel.error('error'); + channel.next('c'); + + expect(events).toStrictEqual(['a', 'b']); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 236523c3625171dd65040a779bd0a9f1efa7f33a..bd9ce254c7ea93208b53e7d8b37a960efb3b826d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10757,6 +10757,13 @@ rw@1: resolved "https://registry.yarnpkg.com/rw/-/rw-1.3.3.tgz#3f862dfa91ab766b14885ef4d01124bfda074fb4" integrity sha1-P4Yt+pGrdmsUiF700BEkv9oHT7Q= +rxjs@7.5.5: + version "7.5.5" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.5.5.tgz#2ebad89af0f560f460ad5cc4213219e1f7dd4e9f" + integrity sha512-sy+H0pQofO95VDmFLzyaw9xNJU4KTRSwQIGM6+iG3SypAtCiLDzpeG8sJrNCWn2Up9km+KhkvTdbkrdy+yzZdw== + dependencies: + tslib "^2.1.0" + sade@^1.7.3: version "1.8.1" resolved "https://registry.yarnpkg.com/sade/-/sade-1.8.1.tgz#0a78e81d658d394887be57d2a409bf703a3b2701"