feat: add support for chart plugins (#599)

* feat: add support for chart plugins

* docs: adjust and generate specs

* docs: add plugins.md file and update api specs

* docs: add plugin example
This commit is contained in:
Christian Veinfors
2021-04-27 16:48:06 +02:00
committed by GitHub
parent ebecd388c0
commit ae75817426
25 changed files with 415 additions and 17 deletions

View File

@@ -1,10 +1,11 @@
/* eslint no-underscore-dangle:0 */
const doMock = ({ glue = () => {}, getPatches = () => {}, objectConversion = {} } = {}) =>
const doMock = ({ glue = () => {}, getPatches = () => {}, objectConversion = {}, validatePlugins = () => {} } = {}) =>
aw.mock(
[
['**/components/glue.jsx', () => glue],
['**/utils/patcher.js', () => getPatches],
['@nebula.js/conversion', () => objectConversion],
['**/plugins/plugins.js', () => validatePlugins],
],
['../viz.js']
);
@@ -21,20 +22,24 @@ describe('viz', () => {
let cellRef;
let setSnOptions;
let setSnContext;
let setSnPlugins;
let takeSnapshot;
let exportImage;
let objectConversion;
let validatePlugins;
before(() => {
sandbox = sinon.createSandbox();
unmount = sandbox.spy();
setSnOptions = sandbox.spy();
setSnContext = sandbox.spy();
setSnPlugins = sandbox.spy();
takeSnapshot = sandbox.spy();
exportImage = sandbox.spy();
cellRef = {
current: {
setSnOptions,
setSnContext,
setSnPlugins,
takeSnapshot,
exportImage,
},
@@ -42,7 +47,8 @@ describe('viz', () => {
glue = sandbox.stub().returns([unmount, cellRef]);
getPatches = sandbox.stub().returns(['patch']);
objectConversion = { convertTo: sandbox.stub().returns('props') };
[{ default: create }] = doMock({ glue, getPatches, objectConversion });
validatePlugins = sandbox.spy();
[{ default: create }] = doMock({ glue, getPatches, objectConversion, validatePlugins });
model = {
getEffectiveProperties: sandbox.stub().returns('old'),
applyPatches: sandbox.spy(),
@@ -131,6 +137,22 @@ describe('viz', () => {
});
});
describe('plugins', () => {
it('should set sn plugins', async () => {
const plugins = [{ info: { name: 'testplugin' }, fn: () => {} }];
api.__DO_NOT_USE__.plugins(plugins);
await mounted;
expect(cellRef.current.setSnPlugins).to.have.been.calledWithExactly(plugins);
});
it('should validate plugins', async () => {
const plugins = [{ info: { name: 'testplugin' }, fn: () => {} }];
api.__DO_NOT_USE__.plugins(plugins);
await mounted;
expect(validatePlugins).to.have.been.calledWithExactly(plugins);
});
});
describe('snapshot', () => {
it('should take a snapshot', async () => {
api.__DO_NOT_USE__.takeSnapshot();

View File

@@ -269,7 +269,7 @@ const loadType = async ({ dispatch, types, visualization, version, model, app, s
return undefined;
};
const Cell = forwardRef(({ halo, model, initialSnOptions, initialError, onMount }, ref) => {
const Cell = forwardRef(({ halo, model, initialSnOptions, initialSnPlugins, initialError, onMount }, ref) => {
const { app, types } = halo;
const { translator, language } = useContext(InstanceContext);
@@ -280,6 +280,7 @@ const Cell = forwardRef(({ halo, model, initialSnOptions, initialError, onMount
const [appLayout] = useAppLayout(app);
const [contentRef, contentRect] = useRect();
const [snOptions, setSnOptions] = useState(initialSnOptions);
const [snPlugins, setSnPlugins] = useState(initialSnPlugins);
const [selections] = useObjectSelections(app, model);
const [hovering, setHover] = useState(false);
const hoveringDebouncer = useRef({ enter: null, leave: null });
@@ -375,6 +376,7 @@ const Cell = forwardRef(({ halo, model, initialSnOptions, initialError, onMount
return state.sn.generator.qae;
},
setSnOptions,
setSnPlugins,
async takeSnapshot() {
const { width, height } = cellRect;
@@ -423,6 +425,7 @@ const Cell = forwardRef(({ halo, model, initialSnOptions, initialError, onMount
sn={state.sn}
halo={halo}
snOptions={snOptions}
snPlugins={snPlugins}
layout={layout}
appLayout={appLayout}
/>

View File

@@ -12,7 +12,7 @@ const VizElement = {
className: 'njs-viz',
};
const Supernova = ({ sn, snOptions: options, layout, appLayout, halo }) => {
const Supernova = ({ sn, snOptions: options, snPlugins: plugins, layout, appLayout, halo }) => {
const { component } = sn;
const { theme: themeName, language, constraints } = useContext(InstanceContext);
@@ -75,6 +75,7 @@ const Supernova = ({ sn, snOptions: options, layout, appLayout, halo }) => {
component.render({
layout,
options,
plugins,
context: {
constraints,
// halo.public.theme is a singleton so themeName is used as dep to make sure this effect is triggered
@@ -99,7 +100,19 @@ const Supernova = ({ sn, snOptions: options, layout, appLayout, halo }) => {
setRenderCnt(renderCnt + 1);
});
}, 10);
}, [containerRect, options, snNode, containerNode, layout, appLayout, themeName, language, constraints, isMounted]);
}, [
containerRect,
options,
plugins,
snNode,
containerNode,
layout,
appLayout,
themeName,
language,
constraints,
isMounted,
]);
return (
<div

View File

@@ -574,6 +574,7 @@ describe('<Cell />', () => {
await render({ model, types, cellRef });
expect(cellRef.current.setSnOptions).to.be.a('function');
expect(cellRef.current.setSnPlugins).to.be.a('function');
expect(cellRef.current.exportImage).to.be.a('function');
expect(cellRef.current.takeSnapshot).to.be.a('function');
expect(cellRef.current.getQae).to.be.a('function');

View File

@@ -18,6 +18,7 @@ describe('<Supernova />', () => {
render = async ({
sn = { component: {} },
snOptions = {},
snPlugins = [],
layout = {},
appLayout = {},
halo = {},
@@ -25,7 +26,14 @@ describe('<Supernova />', () => {
} = {}) => {
await act(async () => {
renderer = create(
<Supernova sn={sn} snOptions={snOptions} layout={layout} appLayout={appLayout} halo={halo} />,
<Supernova
sn={sn}
snOptions={snOptions}
snPlugins={snPlugins}
layout={layout}
appLayout={appLayout}
halo={halo}
/>,
rendererOptions || null
);
});
@@ -43,6 +51,7 @@ describe('<Supernova />', () => {
component: {},
},
snOptions: {},
snPlugins: [],
layout: {},
appLayout: {},
halo: {},
@@ -95,6 +104,7 @@ describe('<Supernova />', () => {
component,
},
snOptions,
snPlugins: [],
layout: 'layout',
appLayout: { qLocaleInfo: 'loc' },
halo: { public: { theme: 'theme' }, app: { session: {} } },
@@ -115,6 +125,7 @@ describe('<Supernova />', () => {
expect(component.render.getCall(0).args[0]).to.eql({
layout: 'layout',
options: snOptions,
plugins: [],
context: {
constraints: {},
appLayout: { qLocaleInfo: 'loc' },

View File

@@ -2,7 +2,7 @@ import React from 'react';
import ReactDOM from 'react-dom';
import Cell from './Cell';
export default function glue({ halo, element, model, initialSnOptions, onMount, initialError }) {
export default function glue({ halo, element, model, initialSnOptions, initialSnPlugins, onMount, initialError }) {
const { root } = halo;
const cellRef = React.createRef();
const portal = ReactDOM.createPortal(
@@ -11,6 +11,7 @@ export default function glue({ halo, element, model, initialSnOptions, onMount,
halo={halo}
model={model}
initialSnOptions={initialSnOptions}
initialSnPlugins={initialSnPlugins}
initialError={initialError}
onMount={onMount}
/>,

View File

@@ -91,11 +91,14 @@ describe('create-session-object', () => {
});
it('should call init', async () => {
const ret = await create({ type: 't', version: 'v', fields: 'f', properties: 'props', options: 'a' }, halo);
const ret = await create(
{ type: 't', version: 'v', fields: 'f', properties: 'props', options: 'a', plugins: [] },
halo
);
expect(ret).to.equal('api');
expect(init).to.have.been.calledWithExactly(
objectModel,
{ options: 'a', element: undefined },
{ options: 'a', plugins: [], element: undefined },
halo,
undefined,
sinon.match.func
@@ -110,7 +113,7 @@ describe('create-session-object', () => {
expect(ret).to.equal('api');
expect(init).to.have.been.calledWithExactly(
objectModel,
{ options: 'opts', element: 'el' },
{ options: 'opts', plugins: undefined, element: 'el' },
halo,
err,
sinon.match.func

View File

@@ -38,8 +38,8 @@ describe('get-object', () => {
it('should call init', async () => {
objectModel.withArgs('x').returns(model);
const ret = await create({ id: 'x', options: 'op', element: 'el' }, context);
const ret = await create({ id: 'x', options: 'op', plugins: [], element: 'el' }, context);
expect(ret).to.equal('api');
expect(init).to.have.been.calledWithExactly(model, { options: 'op', element: 'el' }, context);
expect(init).to.have.been.calledWithExactly(model, { options: 'op', plugins: [], element: 'el' }, context);
});
});

View File

@@ -18,6 +18,7 @@ describe('initiate api', () => {
__DO_NOT_USE__: {
mount: sandbox.stub(),
options: sandbox.stub(),
plugins: sandbox.stub(),
},
};
viz.returns(api);
@@ -44,4 +45,10 @@ describe('initiate api', () => {
await create(model, { options: 'opts' }, halo);
expect(api.__DO_NOT_USE__.options).to.have.been.calledWithExactly('opts');
});
it('should call plugins when provided ', async () => {
const plugins = [{ info: { name: 'plugino' }, fn() {} }];
await create(model, { plugins }, halo);
expect(api.__DO_NOT_USE__.plugins).to.have.been.calledWithExactly(plugins);
});
});

View File

@@ -15,7 +15,10 @@ import { subscribe, modelStore } from '../stores/model-store';
* @property {(Field[])=} fields
* @property {qae.GenericObjectProperties=} properties
*/
export default async function createSessionObject({ type, version, fields, properties, options, element }, halo) {
export default async function createSessionObject(
{ type, version, fields, properties, options, plugins, element },
halo
) {
let mergedProps = {};
let error;
try {
@@ -54,5 +57,5 @@ export default async function createSessionObject({ type, version, fields, prope
await halo.app.destroySessionObject(model.id);
unsubscribe();
};
return init(model, { options, element }, halo, error, onDestroy);
return init(model, { options, plugins, element }, halo, error, onDestroy);
}

View File

@@ -6,6 +6,7 @@ import { modelStore, rpcRequestModelStore } from '../stores/model-store';
* @description Basic rendering configuration for rendering an object
* @property {HTMLElement} element
* @property {object=} options
* @property {Plugin[]} [plugins]
*/
/**
@@ -15,7 +16,7 @@ import { modelStore, rpcRequestModelStore } from '../stores/model-store';
* @property {string} id
*/
export default async function getObject({ id, options, element }, halo) {
export default async function getObject({ id, options, plugins, element }, halo) {
const key = `${id}`;
let rpc = rpcRequestModelStore.get(key);
if (!rpc) {
@@ -24,5 +25,5 @@ export default async function getObject({ id, options, element }, halo) {
}
const model = await rpc;
modelStore.set(key, model);
return init(model, { options, element }, halo);
return init(model, { options, plugins, element }, halo);
}

View File

@@ -11,6 +11,9 @@ export default async function init(model, optional, halo, initialError, onDestro
if (optional.options) {
api.__DO_NOT_USE__.options(optional.options);
}
if (optional.plugins) {
api.__DO_NOT_USE__.plugins(optional.plugins);
}
if (optional.element) {
await api.__DO_NOT_USE__.mount(optional.element);
}

View File

@@ -0,0 +1,32 @@
import validatePlugins from '../plugins';
describe('get-object', () => {
it('should throw when plugins is not an array', () => {
const plugins = {};
const validateFn = () => validatePlugins(plugins);
expect(validateFn).to.throw('Invalid plugin format: plugins should be an array!');
});
it('should throw when plugin is not an object', () => {
const plugins = ['blabla'];
const validateFn = () => validatePlugins(plugins);
expect(validateFn).to.throw('Invalid plugin format: a plugin should be an object');
});
it('should throw when plugin has no info object or name', () => {
const plugins1 = [{}];
const plugins2 = [{ info: {} }];
expect(() => validatePlugins(plugins1)).to.throw(
'Invalid plugin format: a plugin should have an info object containing a name'
);
expect(() => validatePlugins(plugins2)).to.throw(
'Invalid plugin format: a plugin should have an info object containing a name'
);
});
it('should throw when plugin has no "fn" function', () => {
const plugins = [{ info: { name: 'blabla' } }];
const validateFn = () => validatePlugins(plugins);
expect(validateFn).to.throw('Invalid plugin format: The plugin "blabla" has no "fn" function');
});
});

View File

@@ -0,0 +1,36 @@
/**
* An object literal containing meta information about the plugin and a function containing the plugin implementation.
* @interface Plugin
* @property {object} info Object that can hold various meta info about the plugin
* @property {string} info.name The name of the plugin
* @property {function} fn The implementation of the plugin. Input and return value is up to the plugin implementation to decide based on its purpose.
* @experimental
* @since 1.2.0
* @example
* const plugin = {
* info: {
* name: "example-plugin",
* type: "meta-type",
* },
* fn: () => {
* // Plugin implementation goes here
* }
* };
*/
export default function validatePlugins(plugins) {
if (!Array.isArray(plugins)) {
throw new Error('Invalid plugin format: plugins should be an array!');
}
plugins.forEach((p) => {
if (typeof p !== 'object') {
throw new Error('Invalid plugin format: a plugin should be an object');
}
if (typeof p.info !== 'object' || typeof p.info.name !== 'string') {
throw new Error('Invalid plugin format: a plugin should have an info object containing a name');
}
if (typeof p.fn !== 'function') {
throw new Error(`Invalid plugin format: The plugin "${p.info.name}" has no "fn" function`);
}
});
}

View File

@@ -2,6 +2,7 @@
import { convertTo as conversionConvertTo } from '@nebula.js/conversion';
import glueCell from './components/glue';
import getPatches from './utils/patcher';
import validatePlugins from './plugins/plugins';
const noopi = () => {};
@@ -15,6 +16,7 @@ export default function viz({ model, halo, initialError, onDestroy = async () =>
});
let initialSnOptions = {};
let initialSnPlugins = [];
const setSnOptions = async (opts) => {
if (mountedReference) {
@@ -34,6 +36,19 @@ export default function viz({ model, halo, initialError, onDestroy = async () =>
}
};
const setSnPlugins = async (plugins) => {
validatePlugins(plugins);
if (mountedReference) {
(async () => {
await mounted;
cellRef.current.setSnPlugins(plugins);
})();
} else {
// Handle setting plugins before mount
initialSnPlugins = plugins;
}
};
/**
* @class
* @alias Viz
@@ -102,6 +117,7 @@ export default function viz({ model, halo, initialError, onDestroy = async () =>
element,
model,
initialSnOptions,
initialSnPlugins,
initialError,
onMount,
});
@@ -118,6 +134,9 @@ export default function viz({ model, halo, initialError, onDestroy = async () =>
options(opts) {
setSnOptions(opts);
},
plugins(plugins) {
setSnPlugins(plugins);
},
exportImage() {
return cellRef.current.exportImage();
},

View File

@@ -3,7 +3,7 @@
"info": {
"name": "@nebula.js/stardust",
"description": "Product and framework agnostic integration API for Qlik's Associative Engine",
"version": "1.1.0",
"version": "1.1.1",
"license": "MIT",
"stability": "stable"
},
@@ -365,6 +365,22 @@
"import { useDeviceType } from '@nebula.js/stardust';\n// ...\nconst deviceType = useDeviceType();\nif (deviceType === 'touch') { ... };"
]
},
"usePlugins": {
"description": "Gets the array of plugins provided when rendering the visualization.",
"kind": "function",
"params": [],
"returns": {
"description": "array of plugins.",
"kind": "array",
"items": {
"type": "#/definitions/Plugin"
}
},
"examples": [
"// provide plugins that can be used when rendering\nembed(app).render({\n element,\n type: 'my-chart',\n plugins: [plugin]\n});",
"// It's up to the chart implementation to make use of plugins in any way\nimport { usePlugins } from '@nebula.js/stardust';\n// ...\nconst plugins = usePlugins();\nplugins.forEach((plugin) => {\n // Invoke plugin\n plugin.fn();\n});"
]
},
"useAction": {
"description": "Registers a custom action.",
"templates": [
@@ -1077,6 +1093,13 @@
"options": {
"optional": true,
"type": "object"
},
"plugins": {
"optional": true,
"kind": "array",
"items": {
"type": "#/definitions/Plugin"
}
}
}
},
@@ -1115,6 +1138,33 @@
}
}
},
"Plugin": {
"description": "An object literal containing meta information about the plugin and a function containing the plugin implementation.",
"stability": "experimental",
"availability": {
"since": "1.2.0"
},
"kind": "interface",
"entries": {
"info": {
"description": "Object that can hold various meta info about the plugin",
"kind": "object",
"entries": {
"name": {
"description": "The name of the plugin",
"type": "string"
}
}
},
"fn": {
"description": "The implementation of the plugin. Input and return value is up to the plugin implementation to decide based on its purpose.",
"type": "function"
}
},
"examples": [
"const plugin = {\n info: {\n name: \"example-plugin\",\n type: \"meta-type\",\n },\n fn: () => {\n // Plugin implementation goes here\n }\n};"
]
},
"LoadType": {
"kind": "interface",
"params": [

View File

@@ -29,6 +29,7 @@ export {
useAppLayout,
useTranslator,
useDeviceType,
usePlugins,
useConstraints,
useOptions,
onTakeSnapshot,

View File

@@ -113,6 +113,7 @@ describe('creator', () => {
},
deviceType: 'desktop',
options: {},
plugins: [],
});
});
@@ -302,6 +303,43 @@ describe('creator', () => {
});
});
it('should run when plugins have changed', () => {
const c = create(generator, opts, galaxy).component;
c.render({}); // initial should always run
const plugins1 = [
{ info: { name: 'a' }, fn() {} },
{ info: { name: 'b' }, fn() {} },
];
const plugins2 = [
{ info: { name: 'a' }, fn() {} },
{ info: { name: 'b' }, fn() {} },
];
c.render({
plugins: plugins1,
});
expect(hooked.run.callCount).to.equal(2);
c.render({
plugins: plugins1,
});
expect(hooked.run.callCount).to.equal(2);
c.render({
plugins: plugins2,
});
expect(hooked.run.callCount).to.equal(3);
plugins2.pop();
c.render({
plugins: plugins2,
});
expect(hooked.run.callCount).to.equal(4);
expect(c.context.plugins).to.eql(plugins2);
});
it('should run when theme name has changed', () => {
const c = create(generator, opts, galaxy).component;
c.render({}); // initial should always run

View File

@@ -27,6 +27,7 @@ import {
useStaleLayout,
useAppLayout,
useTranslator,
usePlugins,
useConstraints,
useOptions,
onTakeSnapshot,
@@ -753,6 +754,7 @@ describe('hooks', () => {
element: 'element',
theme: 'theme',
translator: 'translator',
plugins: 'plugins',
layout: 'layout',
appLayout: 'appLayout',
constraints: 'constraints',
@@ -865,6 +867,14 @@ describe('hooks', () => {
run(c);
expect(value).to.equal('translator');
});
it('usePlugins', () => {
let value;
c.fn = () => {
value = usePlugins();
};
run(c);
expect(value).to.eql('plugins');
});
it('useConstraints', () => {
let value;
c.fn = () => {

View File

@@ -76,6 +76,7 @@ function createWithHooks(generator, opts, galaxy) {
appLayout: {},
constraints: forcedConstraints,
options: {},
plugins: [],
},
fn: generator.component.fn,
created() {},
@@ -118,6 +119,19 @@ function createWithHooks(generator, opts, galaxy) {
}
}
if (r.plugins) {
let pluginsChanged = this.context.plugins.length !== r.plugins.length;
r.plugins.forEach((plugin, index) => {
if (this.context.plugins[index] !== plugin) {
pluginsChanged = true;
}
});
if (pluginsChanged) {
this.context.plugins = [...r.plugins];
changed = true;
}
}
// do a deep check on 'small' objects
deepCheck.forEach((key) => {
const ref = r.context;

View File

@@ -718,6 +718,32 @@ export function useDeviceType() {
return useInternalContext('deviceType');
}
/**
* Gets the array of plugins provided when rendering the visualization.
* @entry
* @returns {Plugin[]} array of plugins.
* @example
* // provide plugins that can be used when rendering
* embed(app).render({
* element,
* type: 'my-chart',
* plugins: [plugin]
* });
*
* @example
* // It's up to the chart implementation to make use of plugins in any way
* import { usePlugins } from '@nebula.js/stardust';
* // ...
* const plugins = usePlugins();
* plugins.forEach((plugin) => {
* // Invoke plugin
* plugin.fn();
* });
*/
export function usePlugins() {
return useInternalContext('plugins');
}
/**
* @template A
* @interface ActionDefinition

View File

@@ -21,6 +21,7 @@ export {
useAppLayout,
useTranslator,
useDeviceType,
usePlugins,
useConstraints,
useOptions,
onTakeSnapshot,

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

BIN
docs/assets/simple-vis.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

103
docs/plugins.md Normal file
View File

@@ -0,0 +1,103 @@
---
id: plugins
title: Plugins
---
Plugins can be used to add or modify a visualization. Nebula provides a way to pass in plugins (through the `render` function of an `embed` instance), and to access provided plugins (through the `usePlugins` hook in the chart implementation).
## Render with plugins
```js
embed(app).render({
element,
type: 'my-chart',
plugins: [plugin],
});
```
## How to implement a plugin
A plugin needs to be an object literal, including plugin info (`name`) and a function, `fn`, in which the plugin is implemented.
```js
const plugin = {
info: {
name: 'example-plugin',
},
fn: () => {
// Plugin implementation goes here
},
};
```
The `info` object is a suitable place to add more meta information if needed. Input and return value of the `fn` function is up to the plugin implementation to decide based on its purpose.
## Accessing plugins in chart implementation
Plugins passed at rendering can be accessed through the `usePlugins` hook in the following way:
```js
import { usePlugins } from '@nebula.js/stardust';
// ...
const plugins = usePlugins();
plugins.forEach((plugin) => {
// Invoke plugin
plugin.fn();
});
```
## A simple example
A visualization rendering a random value.
```js
import { useElement, useEffect, usePlugins } from '@nebula.js/stardust';
export default function supernova() {
return {
component() {
const element = useElement();
const plugins = usePlugins();
useEffect(() => {
const randomValue = Math.random().toFixed(2) * 100;
let valueHtml;
if (plugins[0] && plugins[0].info.type === 'value-html') {
valueHtml = plugins[0].fn(randomValue);
} else {
valueHtml = `<span>${randomValue}</span>`;
}
element.innerHTML = `<div>Value: ${valueHtml}</div>`;
}, []);
},
};
}
```
The visualization looks for provided plugins with `type` set to "value-html", and if that is present it invokes the plugin to get html for the value element, which it inserts into the DOM.
### Rendering with a simple plugin
```js
const pinkStylePlugin = {
info: {
name: 'styling-plugin',
type: 'value-html',
},
fn(value) {
return `<span style="font-family: Arial; font-size: 25px;
text-shadow: 2px 2px 0px #d5d5d5; color: deeppink;">${value}</span>`;
},
};
// ...
embed(app).render({
'simple-vis',
element: el,
plugins: [pinkStylePlugin],
});
```
| No plugin | With plugin |
| :-----------------------------------: | :-------------------------------------------------: |
| ![No plugin](./assets/simple-vis.png) | ![With plugin](./assets/simple-vis-with-plugin.png) |