mirror of
https://github.com/qlik-oss/nebula.js.git
synced 2025-12-19 17:58:43 -05:00
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:
committed by
GitHub
parent
ebecd388c0
commit
ae75817426
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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}
|
||||
/>,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
32
apis/nucleus/src/plugins/__tests__/plugins.spec.js
Normal file
32
apis/nucleus/src/plugins/__tests__/plugins.spec.js
Normal 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');
|
||||
});
|
||||
});
|
||||
36
apis/nucleus/src/plugins/plugins.js
Normal file
36
apis/nucleus/src/plugins/plugins.js
Normal 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`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -29,6 +29,7 @@ export {
|
||||
useAppLayout,
|
||||
useTranslator,
|
||||
useDeviceType,
|
||||
usePlugins,
|
||||
useConstraints,
|
||||
useOptions,
|
||||
onTakeSnapshot,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -21,6 +21,7 @@ export {
|
||||
useAppLayout,
|
||||
useTranslator,
|
||||
useDeviceType,
|
||||
usePlugins,
|
||||
useConstraints,
|
||||
useOptions,
|
||||
onTakeSnapshot,
|
||||
|
||||
BIN
docs/assets/simple-vis-with-plugin.png
Normal file
BIN
docs/assets/simple-vis-with-plugin.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.1 KiB |
BIN
docs/assets/simple-vis.png
Normal file
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
103
docs/plugins.md
Normal 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 |
|
||||
| :-----------------------------------: | :-------------------------------------------------: |
|
||||
|  |  |
|
||||
Reference in New Issue
Block a user