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 */ /* eslint no-underscore-dangle:0 */
const doMock = ({ glue = () => {}, getPatches = () => {}, objectConversion = {} } = {}) => const doMock = ({ glue = () => {}, getPatches = () => {}, objectConversion = {}, validatePlugins = () => {} } = {}) =>
aw.mock( aw.mock(
[ [
['**/components/glue.jsx', () => glue], ['**/components/glue.jsx', () => glue],
['**/utils/patcher.js', () => getPatches], ['**/utils/patcher.js', () => getPatches],
['@nebula.js/conversion', () => objectConversion], ['@nebula.js/conversion', () => objectConversion],
['**/plugins/plugins.js', () => validatePlugins],
], ],
['../viz.js'] ['../viz.js']
); );
@@ -21,20 +22,24 @@ describe('viz', () => {
let cellRef; let cellRef;
let setSnOptions; let setSnOptions;
let setSnContext; let setSnContext;
let setSnPlugins;
let takeSnapshot; let takeSnapshot;
let exportImage; let exportImage;
let objectConversion; let objectConversion;
let validatePlugins;
before(() => { before(() => {
sandbox = sinon.createSandbox(); sandbox = sinon.createSandbox();
unmount = sandbox.spy(); unmount = sandbox.spy();
setSnOptions = sandbox.spy(); setSnOptions = sandbox.spy();
setSnContext = sandbox.spy(); setSnContext = sandbox.spy();
setSnPlugins = sandbox.spy();
takeSnapshot = sandbox.spy(); takeSnapshot = sandbox.spy();
exportImage = sandbox.spy(); exportImage = sandbox.spy();
cellRef = { cellRef = {
current: { current: {
setSnOptions, setSnOptions,
setSnContext, setSnContext,
setSnPlugins,
takeSnapshot, takeSnapshot,
exportImage, exportImage,
}, },
@@ -42,7 +47,8 @@ describe('viz', () => {
glue = sandbox.stub().returns([unmount, cellRef]); glue = sandbox.stub().returns([unmount, cellRef]);
getPatches = sandbox.stub().returns(['patch']); getPatches = sandbox.stub().returns(['patch']);
objectConversion = { convertTo: sandbox.stub().returns('props') }; objectConversion = { convertTo: sandbox.stub().returns('props') };
[{ default: create }] = doMock({ glue, getPatches, objectConversion }); validatePlugins = sandbox.spy();
[{ default: create }] = doMock({ glue, getPatches, objectConversion, validatePlugins });
model = { model = {
getEffectiveProperties: sandbox.stub().returns('old'), getEffectiveProperties: sandbox.stub().returns('old'),
applyPatches: sandbox.spy(), 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', () => { describe('snapshot', () => {
it('should take a snapshot', async () => { it('should take a snapshot', async () => {
api.__DO_NOT_USE__.takeSnapshot(); api.__DO_NOT_USE__.takeSnapshot();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -91,11 +91,14 @@ describe('create-session-object', () => {
}); });
it('should call init', async () => { 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(ret).to.equal('api');
expect(init).to.have.been.calledWithExactly( expect(init).to.have.been.calledWithExactly(
objectModel, objectModel,
{ options: 'a', element: undefined }, { options: 'a', plugins: [], element: undefined },
halo, halo,
undefined, undefined,
sinon.match.func sinon.match.func
@@ -110,7 +113,7 @@ describe('create-session-object', () => {
expect(ret).to.equal('api'); expect(ret).to.equal('api');
expect(init).to.have.been.calledWithExactly( expect(init).to.have.been.calledWithExactly(
objectModel, objectModel,
{ options: 'opts', element: 'el' }, { options: 'opts', plugins: undefined, element: 'el' },
halo, halo,
err, err,
sinon.match.func sinon.match.func

View File

@@ -38,8 +38,8 @@ describe('get-object', () => {
it('should call init', async () => { it('should call init', async () => {
objectModel.withArgs('x').returns(model); 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(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__: { __DO_NOT_USE__: {
mount: sandbox.stub(), mount: sandbox.stub(),
options: sandbox.stub(), options: sandbox.stub(),
plugins: sandbox.stub(),
}, },
}; };
viz.returns(api); viz.returns(api);
@@ -44,4 +45,10 @@ describe('initiate api', () => {
await create(model, { options: 'opts' }, halo); await create(model, { options: 'opts' }, halo);
expect(api.__DO_NOT_USE__.options).to.have.been.calledWithExactly('opts'); 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 {(Field[])=} fields
* @property {qae.GenericObjectProperties=} properties * @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 mergedProps = {};
let error; let error;
try { try {
@@ -54,5 +57,5 @@ export default async function createSessionObject({ type, version, fields, prope
await halo.app.destroySessionObject(model.id); await halo.app.destroySessionObject(model.id);
unsubscribe(); 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 * @description Basic rendering configuration for rendering an object
* @property {HTMLElement} element * @property {HTMLElement} element
* @property {object=} options * @property {object=} options
* @property {Plugin[]} [plugins]
*/ */
/** /**
@@ -15,7 +16,7 @@ import { modelStore, rpcRequestModelStore } from '../stores/model-store';
* @property {string} id * @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}`; const key = `${id}`;
let rpc = rpcRequestModelStore.get(key); let rpc = rpcRequestModelStore.get(key);
if (!rpc) { if (!rpc) {
@@ -24,5 +25,5 @@ export default async function getObject({ id, options, element }, halo) {
} }
const model = await rpc; const model = await rpc;
modelStore.set(key, model); 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) { if (optional.options) {
api.__DO_NOT_USE__.options(optional.options); api.__DO_NOT_USE__.options(optional.options);
} }
if (optional.plugins) {
api.__DO_NOT_USE__.plugins(optional.plugins);
}
if (optional.element) { if (optional.element) {
await api.__DO_NOT_USE__.mount(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 { convertTo as conversionConvertTo } from '@nebula.js/conversion';
import glueCell from './components/glue'; import glueCell from './components/glue';
import getPatches from './utils/patcher'; import getPatches from './utils/patcher';
import validatePlugins from './plugins/plugins';
const noopi = () => {}; const noopi = () => {};
@@ -15,6 +16,7 @@ export default function viz({ model, halo, initialError, onDestroy = async () =>
}); });
let initialSnOptions = {}; let initialSnOptions = {};
let initialSnPlugins = [];
const setSnOptions = async (opts) => { const setSnOptions = async (opts) => {
if (mountedReference) { 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 * @class
* @alias Viz * @alias Viz
@@ -102,6 +117,7 @@ export default function viz({ model, halo, initialError, onDestroy = async () =>
element, element,
model, model,
initialSnOptions, initialSnOptions,
initialSnPlugins,
initialError, initialError,
onMount, onMount,
}); });
@@ -118,6 +134,9 @@ export default function viz({ model, halo, initialError, onDestroy = async () =>
options(opts) { options(opts) {
setSnOptions(opts); setSnOptions(opts);
}, },
plugins(plugins) {
setSnPlugins(plugins);
},
exportImage() { exportImage() {
return cellRef.current.exportImage(); return cellRef.current.exportImage();
}, },

View File

@@ -3,7 +3,7 @@
"info": { "info": {
"name": "@nebula.js/stardust", "name": "@nebula.js/stardust",
"description": "Product and framework agnostic integration API for Qlik's Associative Engine", "description": "Product and framework agnostic integration API for Qlik's Associative Engine",
"version": "1.1.0", "version": "1.1.1",
"license": "MIT", "license": "MIT",
"stability": "stable" "stability": "stable"
}, },
@@ -365,6 +365,22 @@
"import { useDeviceType } from '@nebula.js/stardust';\n// ...\nconst deviceType = useDeviceType();\nif (deviceType === 'touch') { ... };" "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": { "useAction": {
"description": "Registers a custom action.", "description": "Registers a custom action.",
"templates": [ "templates": [
@@ -1077,6 +1093,13 @@
"options": { "options": {
"optional": true, "optional": true,
"type": "object" "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": { "LoadType": {
"kind": "interface", "kind": "interface",
"params": [ "params": [

View File

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

View File

@@ -113,6 +113,7 @@ describe('creator', () => {
}, },
deviceType: 'desktop', deviceType: 'desktop',
options: {}, 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', () => { it('should run when theme name has changed', () => {
const c = create(generator, opts, galaxy).component; const c = create(generator, opts, galaxy).component;
c.render({}); // initial should always run c.render({}); // initial should always run

View File

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

View File

@@ -76,6 +76,7 @@ function createWithHooks(generator, opts, galaxy) {
appLayout: {}, appLayout: {},
constraints: forcedConstraints, constraints: forcedConstraints,
options: {}, options: {},
plugins: [],
}, },
fn: generator.component.fn, fn: generator.component.fn,
created() {}, 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 // do a deep check on 'small' objects
deepCheck.forEach((key) => { deepCheck.forEach((key) => {
const ref = r.context; const ref = r.context;

View File

@@ -718,6 +718,32 @@ export function useDeviceType() {
return useInternalContext('deviceType'); 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 * @template A
* @interface ActionDefinition * @interface ActionDefinition

View File

@@ -21,6 +21,7 @@ export {
useAppLayout, useAppLayout,
useTranslator, useTranslator,
useDeviceType, useDeviceType,
usePlugins,
useConstraints, useConstraints,
useOptions, useOptions,
onTakeSnapshot, 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) |