diff --git a/README.md b/README.md index d8ce88f2251..ca2b02a1ece 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,8 @@ npm install @brightspace-ui/core * [Tooltip](components/tooltip/): tooltip components * [Typography](components/typography/): typography styles and components * [Validation](components/validation/): plugin custom validation logic to native and custom form elements +* Controllers + * [Subscriber](controllers/subscriber/): for managing a registry of subscribers in a many-to-many relationship * Directives * [Animate](directives/animate/): animate showing, hiding and removal of elements * Helpers diff --git a/controllers/subscriber/README.md b/controllers/subscriber/README.md new file mode 100644 index 00000000000..87e62fe7e2d --- /dev/null +++ b/controllers/subscriber/README.md @@ -0,0 +1,177 @@ +# Subscriber Controllers + +The `SubscriberRegistryController` and the corresponding `*SubscriberController`s can be used to create a subscription system within your app. Components can setup a subscriber registry instance to keep track of all components subscribed to them with the `SubscriberRegistryController`. Whenever it makes sense to do so, they can iterate over their subscribers to perform some action, update them with new data, etc. Components can subscribe themselves to different registries using the `IdSubscriberController` or the `EventSubscriberController`. This system supports a many-to-many relationship - registry components can contain multiple registry instances with multiple subscribers in each, and subscriber components can subscribe to multiple different registries. + +## Usage + +Create an instance of the `SubscriberRegistryController` in the component that will be responsible for providing some data or performing some function on all its subscribers: + +```js +import { SubscriberRegistryController } from '@brightspace-ui/core/controllers/subscriber/subscriberControllers.js'; + +class CableSubscription extends LitElement { + constructor() { + super(); + this._sportsSubscribers = new SubscriberRegistryController(this, + { onSubscribe: this._unlockSportsChannels.bind(this) }, + { eventName: 'd2l-channels-subscribe-sports' } + ); + + this._movieSubscribers = new SubscriberRegistryController(this, {}, + { onSubscribe: this._unlockMovieChannels.bind(this), updateSubscribers: this._sendMovieGuide.bind(this) }, + { eventName: 'd2l-channels-subscribe-movies' } + ); + + // This controller only supports registering by id - no event is needed + this._kidsChannelSubscribers = new SubscriberRegistryController(this, + { onSubscribe: this._unlockKidsChannels.bind(this) }, {}); + } + + getController(controllerId) { + if (controllerId === 'sports') { + return this._sportsSubscribers; + } else if (controllerId === 'movies') { + return this._movieSubscribers; + } else if (controllerId === 'kids') { + return this._kidsChannelSubscribers; + } + } + + _sendMovieGuide(subscribers) { + subscribers.forEach(subscriber => subscriber.updateGuide(new MovieGuide(new Date().getMonth()))); + } + + _unlockMovieChannels(subscriber) { + subscriber.addChannels([330, 331, 332, 333, 334, 335]); + } + + ... + } +``` + +When creating the controller, you can pass in callbacks to run whenever a subscriber is added, removed, or `updateSubscribers` is called (which handles request debouncing for you). + +The `*subscriberController`s will use a `getController` method that needs to be exposed on the registry component. If you only have one `SubscriberRegistryController` you can simple return that. If you have multiple, you will return the proper controller depending on the id the subscriber component passed to you. + +Once this has been set up, components can subscribe to particular registries two different ways: +1. Using a matching event name with `EventSubscriberController`. The component will need to be a child of the registry component for this to work. +2. By pointing to the registry component's id with `IdSubscriberController`. The component will need to be in the same DOM scope as the registry component for this to work. + +Like the `SubscriberRegistryController`, these `*subscriberController`s take optional callbacks to throw at different points in the subscription process. + +```js +import { EventSubscriberController, IdSubscriberController } from '@brightspace-ui/core/controllers/subscriber/subscriberControllers.js'; + +class GeneralViewer extends LitElement { + static get properties() { + return { + _subscribedChannels: { type: Object } + }; + } + + constructor() { + super(); + this._subscribedChannels = new Set(); + + this._sportsSubscription = new EventSubscriberController(this, + { onError: this._onSportsError.bind(this) } + { eventName: 'd2l-channels-subscribe-sports', controllerId: 'sports' } + ); + + this._movieSubscription = new EventSubscriberController(this, {}, + { eventName: 'd2l-channels-subscribe-movies', controllerId: 'movies' } + ); + } + + addChannels(channels) { + channels.forEach(channel => this._subscribedChannels.add(channel)); + } + + _onSportsError() { + throw new Error('Where are the sports?'); + } + + ... +} + +class YoungerViewer extends LitElement { + static get properties() { + return { + for: { type: String }, + _subscribedChannels: { type: Object } + }; + } + + constructor() { + super(); + this._subscribedChannels = new Set(); + + this._kidsSubscription = new IdSubscriberController(this, + { onSubscribe: this._onSubscribe.bind(this), onUnsubscribe: this._onUnsubscribe.bind(this) }, + { idPropertyName: 'for', controllerId: 'kids' } + ); + } + + addChannels(channels) { + channels.forEach(channel => this._subscribedChannels.add(channel)); + } + + _onSubscribe(cableProvider) { + console.log(`Subscribed with ${cableProvider.id} successfully.`); + } + + _onUnsubscribe(cableProviderId) { + console.log(`Looks like ${cableProviderId} is having an outage again.`); + } + + ... +} +``` + +An example of what this could look like altogether: +```html + + + + +``` + +NOTE: Until we are on Lit 2, the controller lifecycle events will need to be manually called: +```js + connectedCallback() { + super.connectedCallback(); + if (this._subscriptionController) this._subscriptionController.hostConnected(); + } + + disconnectedCallback() { + super.disconnectedCallback(); + if (this._subscriptionController) this._subscriptionController.hostDisconnected(); + } + + updated(changedProperties) { + super.updated(changedProperties); + if (this._subscriptionController) this._subscriptionController.hostUpdated(changedProperties); + } +``` + +## Available Callbacks + +### SubscriberRegistryController +| Callback Name | Description | Passed to Callback | +|---|---|---| +| `onSubscribe` | Runs whenever a new subscriber is added | Subscriber that was just subscribed | +| `onUnsubscribe` | Runs whenever a subscriber is removed | Subscriber that was just unsubscribed | +| `updateSubscribers` | Runs whenever `updateSubscribers` is called on the controller, handles debouncing requests for you | Map of all current subscribers | + +### EventSubscriberController +| Callback Name | Description | Passed to Callback | +|---|---|---| +| `onSubscribe` | Runs when successfully subscribed to a registry component | Registry that was just subscribed to | +| `onError` | Runs if the event was unacknowledged and no registry component was found | None | + +### IdSubscriberController +| Callback Name | Description | Passed to Callback | +|---|---|---| +| `onSubscribe` | Runs whenever a registry component is successfully subscribed to | Registry that was just subscribed to | +| `onUnsubscribe` | Runs whenever we unsubscribe to a registry (because it is now gone, or its id was removed from the id property list) | Id of the registry that was just unsubscribed to | +| `onError` | Runs if no registry component was found for an id | Id of the registry we do not have a component for | diff --git a/controllers/subscriber/subscriberControllers.js b/controllers/subscriber/subscriberControllers.js new file mode 100644 index 00000000000..1a3a4c7ca33 --- /dev/null +++ b/controllers/subscriber/subscriberControllers.js @@ -0,0 +1,180 @@ +import { cssEscape } from '../../helpers/dom.js'; + +export class SubscriberRegistryController { + + constructor(host, callbacks, options) { + this._host = host; + this._callbacks = callbacks || {}; + this._eventName = options && options.eventName; + this._subscribers = new Map(); + + this._handleSubscribe = this._handleSubscribe.bind(this); + } + + get subscribers() { + return this._subscribers; + } + + hostConnected() { + if (this._eventName) this._host.addEventListener(this._eventName, this._handleSubscribe); + } + + hostDisconnected() { + if (this._eventName) this._host.removeEventListener(this._eventName, this._handleSubscribe); + } + + subscribe(target) { + if (this._subscribers.has(target)) return; + this._subscribers.set(target, target); + if (this._callbacks.onSubscribe) this._callbacks.onSubscribe(target); + } + + unsubscribe(target) { + this._subscribers.delete(target); + if (this._callbacks.onUnsubscribe) this._callbacks.onUnsubscribe(target); + } + + updateSubscribers() { + if (!this._subscribers || this._subscribers.size === 0) return; + if (!this._callbacks.updateSubscribers) return; + + // debounce the updates + if (this._updateSubscribersRequested) return; + + this._updateSubscribersRequested = true; + setTimeout(() => { + this._callbacks.updateSubscribers(this._subscribers); + this._updateSubscribersRequested = false; + }, 0); + } + + _handleSubscribe(e) { + e.stopPropagation(); + e.detail.registry = this._host; + const target = e.composedPath()[0]; + this.subscribe(target); + } +} + +export class EventSubscriberController { + + constructor(host, callbacks, options) { + this._host = host; + this._callbacks = callbacks || {}; + this._eventName = options && options.eventName; + this._controllerId = options && options.controllerId; + this._registry = null; + } + + get registry() { + return this._registry; + } + + hostConnected() { + // delay subscription otherwise import/upgrade order can cause selection mixin to miss event + requestAnimationFrame(() => { + const evt = new CustomEvent(this._eventName, { + bubbles: true, + composed: true, + detail: {} + }); + this._host.dispatchEvent(evt); + this._registry = evt.detail.registry; + + if (!this._registry) { + if (this._callbacks.onError) this._callbacks.onError(); + return; + } + if (this._callbacks.onSubscribe) this._callbacks.onSubscribe(this._registry); + }); + } + + hostDisconnected() { + if (this._registry) this._registry.getController(this._controllerId).unsubscribe(this._host); + } + +} + +export class IdSubscriberController { + + constructor(host, callbacks, options) { + this._host = host; + this._callbacks = callbacks || {}; + this._idPropertyName = options && options.idPropertyName; + this._controllerId = options && options.controllerId; + this._registries = new Map(); + this._timeouts = new Set(); + } + + get registries() { + return Array.from(this._registries.values()); + } + + hostDisconnected() { + if (this._registryObserver) this._registryObserver.disconnect(); + this._timeouts.forEach(timeoutId => clearTimeout(timeoutId)); + this._registries.forEach(registry => { + registry.getController(this._controllerId).unsubscribe(this._host); + }); + } + + hostUpdated(changedProperties) { + if (!changedProperties.has(this._idPropertyName)) return; + + if (this._registryObserver) this._registryObserver.disconnect(); + this._registries.forEach(registry => { + registry.getController(this._controllerId).unsubscribe(this._host); + if (this._callbacks.onUnsubscribe) this._callbacks.onUnsubscribe(registry.id); + }); + this._registries = new Map(); + + this._updateRegistries(); + + this._registryObserver = new MutationObserver(() => { + this._updateRegistries(); + }); + + this._registryObserver.observe(this._host.getRootNode(), { + childList: true, + subtree: true + }); + } + + _updateRegistries() { + let registryIds = this._host[this._idPropertyName]; + if (!registryIds) return; + + registryIds = registryIds.split(' '); + registryIds.forEach(registryId => { + this._updateRegistry(registryId, 0); + }); + } + + _updateRegistry(registryId, elapsedTime) { + let registryComponent = this._host.getRootNode().querySelector(`#${cssEscape(registryId)}`); + if (!registryComponent && this._callbacks.onError) { + if (elapsedTime < 3000) { + const timeoutId = setTimeout(() => { + this._timeouts.delete(timeoutId); + this._updateRegistry(registryId, elapsedTime + 100); + }, 100); + this._timeouts.add(timeoutId); + } else { + this._callbacks.onError(registryId); + } + } + + registryComponent = registryComponent || undefined; + if (this._registries.get(registryId) === registryComponent) return; + + if (registryComponent) { + registryComponent.getController(this._controllerId).subscribe(this._host); + this._registries.set(registryId, registryComponent); + if (this._callbacks.onSubscribe) this._callbacks.onSubscribe(registryComponent); + } else { + this._registries.delete(registryId); + if (this._callbacks.onUnsubscribe) this._callbacks.onUnsubscribe(registryId); + } + } + +} diff --git a/controllers/subscriber/test/.eslintrc.json b/controllers/subscriber/test/.eslintrc.json new file mode 100644 index 00000000000..b3d86243e15 --- /dev/null +++ b/controllers/subscriber/test/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "brightspace/open-wc-testing-config" +} diff --git a/controllers/subscriber/test/subscriberControllers.test.js b/controllers/subscriber/test/subscriberControllers.test.js new file mode 100644 index 00000000000..20d581e9ec8 --- /dev/null +++ b/controllers/subscriber/test/subscriberControllers.test.js @@ -0,0 +1,487 @@ + +import { defineCE, expect, fixture, nextFrame } from '@open-wc/testing'; +import { EventSubscriberController, IdSubscriberController, SubscriberRegistryController } from '../subscriberControllers.js'; +import { LitElement } from 'lit-element/lit-element.js'; +import sinon from 'sinon'; + +const separateRegistries = defineCE( + class extends LitElement { + constructor() { + super(); + this._eventSubscribers = new SubscriberRegistryController(this, + { onSubscribe: this._onSubscribe.bind(this), onUnsubscribe: this._onUnsubscribe.bind(this), updateSubscribers: this._updateSubscribers.bind(this) }, + { eventName: 'd2l-test-subscribe' } + ); + + this._idSubscribers = new SubscriberRegistryController(this, + { onSubscribe: this._onSubscribe.bind(this), onUnsubscribe: this._onUnsubscribe.bind(this), updateSubscribers: this._updateSubscribers.bind(this) }, + {} + ); + this._onSubscribeTargets = []; + this._onUnsubscribeTargets = []; + this._updateSubscribersCalledWith = []; + } + connectedCallback() { + super.connectedCallback(); + this._eventSubscribers.hostConnected(); + this._idSubscribers.hostConnected(); + } + disconnectedCallback() { + super.disconnectedCallback(); + this._eventSubscribers.hostDisconnected(); + this._idSubscribers.hostDisconnected(); + } + getController(controllerId) { + if (controllerId === 'event') { + return this._eventSubscribers; + } else if (controllerId === 'id') { + return this._idSubscribers; + } + } + _onSubscribe(target) { + this._onSubscribeTargets.push(target); + } + _onUnsubscribe(target) { + this._onUnsubscribeTargets.push(target); + } + _updateSubscribers(subscribers) { + this._updateSubscribersCalledWith.push(subscribers); + } + } +); + +const combinedRegistry = defineCE( + class extends LitElement { + constructor() { + super(); + this._subscribers = new SubscriberRegistryController(this, + { onSubscribe: this._onSubscribe.bind(this), onUnsubscribe: this._onUnsubscribe.bind(this), updateSubscribers: this._updateSubscribers.bind(this) }, + { eventName: 'd2l-test-subscribe' } + ); + this._onSubscribeTargets = []; + this._onUnsubscribeTargets = []; + this._updateSubscribersCalledWith = []; + } + connectedCallback() { + super.connectedCallback(); + this._subscribers.hostConnected(); + } + disconnectedCallback() { + super.disconnectedCallback(); + this._subscribers.hostDisconnected(); + } + getController() { + return this._subscribers; + } + _onSubscribe(target) { + this._onSubscribeTargets.push(target); + } + _onUnsubscribe(target) { + this._onUnsubscribeTargets.push(target); + } + _updateSubscribers(subscribers) { + this._updateSubscribersCalledWith.push(subscribers); + } + } +); + +const eventSubscriber = defineCE( + class extends LitElement { + constructor() { + super(); + this._subscriberController = new EventSubscriberController(this, + { onError: this._onError.bind(this), onSubscribe: this._onSubscribe.bind(this) }, + { eventName: 'd2l-test-subscribe', controllerId: 'event' } + ); + this._onSubscribeRegistry = null; + this._onError = false; + } + connectedCallback() { + super.connectedCallback(); + this._subscriberController.hostConnected(); + } + disconnectedCallback() { + super.disconnectedCallback(); + this._subscriberController.hostDisconnected(); + } + _onError() { + this._onError = true; + } + _onSubscribe(registry) { + this._onSubscribeRegistry = registry; + } + } +); + +const idSubscriber = defineCE( + class extends LitElement { + static get properties() { + return { + for: { type: String } + }; + } + constructor() { + super(); + this._subscriberController = new IdSubscriberController(this, + { onError: this._onError.bind(this), onSubscribe: this._onSubscribe.bind(this), onUnsubscribe: this._onUnsubscribe.bind(this) }, + { idPropertyName: 'for', controllerId: 'id' } + ); + this._onErrorRegistryIds = []; + this._onSubscribeRegistries = []; + this._onUnsubscribeRegistryIds = []; + } + disconnectedCallback() { + super.disconnectedCallback(); + this._subscriberController.hostDisconnected(); + } + updated(changedProperties) { + super.updated(changedProperties); + this._subscriberController.hostUpdated(changedProperties); + } + _onError(registryId) { + this._onErrorRegistryIds.push(registryId); + } + _onSubscribe(registry) { + this._onSubscribeRegistries.push(registry); + } + _onUnsubscribe(registryId) { + this._onUnsubscribeRegistryIds.push(registryId); + } + } +); + +describe('SubscriberRegistryController', () => { + + describe('With multiple different subscriber registries', () => { + let elem, registry; + + beforeEach(async() => { + elem = await fixture(`
+ <${separateRegistries} id="separate"> + <${eventSubscriber} id="event"> + + <${idSubscriber} id="id" for="separate"> +
`); + await elem.updateComplete; + registry = elem.querySelector('#separate'); + }); + + it('Event and id subscribers were registered properly and onSubscribe was called', () => { + expect(registry._eventSubscribers.subscribers.size).to.equal(1); + expect(registry._eventSubscribers.subscribers.has(elem.querySelector('#event'))).to.be.true; + expect(registry._idSubscribers.subscribers.size).to.equal(1); + expect(registry._idSubscribers.subscribers.has(elem.querySelector('#id'))).to.be.true; + + expect(registry._onSubscribeTargets.length).to.equal(2); + expect(registry._onSubscribeTargets[0]).to.equal(elem.querySelector('#id')); + expect(registry._onSubscribeTargets[1]).to.equal(elem.querySelector('#event')); + }); + + it('Additional subscribers can be subscribed manually', () => { + const newNode = document.createElement('div'); + registry._eventSubscribers.subscribe(newNode); + expect(registry._eventSubscribers.subscribers.size).to.equal(2); + expect(registry._eventSubscribers.subscribers.has(newNode)).to.be.true; + expect(registry._onSubscribeTargets.length).to.equal(3); + expect(registry._onSubscribeTargets[2]).to.equal(newNode); + + registry._idSubscribers.subscribe(newNode); + expect(registry._idSubscribers.subscribers.size).to.equal(2); + expect(registry._idSubscribers.subscribers.has(newNode)).to.be.true; + expect(registry._onSubscribeTargets.length).to.equal(4); + expect(registry._onSubscribeTargets[3]).to.equal(newNode); + }); + + it('Event and id subscribers are unsubscribed properly and onUnsubscribe is called', async() => { + expect(registry._onUnsubscribeTargets.length).to.equal(0); + + let removed = elem.querySelector('#event'); + removed.remove(); + await registry.updateComplete; + expect(registry._eventSubscribers.subscribers.size).to.equal(0); + expect(registry._onUnsubscribeTargets.length).to.equal(1); + expect(registry._onUnsubscribeTargets[0]).to.equal(removed); + + removed = elem.querySelector('#id'); + removed.remove(); + await registry.updateComplete; + expect(registry._idSubscribers.subscribers.size).to.equal(0); + expect(registry._onUnsubscribeTargets.length).to.equal(2); + expect(registry._onUnsubscribeTargets[1]).to.equal(removed); + }); + + it('Subscribers can be unsubscribed manually', () => { + let removed = elem.querySelector('#event'); + registry._eventSubscribers.unsubscribe(removed); + expect(registry._eventSubscribers.subscribers.size).to.equal(0); + expect(registry._onUnsubscribeTargets.length).to.equal(1); + expect(registry._onUnsubscribeTargets[0]).to.equal(removed); + + removed = elem.querySelector('#id'); + registry._idSubscribers.unsubscribe(removed); + expect(registry._idSubscribers.subscribers.size).to.equal(0); + expect(registry._onUnsubscribeTargets.length).to.equal(2); + expect(registry._onUnsubscribeTargets[1]).to.equal(removed); + }); + + it('Calls to updateSubscribers are debounced', async() => { + expect(registry._updateSubscribersCalledWith.length).to.equal(0); + + registry._idSubscribers.updateSubscribers(); + registry._eventSubscribers.updateSubscribers(); + registry._eventSubscribers.updateSubscribers(); + registry._idSubscribers.updateSubscribers(); + registry._idSubscribers.updateSubscribers(); + registry._eventSubscribers.updateSubscribers(); + await nextFrame(); + await nextFrame(); + expect(registry._updateSubscribersCalledWith.length).to.equal(2); + expect(registry._updateSubscribersCalledWith[0]).to.equal(registry._idSubscribers.subscribers); + expect(registry._updateSubscribersCalledWith[1]).to.equal(registry._eventSubscribers.subscribers); + }); + }); + + describe('With both event and id subscribers added to the same registry', () => { + let elem, registry; + + beforeEach(async() => { + elem = await fixture(`
+ <${combinedRegistry} id="combined"> + <${eventSubscriber} id="event"> + + <${idSubscriber} id="id" for="combined"> +
`); + await elem.updateComplete; + registry = elem.querySelector('#combined'); + }); + + it('Event and id subscribers were registered properly and onSubscribe was called', () => { + expect(registry._subscribers.subscribers.size).to.equal(2); + expect(registry._subscribers.subscribers.has(elem.querySelector('#event'))).to.be.true; + expect(registry._subscribers.subscribers.has(elem.querySelector('#id'))).to.be.true; + + expect(registry._onSubscribeTargets.length).to.equal(2); + expect(registry._onSubscribeTargets[0]).to.equal(elem.querySelector('#id')); + expect(registry._onSubscribeTargets[1]).to.equal(elem.querySelector('#event')); + }); + + it('Additional subscribers can be subscribed manually', () => { + const newNode = document.createElement('div'); + registry._subscribers.subscribe(newNode); + expect(registry._subscribers.subscribers.size).to.equal(3); + expect(registry._subscribers.subscribers.has(newNode)).to.be.true; + expect(registry._onSubscribeTargets.length).to.equal(3); + expect(registry._onSubscribeTargets[2]).to.equal(newNode); + }); + + it('Event and id subscribers are unsubscribed properly and onUnsubscribe is called', async() => { + expect(registry._onUnsubscribeTargets.length).to.equal(0); + + let removed = elem.querySelector('#event'); + removed.remove(); + await registry.updateComplete; + expect(registry._subscribers.subscribers.size).to.equal(1); + expect(registry._onUnsubscribeTargets.length).to.equal(1); + expect(registry._onUnsubscribeTargets[0]).to.equal(removed); + expect(registry._subscribers.subscribers.has(elem.querySelector('#id'))).to.be.true; + + removed = elem.querySelector('#id'); + removed.remove(); + await registry.updateComplete; + expect(registry._subscribers.subscribers.size).to.equal(0); + expect(registry._onUnsubscribeTargets.length).to.equal(2); + expect(registry._onUnsubscribeTargets[1]).to.equal(removed); + }); + + it('Subscribers can be unsubscribed manually', () => { + let removed = elem.querySelector('#event'); + registry._subscribers.unsubscribe(removed); + expect(registry._subscribers.subscribers.size).to.equal(1); + expect(registry._onUnsubscribeTargets.length).to.equal(1); + expect(registry._onUnsubscribeTargets[0]).to.equal(removed); + expect(registry._subscribers.subscribers.has(elem.querySelector('#id'))).to.be.true; + + removed = elem.querySelector('#id'); + registry._subscribers.unsubscribe(removed); + expect(registry._subscribers.subscribers.size).to.equal(0); + expect(registry._onUnsubscribeTargets.length).to.equal(2); + expect(registry._onUnsubscribeTargets[1]).to.equal(removed); + }); + + it('Calls to updateSubscribers are debounced', async() => { + expect(registry._updateSubscribersCalledWith.length).to.equal(0); + + registry._subscribers.updateSubscribers(); + registry._subscribers.updateSubscribers(); + registry._subscribers.updateSubscribers(); + await nextFrame(); + expect(registry._updateSubscribersCalledWith.length).to.equal(1); + expect(registry._updateSubscribersCalledWith[0]).to.equal(registry._subscribers.subscribers); + }); + }); +}); + +describe('EventSubscriberController', () => { + let elem; + + beforeEach(async() => { + elem = await fixture(`
+ <${separateRegistries} id="registry"> + <${eventSubscriber} id="success"> + + <${eventSubscriber} id="error"> +
`); + await elem.updateComplete; + }); + + it('Call onSubscribe after subscribing and getting the registry component', () => { + const subscriber = elem.querySelector('#success'); + expect(subscriber._onSubscribeRegistry).to.equal(elem.querySelector('#registry')); + expect(subscriber._onError).to.be.false; + }); + + it('Call onError if we did not find a registry component', () => { + const subscriber = elem.querySelector('#error'); + expect(subscriber._onSubscribeRegistry).to.be.null; + expect(subscriber._onError).to.be.true; + }); +}); + +describe('IdSubscriberController', () => { + let elem; + const fixtureHtml = `
+ <${separateRegistries} id="registry-1"> + <${idSubscriber} id="nested" for="registry-1"> + + <${separateRegistries} id="registry-2"> + <${idSubscriber} id="single" for="registry-1"> + <${idSubscriber} id="multiple" for="registry-1 registry-2 non-existant"> + <${idSubscriber} id="error" for="non-existant"> +
`; + + describe('Adding and removing', () => { + beforeEach(async() => { + elem = await fixture(fixtureHtml); + await elem.updateComplete; + }); + + it('Call onSubscribe after subscribing and getting the registry component', () => { + const nestedSubscriber = elem.querySelector('#nested'); + expect(nestedSubscriber._onSubscribeRegistries.length).to.equal(1); + expect(nestedSubscriber._onSubscribeRegistries[0]).to.equal(elem.querySelector('#registry-1')); + + const singleSubscriber = elem.querySelector('#single'); + expect(singleSubscriber._onSubscribeRegistries.length).to.equal(1); + expect(singleSubscriber._onSubscribeRegistries[0]).to.equal(elem.querySelector('#registry-1')); + + const multipleSubscriber = elem.querySelector('#multiple'); + expect(multipleSubscriber._onSubscribeRegistries.length).to.equal(2); + expect(multipleSubscriber._onSubscribeRegistries[0]).to.equal(elem.querySelector('#registry-1')); + expect(multipleSubscriber._onSubscribeRegistries[1]).to.equal(elem.querySelector('#registry-2')); + }); + + it('If a registry component is removed, registry maps are updated', async() => { + const registry1 = elem.querySelector('#registry-1'); + const registry2 = elem.querySelector('#registry-2'); + const singleSubscriber = elem.querySelector('#single'); + const multipleSubscriber = elem.querySelector('#multiple'); + expect(singleSubscriber._onUnsubscribeRegistryIds.length).to.equal(0); + expect(multipleSubscriber._onUnsubscribeRegistryIds.length).to.equal(0); + + registry1.remove(); + await singleSubscriber.updateComplete; + await multipleSubscriber.updateComplete; + + expect(singleSubscriber._onUnsubscribeRegistryIds.length).to.equal(1); + expect(singleSubscriber._onUnsubscribeRegistryIds[0]).to.equal('registry-1'); + expect(singleSubscriber._subscriberController.registries.length).to.equal(0); + expect(multipleSubscriber._onUnsubscribeRegistryIds.length).to.equal(1); + expect(multipleSubscriber._onUnsubscribeRegistryIds[0]).to.equal('registry-1'); + expect(multipleSubscriber._subscriberController.registries.length).to.equal(1); + expect(multipleSubscriber._subscriberController.registries[0]).to.equal(registry2); + }); + + it('If a registry component is added, the registry and subscriber maps are updated', async() => { + const errorSubscriber = elem.querySelector('#error'); + const multipleSubscriber = elem.querySelector('#multiple'); + expect(errorSubscriber._onSubscribeRegistries.length).to.equal(0); + expect(errorSubscriber._subscriberController.registries.length).to.equal(0); + expect(multipleSubscriber._onSubscribeRegistries.length).to.equal(2); + expect(multipleSubscriber._subscriberController.registries.length).to.equal(2); + + const newNode = document.createElement(`${combinedRegistry}`); + newNode.id = 'non-existant'; + elem.appendChild(newNode); + await newNode.updateComplete; + + expect(errorSubscriber._onSubscribeRegistries.length).to.equal(1); + expect(errorSubscriber._onSubscribeRegistries[0]).to.equal(newNode); + expect(errorSubscriber._subscriberController.registries.length).to.equal(1); + expect(errorSubscriber._subscriberController.registries[0]).to.equal(newNode); + expect(multipleSubscriber._onSubscribeRegistries.length).to.equal(3); + expect(multipleSubscriber._onSubscribeRegistries[2]).to.equal(newNode); + expect(multipleSubscriber._subscriberController.registries.length).to.equal(3); + expect(multipleSubscriber._subscriberController.registries[2]).to.equal(newNode); + expect(newNode.getController().subscribers.size).to.equal(2); + expect(newNode.getController().subscribers.has(errorSubscriber)).to.be.true; + expect(newNode.getController().subscribers.has(multipleSubscriber)).to.be.true; + }); + + it('If the list of registry ids changes, the registry and subscriber maps are updated', async() => { + const registry1 = elem.querySelector('#registry-1'); + const registry2 = elem.querySelector('#registry-2'); + const singleSubscriber = elem.querySelector('#single'); + expect(singleSubscriber._onUnsubscribeRegistryIds.length).to.equal(0); + expect(singleSubscriber._subscriberController.registries.length).to.equal(1); + expect(singleSubscriber._subscriberController.registries[0]).to.equal(registry1); + expect(registry1.getController('id').subscribers.has(singleSubscriber)).to.be.true; + expect(registry2.getController('id').subscribers.has(singleSubscriber)).to.be.false; + + singleSubscriber.for = 'registry-2'; + await singleSubscriber.updateComplete; + + expect(singleSubscriber._onSubscribeRegistries.length).to.equal(2); + expect(singleSubscriber._onSubscribeRegistries[1]).to.equal(registry2); + expect(singleSubscriber._onUnsubscribeRegistryIds.length).to.equal(1); + expect(singleSubscriber._onUnsubscribeRegistryIds[0]).to.equal('registry-1'); + expect(singleSubscriber._subscriberController.registries.length).to.equal(1); + expect(singleSubscriber._subscriberController.registries[0]).to.equal(registry2); + expect(registry1.getController('id').subscribers.has(singleSubscriber)).to.be.false; + expect(registry2.getController('id').subscribers.has(singleSubscriber)).to.be.true; + }); + }); + + describe('Error handling', () => { + let clock; + + beforeEach(async() => { + clock = sinon.useFakeTimers({ toFake: ['setTimeout'] }); + elem = await fixture(fixtureHtml); + await elem.updateComplete; + }); + afterEach(() => { + clock.restore(); + }); + + it('Call onError if we did not find a registry component with the provided id', async() => { + const errorSubscriber = elem.querySelector('#error'); + const multipleSubscriber = elem.querySelector('#multiple'); + expect(errorSubscriber._onErrorRegistryIds).to.be.empty; + expect(multipleSubscriber._onErrorRegistryIds).to.be.empty; + + clock.tick(2999); + expect(errorSubscriber._onErrorRegistryIds).to.be.empty; + expect(multipleSubscriber._onErrorRegistryIds).to.be.empty; + clock.tick(1); + + expect(errorSubscriber._onSubscribeRegistries).to.be.empty; + expect(errorSubscriber._onErrorRegistryIds.length).to.equal(1); + expect(errorSubscriber._onErrorRegistryIds[0]).to.equal('non-existant'); + expect(multipleSubscriber._onSubscribeRegistries.length).to.equal(2); + expect(multipleSubscriber._onSubscribeRegistries[0]).to.equal(elem.querySelector('#registry-1')); + expect(multipleSubscriber._onSubscribeRegistries[1]).to.equal(elem.querySelector('#registry-2')); + expect(multipleSubscriber._onErrorRegistryIds.length).to.equal(1); + expect(multipleSubscriber._onErrorRegistryIds[0]).to.equal('non-existant'); + }); + }); +}); diff --git a/package.json b/package.json index e7de539bc5d..083d70f7769 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "build": "npm run build:clean && npm run build:icons && npm run build:sass", "lint": "npm run lint:eslint && npm run lint:style && npm run lint:lit", "lint:eslint": "eslint . --ext .js,.html", - "lint:lit": "lit-analyzer \"{components,directives,helpers,mixins,templates,test,tools}/**/*.js\" --strict", + "lint:lit": "lit-analyzer \"{components,controllers,directives,helpers,mixins,templates,test,tools}/**/*.js\" --strict", "lint:style": "stylelint \"**/*.{js,html}\"", "start": "web-dev-server --node-resolve --watch --open", "test": "npm run lint && npm run test:headless && npm run test:axe", @@ -26,6 +26,7 @@ "files": [ "custom-elements.json", "/components", + "/controllers", "/directives", "/generated", "/helpers", diff --git a/web-test-runner.config.js b/web-test-runner.config.js index 5686eacc1db..951ca40d779 100644 --- a/web-test-runner.config.js +++ b/web-test-runner.config.js @@ -2,7 +2,7 @@ import { playwrightLauncher } from '@web/test-runner-playwright'; import { renderPerformancePlugin } from 'web-test-runner-performance'; function getPattern(type) { - return `+(components|directives|helpers|mixins|templates)/**/*.${type}.js`; + return `+(components|controllers|directives|helpers|mixins|templates)/**/*.${type}.js`; } export default {