Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Plugins helpers #4274

Merged
merged 13 commits into from
Nov 23, 2023
39 changes: 39 additions & 0 deletions helpers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
44 changes: 44 additions & 0 deletions helpers/plugins.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
const pluginSets = new Map();
const pluginsRequested = new Set();

export function getPlugins(setKey) {
if (!pluginsRequested.has(setKey)) pluginsRequested.add(setKey);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another option would be to set something like a requested = true on the set, similar to requiresSorting.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I originally considered that but felt wrong because getPlugins would need to add to the pluginSets for the case where there are no registrations. But now that I think about it more, tracking the bit separately isn't really saving anything, whether the plugin Set key is valid or not. I think I will switch it.

const pluginSet = pluginSets.get(setKey);
if (!pluginSet) return [];
dbatiste marked this conversation as resolved.
Show resolved Hide resolved
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) {
if (pluginsRequested.has(setKey)) {
throw new Error(`Plugin Set "${setKey}" has already been requested. Additional plugin registrations would result in stale consumer plugins.`);
}

let pluginSet = pluginSets.get(setKey);
if (!pluginSet) {
pluginSet = { plugins: [], requiresSorting: false };
dbatiste marked this conversation as resolved.
Show resolved Hide resolved
pluginSets.set(setKey, pluginSet);
} 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}".`);
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: not sure if this is better or not, but another option that's 1 less line!

if (!pluginSets.has(setKey)) {
  pluginSets.set({ plugins: [], requiresSorting: false });
}
const pluginSet = pluginSets.get(setKey);

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh yeah... I think I might had that initially, but then switched it to only do the single look-up. The difference is probably negligible for the number of plugin sets we'll have though.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be O(1) lookup on the Map anyway.


pluginSet.plugins.push({ plugin, options: Object.assign({ key: undefined, sort: 0 }, options) });
pluginSet.requiresSorting = pluginSet.requiresSorting || (options?.sort !== undefined);
dlockhart marked this conversation as resolved.
Show resolved Hide resolved
}

// Do not import! Testing only!!
export function resetPlugins() {
pluginSets.clear();
pluginsRequested.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;
}
103 changes: 103 additions & 0 deletions helpers/test/plugins.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { getPlugins, registerPlugin, resetPlugins, tryGetPluginByKey } from '../plugins.js';
import { expect } from '@brightspace-ui/testing';

describe('plugins', () => {

afterEach(() => {
resetPlugins();
});
dlockhart marked this conversation as resolved.
Show resolved Hide resolved

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();
});

});
dlockhart marked this conversation as resolved.
Show resolved Hide resolved

});