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 */
|
/* 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();
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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}
|
||||||
/>,
|
/>,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
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 { 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();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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": [
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export {
|
|||||||
useAppLayout,
|
useAppLayout,
|
||||||
useTranslator,
|
useTranslator,
|
||||||
useDeviceType,
|
useDeviceType,
|
||||||
|
usePlugins,
|
||||||
useConstraints,
|
useConstraints,
|
||||||
useOptions,
|
useOptions,
|
||||||
onTakeSnapshot,
|
onTakeSnapshot,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export {
|
|||||||
useAppLayout,
|
useAppLayout,
|
||||||
useTranslator,
|
useTranslator,
|
||||||
useDeviceType,
|
useDeviceType,
|
||||||
|
usePlugins,
|
||||||
useConstraints,
|
useConstraints,
|
||||||
useOptions,
|
useOptions,
|
||||||
onTakeSnapshot,
|
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