diff --git a/helpers/README.md b/helpers/README.md index 3c6e83d3fc9..884b063ebef 100644 --- a/helpers/README.md +++ b/helpers/README.md @@ -187,6 +187,45 @@ import { getLegacyOffsetParent } from '@brightspace-ui/core/helpers/offsetParent const offsetParent = getLegacyOffsetParent(element); ``` +## Plugins + +Plugin helpers provide a way for modules to implement and register objects that can be plugged into other project's modules without requiring the plugin consumer to import those modules or objects directly. A higher order module (ex. in BSI) is responsible for importing the plugin registrations. + +The plugin implementor uses the `registerPlugin` helper method to make its implementation available to interested consumers. The implementor provides a key for the set of plugins in which to register and the plugin implementation. + +Optionally, an object to specify a `key` for the plugin and/or the `sort` value may be provided. The `key` is useful if consumers intend to request a specific plugin, while the `sort` is useful in cases where the order of plugins is important to consumers. If `sort` is not specified for at least one plugin, they will be provided to consumers in registration order. + +**Important!** plugin registrations should defer loading their dependencies using dynamic imports. They should **not** be synchronously imported in the registration module. + +```js +import { registerPlugin } from '@brightspace-ui/core/helpers/plugins.js'; + +// Provide plugin set key, plugin +registerPlugin('foo-plugins', { prop1: 'some value' }); +registerPlugin('foo-plugins', { prop1: 'other value' }); + +// Optionally provide key and/or sort value +registerPlugin('foo-plugins', { prop1: 'some value' }, { key: 'key-1', sort: 1 }); +registerPlugin('foo-plugins', { prop1: 'other value' }, { key: 'key-2', sort: 2 }); + +// Defer loading dependencies until needed +registerPlugin('foo-plugins', { getRenderer: async () => { + return (await import('./some-module.js')).renderer +}}); +``` + +The plugin consumer uses the `getPlugins` helper method to get references to the registered plugins by providing a key for the set of plugins. If the consumer knows the key of the plugin it needs, it can request the plugin by using `tryGetPluginByKey` and specifying the plugin set key and plugin key. + +```js +import { getPlugins, tryGetPluginByKey } from '@brightspace-ui/core/helpers/plugins.js'; + +// Call getPlugins to get plugins +const plugins = getPlugins('foo-plugins'); + +// Call tryGetPluginByKey to get a specific plugin by key +const plugin = tryGetPluginByKey('foo-plugins', 'key-1'); +``` + ## queueMicrotask A polyfill for [queueMicrotask](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/queueMicrotask). For more information on microtasks, read [this article from Mozilla](https://developer.mozilla.org/en-US/docs/Web/API/HTML_DOM_API/Microtask_guide). diff --git a/helpers/plugins.js b/helpers/plugins.js new file mode 100644 index 00000000000..ad8e985ab97 --- /dev/null +++ b/helpers/plugins.js @@ -0,0 +1,46 @@ +const pluginSets = new Map(); + +function getPluginSet(setKey) { + let pluginSet = pluginSets.get(setKey); + if (pluginSet) return pluginSet; + + pluginSet = { plugins: [], requested: false, requiresSorting: false }; + pluginSets.set(setKey, pluginSet); + return pluginSet; +} + +export function getPlugins(setKey) { + const pluginSet = getPluginSet(setKey); + pluginSet.requested = true; + if (pluginSet.requiresSorting) { + pluginSet.plugins.sort((item1, item2) => item1.options.sort - item2.options.sort); + pluginSet.requiresSorting = false; + } + return pluginSet.plugins.map(item => item.plugin); +} + +export function registerPlugin(setKey, plugin, options) { + const pluginSet = getPluginSet(setKey); + + if (pluginSet.requested) { + throw new Error(`Plugin Set "${setKey}" has already been requested. Additional plugin registrations would result in stale consumer plugins.`); + } else if (options?.key !== undefined) { + if (pluginSet.plugins.find(registeredPlugin => registeredPlugin.options.key === options?.key)) { + throw new Error(`Plugin Set "${setKey}" already has a plugin with the key "${options.key}".`); + } + } + + pluginSet.plugins.push({ plugin, options: Object.assign({ key: undefined, sort: 0 }, options) }); + pluginSet.requiresSorting = pluginSet.requiresSorting || (options?.sort !== undefined); +} + +// Do not import! Testing only!! +export function resetPlugins() { + pluginSets.clear(); +} + +export function tryGetPluginByKey(setKey, pluginKey) { + const pluginSet = pluginSets.get(setKey); + const plugin = pluginSet?.plugins.find(plugin => plugin.options.key === pluginKey)?.plugin; + return plugin || null; +} diff --git a/helpers/test/plugins.test.js b/helpers/test/plugins.test.js new file mode 100644 index 00000000000..cadafa59736 --- /dev/null +++ b/helpers/test/plugins.test.js @@ -0,0 +1,103 @@ +import { getPlugins, registerPlugin, resetPlugins, tryGetPluginByKey } from '../plugins.js'; +import { expect } from '@brightspace-ui/testing'; + +describe('plugins', () => { + + afterEach(() => { + resetPlugins(); + }); + + describe('default', () => { + + beforeEach(() => { + registerPlugin('test-plugins', { prop1: 'beer' }); + registerPlugin('test-plugins', { prop1: 'donuts' }); + }); + + it('getPlugins should return empty array for invalid plugin set key', () => { + const plugins = getPlugins('invalid-plugin-set-key'); + expect(plugins.length).to.equal(0); + }); + + it('getPlugins should return array of plugins in registration order', () => { + const plugins = getPlugins('test-plugins'); + expect(plugins.length).to.equal(2); + expect(plugins[0].prop1).to.equal('beer'); + expect(plugins[1].prop1).to.equal('donuts'); + }); + + it('getPlugins should return copy of the array for each consumer', () => { + const plugins1 = getPlugins('test-plugins'); + const plugins2 = getPlugins('test-plugins'); + expect(plugins1).not.to.equal(plugins2); + }); + + it('registerPlugin should throw when called after a consumer has called getPlugins for the same Set key', () => { + getPlugins('test-plugins'); + expect(() => { + registerPlugin('test-plugins', { prop1: 'candy apple' }); + }).to.throw(); + }); + + it('registerPlugin should not throw when called after a consumer has called getPlugins for a different Set key', () => { + getPlugins('test-plugins'); + expect(() => { + registerPlugin('test-plugins-other', { prop1: 'candy apple' }); + }).to.not.throw(); + }); + + }); + + describe('sorted', () => { + + beforeEach(() => { + registerPlugin('test-plugins', { prop1: 'beer' }, { sort: 3 }); + registerPlugin('test-plugins', { prop1: 'donuts' }, { sort: 1 }); + }); + + it('getPlugins should return array of plugins in sort order', () => { + const plugins = getPlugins('test-plugins'); + expect(plugins.length).to.equal(2); + expect(plugins[0].prop1).to.equal('donuts'); + expect(plugins[1].prop1).to.equal('beer'); + }); + + }); + + describe('keyed', () => { + + beforeEach(() => { + registerPlugin('test-plugins', { prop1: 'beer' }, { key: 'plugin1' }); + registerPlugin('test-plugins', { prop1: 'donuts' }, { key: 'plugin2' }); + }); + + it('getPlugin should return undefined for invalid plugin set key', () => { + const plugin = tryGetPluginByKey('invalid-plugin-set-key', 'plugin1'); + expect(plugin).to.be.null; + }); + + it('getPlugin should return undefined for invalid plugin key', () => { + const plugin = tryGetPluginByKey('test-plugins', 'pluginx'); + expect(plugin).to.be.null; + }); + + it('getPlugin should return plugin for specified keys', () => { + const plugin = tryGetPluginByKey('test-plugins', 'plugin1'); + expect(plugin.prop1).to.equal('beer'); + }); + + it('registerPlugin should not throw when adding a plugin with key not used within the set', () => { + expect(() => { + registerPlugin('test-plugins-other', { prop1: 'candy apple' }, { key: 'plugin1' }); + }).to.not.throw(); + }); + + it('registerPlugin should throw when adding a plugin with key already used within the set', () => { + expect(() => { + registerPlugin('test-plugins', { prop1: 'candy apple' }, { key: 'plugin1' }); + }).to.throw(); + }); + + }); + +});