From b3315d0e6b6d1808cd59c8a2012bb8bb223b2503 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20=C3=85str=C3=B6m?= Date: Wed, 5 Apr 2023 20:35:13 +0200 Subject: [PATCH] feat: create api (#1079) * feat: initial create obj api * chore: split create and generate * chore: remove duplicate selector * chore: set props to null at failed creation --- apis/nucleus/src/index.js | 39 ++++- .../object/__tests__/create-object.test.js | 94 ++++++++++++ apis/nucleus/src/object/create-object.js | 63 ++++++++ .../src/object/create-session-object.js | 25 +++- apis/nucleus/src/object/get-generic-object.js | 15 -- apis/nucleus/src/viz.js | 2 +- apis/stardust/api-spec/spec.json | 139 ++++++++++++------ apis/stardust/types/index.d.ts | 37 +++-- apis/supernova/src/hooks.js | 1 - 9 files changed, 333 insertions(+), 82 deletions(-) create mode 100644 apis/nucleus/src/object/__tests__/create-object.test.js create mode 100644 apis/nucleus/src/object/create-object.js diff --git a/apis/nucleus/src/index.js b/apis/nucleus/src/index.js index dd427ca2e..8696329be 100644 --- a/apis/nucleus/src/index.js +++ b/apis/nucleus/src/index.js @@ -11,7 +11,8 @@ import ListBoxPopoverWrapper, { getOptions as getListboxPopoverOptions, } from './components/listbox/ListBoxPopoverWrapper'; -import create from './object/create-session-object'; +import createSessionObject from './object/create-session-object'; +import createObject from './object/create-object'; import get from './object/get-generic-object'; import flagsFn from './flags/flags'; import { create as typesFn } from './sn/types'; @@ -251,8 +252,9 @@ function nuked(configuration = {}) { const api = /** @lends Embed# */ { /** * Renders a visualization or sheet into an HTMLElement. + * Visualizations can either be existing objects or created on the fly. * Support for sense sheets is experimental. - * @param {CreateConfig | GetConfig} cfg - The render configuration. + * @param {RenderConfig} cfg The render configuration. * @returns {Promise} A controller to the rendered visualization or sheet. * @example * // render from existing object @@ -273,8 +275,39 @@ function nuked(configuration = {}) { if (cfg.id) { return get(cfg, halo); } - return create(cfg, halo); + return createSessionObject(cfg, halo); }, + /** + * Creates a visualization model + * @param {CreateConfig} cfg The create configuration. + * @experimental + * @returns {Promise} An engima model + * @example + * // create a barchart in the app and return the model + * const model = await n.create({ + * type: 'barchart', + * fields: ['Product', { qLibraryId: 'u378hn', type: 'measure' }], + * properties: { showTitle: true } + * } + * ); + */ + create: async (cfg) => createObject(cfg, halo, false), + /** + * Generates properties for a visualization object + * @param {CreateConfig} cfg The create configuration. + * @experimental + * @returns {Promise} The objects properties + * @example + * // generate properties for a barchart + * const properties = await n.create({ + * type: 'barchart', + * fields: ['Product', { qLibraryId: 'u378hn', type: 'measure' }], + * properties: { showTitle: true } + * }, + * true + * ); + */ + generateProperties: async (cfg) => createObject(cfg, halo, true), /** * Updates the current context of this embed instance. * Use this when you want to change some part of the current context, like theme. diff --git a/apis/nucleus/src/object/__tests__/create-object.test.js b/apis/nucleus/src/object/__tests__/create-object.test.js new file mode 100644 index 000000000..911c86f2e --- /dev/null +++ b/apis/nucleus/src/object/__tests__/create-object.test.js @@ -0,0 +1,94 @@ +import * as populatorModule from '../populator'; +import create from '../create-object'; + +describe('create-object', () => { + let halo = {}; + let types; + let sn; + let merged; + let populator; + let init; + let objectModel; + + beforeEach(() => { + populator = jest.fn(); + init = jest.fn(); + + jest.spyOn(populatorModule, 'default').mockImplementation(populator); + objectModel = { id: 'id', on: () => {}, once: () => {} }; + types = { + get: jest.fn(), + }; + halo = { + app: { + createObject: jest.fn().mockResolvedValue(objectModel), + }, + types, + }; + + init.mockReturnValue('api'); + + sn = { qae: { properties: { onChange: jest.fn() } } }; + merged = { m: 'true' }; + const t = { + initialProperties: jest.fn().mockResolvedValue(merged), + supernova: jest.fn().mockResolvedValue(sn), + }; + types.get.mockReturnValue(t); + }); + + afterEach(() => { + jest.resetAllMocks(); + jest.restoreAllMocks(); + }); + + test('should call types.get with name and version', () => { + create({ type: 't', version: 'v', fields: 'f' }, halo); + expect(types.get).toHaveBeenCalledWith({ name: 't', version: 'v' }); + }); + + test('should call initialProperties on returned type', () => { + const t = { initialProperties: jest.fn() }; + t.initialProperties.mockReturnValue({ then: () => {} }); + types.get.mockReturnValue(t); + create({ type: 't', version: 'v', fields: 'f', properties: 'props', extendProperties: false }, halo); + expect(t.initialProperties).toHaveBeenCalledWith('props', false); + }); + + test('should populate fields', async () => { + await create({ type: 't', version: 'v', fields: 'f', properties: 'props' }, halo); + expect(populator).toHaveBeenCalledWith({ sn, properties: merged, fields: 'f' }, halo); + }); + + test('should call properties onChange handler when optional props are provided', async () => { + await create({ type: 't', version: 'v', fields: 'f', properties: 'props' }, halo); + expect(sn.qae.properties.onChange).toHaveBeenCalledWith(merged); + }); + + test('should not call onChange handler when optional props are not provided', async () => { + await create({ type: 't', version: 'v', fields: 'f' }, halo); + expect(sn.qae.properties.onChange).toHaveBeenCalledTimes(0); + }); + + test('should create a object with merged props', async () => { + await create({ type: 't', version: 'v', fields: 'f', properties: 'props' }, halo); + expect(halo.app.createObject).toHaveBeenCalledWith(merged); + }); + + test('should create a dummy object when error is thrown', async () => { + types.get.mockImplementation(() => { + throw new Error('oops'); + }); + await create({ type: 't', version: 'v', fields: 'f', properties: 'props' }, halo); + expect(halo.app.createObject).toHaveBeenCalledWith({ + qInfo: { qType: 't' }, + visualization: 't', + }); + }); + + test('should return props only', async () => { + const props = await create({ type: 't', version: 'v', fields: 'f' }, halo, true); + expect(halo.app.createObject).not.toHaveBeenCalledWith(); + expect(props).toEqual({ m: 'true' }); + }); +}); diff --git a/apis/nucleus/src/object/create-object.js b/apis/nucleus/src/object/create-object.js new file mode 100644 index 000000000..8ebadecb9 --- /dev/null +++ b/apis/nucleus/src/object/create-object.js @@ -0,0 +1,63 @@ +import populateData from './populator'; +import { modelStore } from '../stores/model-store'; + +/** + * @typedef {string | EngineAPI.INxDimension | EngineAPI.INxMeasure | LibraryField} Field + */ + +/** + * @interface CreateConfig + * @description Rendering configuration for creating and rendering a new object + * @property {string} type + * @property {string=} version + * @property {(Field[])=} fields + * @property {EngineAPI.IGenericObjectProperties=} properties + */ + +export default async function createObject( + { type, version, fields, properties, extendProperties /* , options, plugins, element */ }, + halo, + generateOnly +) { + let mergedProps = {}; + // let error; + try { + const t = halo.types.get({ name: type, version }); + mergedProps = await t.initialProperties(properties, extendProperties); + const sn = await t.supernova(); + if (fields) { + populateData( + { + sn, + properties: mergedProps, + fields, + }, + halo + ); + } + if (properties && sn && sn.qae.properties.onChange) { + sn.qae.properties.onChange.call({}, mergedProps); + } + } catch (e) { + // error = e; + // minimal dummy object properties to allow it to be created + // and rendered with the error + if (!generateOnly) { + mergedProps = { + qInfo: { + qType: type, + }, + visualization: type, + }; + } else { + mergedProps = null; + } + // console.error(e); // eslint-disable-line + } + if (!generateOnly) { + const model = await halo.app.createObject(mergedProps); + modelStore.set(model.id, model); + return model; + } + return mergedProps; +} diff --git a/apis/nucleus/src/object/create-session-object.js b/apis/nucleus/src/object/create-session-object.js index 1b6171239..b289b4a69 100644 --- a/apis/nucleus/src/object/create-session-object.js +++ b/apis/nucleus/src/object/create-session-object.js @@ -7,14 +7,17 @@ import { subscribe, modelStore } from '../stores/model-store'; */ /** - * @interface CreateConfig - * @description Rendering configuration for creating and rendering a new object - * @extends BaseConfig - * @property {string} type - * @property {string=} version - * @property {(Field[])=} fields - * @property {boolean} [extendProperties=false] Whether to deeply extend properties or not. If false then subtrees will be overwritten. - * @property {EngineAPI.IGenericObjectProperties=} properties + * @interface RenderConfig + * @description Configuration for rendering a visualisation, either creating or fetching an existing object. + * @property {HTMLElement} element Target html element to render in to + * @property {object=} options Options passed into the visualisation + * @property {Plugin[]} [plugins] plugins passed into the visualisation + * @property {string=} id For existing objects: Engine identifier of object to render + * @property {string=} type For creating objects: Type of visualisation to render + * @property {string=} version For creating objects: Version of visualization to render + * @property {(Field[])=} fields For creating objects: Data fields to use + * @property {boolean=} [extendProperties=false] For creating objects: Whether to deeply extend properties or not. If false then subtrees will be overwritten. + * @property {EngineAPI.IGenericObjectProperties=} properties For creating objects: Explicit properties to set * @example * // A config for Creating objects: * const createConfig = { @@ -29,6 +32,12 @@ import { subscribe, modelStore } from '../stores/model-store'; * } * }; * nebbie.render(createConfig); + * // A config for rendering an existing object: + * const createConfig = { + * id: 'jG5LP', + * element: document.querySelector('.line'), + * }; + * nebbie.render(createConfig); */ export default async function createSessionObject( { type, version, fields, properties, options, plugins, element, extendProperties }, diff --git a/apis/nucleus/src/object/get-generic-object.js b/apis/nucleus/src/object/get-generic-object.js index 17e4b9390..27287f308 100644 --- a/apis/nucleus/src/object/get-generic-object.js +++ b/apis/nucleus/src/object/get-generic-object.js @@ -2,21 +2,6 @@ import init from './initiate'; import initSheet from './initiate-sheet'; import { modelStore, rpcRequestModelStore } from '../stores/model-store'; -/** - * @interface BaseConfig - * @description Basic rendering configuration for rendering an object - * @property {HTMLElement} element - * @property {object=} options - * @property {Plugin[]} [plugins] - */ - -/** - * @interface GetConfig - * @description Rendering configuration for rendering an existing object - * @extends BaseConfig - * @property {string} id - */ - export default async function getObject({ id, options, plugins, element }, halo) { const key = `${id}`; let rpc = rpcRequestModelStore.get(key); diff --git a/apis/nucleus/src/viz.js b/apis/nucleus/src/viz.js index b816c96b9..a6fbe49b6 100644 --- a/apis/nucleus/src/viz.js +++ b/apis/nucleus/src/viz.js @@ -71,7 +71,7 @@ export default function viz({ model, halo, initialError, onDestroy = async () => id: model.id, /** * This visualizations Enigma model, a representation of the generic object. - * @type {string} + * @type {EngineAPI.IGenericObject} */ model, /** diff --git a/apis/stardust/api-spec/spec.json b/apis/stardust/api-spec/spec.json index ce40a4725..81f81d13d 100644 --- a/apis/stardust/api-spec/spec.json +++ b/apis/stardust/api-spec/spec.json @@ -692,21 +692,13 @@ }, "entries": { "render": { - "description": "Renders a visualization or sheet into an HTMLElement.\nSupport for sense sheets is experimental.", + "description": "Renders a visualization or sheet into an HTMLElement.\nVisualizations can either be existing objects or created on the fly.\nSupport for sense sheets is experimental.", "kind": "function", "params": [ { "name": "cfg", "description": "The render configuration.", - "kind": "union", - "items": [ - { - "type": "#/definitions/CreateConfig" - }, - { - "type": "#/definitions/GetConfig" - } - ] + "type": "#/definitions/RenderConfig" } ], "returns": { @@ -731,6 +723,54 @@ "// render on the fly\nn.render({\n element: el,\n type: 'barchart',\n fields: ['Product', { qLibraryId: 'u378hn', type: 'measure' }]\n});" ] }, + "create": { + "description": "Creates a visualization model", + "stability": "experimental", + "kind": "function", + "params": [ + { + "name": "cfg", + "description": "The create configuration.", + "type": "#/definitions/CreateConfig" + } + ], + "returns": { + "description": "An engima model", + "type": "Promise", + "generics": [ + { + "type": "EngineAPI.IGenericObject" + } + ] + }, + "examples": [ + "// create a barchart in the app and return the model\nconst model = await n.create({\n type: 'barchart',\n fields: ['Product', { qLibraryId: 'u378hn', type: 'measure' }],\n properties: { showTitle: true }\n }\n);" + ] + }, + "generateProperties": { + "description": "Generates properties for a visualization object", + "stability": "experimental", + "kind": "function", + "params": [ + { + "name": "cfg", + "description": "The create configuration.", + "type": "#/definitions/CreateConfig" + } + ], + "returns": { + "description": "The objects properties", + "type": "Promise", + "generics": [ + { + "type": "object" + } + ] + }, + "examples": [ + "// generate properties for a barchart\nconst properties = await n.create({\n type: 'barchart',\n fields: ['Product', { qLibraryId: 'u378hn', type: 'measure' }],\n properties: { showTitle: true }\n },\n true\n);" + ] + }, "context": { "description": "Updates the current context of this embed instance.\nUse this when you want to change some part of the current context, like theme.", "kind": "function", @@ -1152,7 +1192,7 @@ }, "model": { "description": "This visualizations Enigma model, a representation of the generic object.", - "type": "string" + "type": "EngineAPI.IGenericObject" }, "destroy": { "description": "Destroys the visualization and removes it from the the DOM.", @@ -1474,11 +1514,6 @@ }, "CreateConfig": { "description": "Rendering configuration for creating and rendering a new object", - "extends": [ - { - "type": "#/definitions/BaseConfig" - } - ], "kind": "interface", "entries": { "type": { @@ -1500,54 +1535,76 @@ } ] }, - "extendProperties": { - "description": "Whether to deeply extend properties or not. If false then subtrees will be overwritten.", - "optional": true, - "defaultValue": false, - "type": "boolean" - }, "properties": { "optional": true, "type": "EngineAPI.IGenericObjectProperties" } - }, - "examples": [ - "// A config for Creating objects:\nconst createConfig = {\n type: 'bar',\n element: document.querySelector('.bar'),\n extendProperties: true,\n fields: ['[Country names]', '=Sum(Sales)'],\n properties: {\n legend: {\n show: false,\n },\n }\n};\nnebbie.render(createConfig);" - ] + } }, - "BaseConfig": { - "description": "Basic rendering configuration for rendering an object", + "RenderConfig": { + "description": "Configuration for rendering a visualisation, either creating or fetching an existing object.", "kind": "interface", "entries": { "element": { + "description": "Target html element to render in to", "type": "HTMLElement" }, "options": { + "description": "Options passed into the visualisation", "optional": true, "type": "object" }, "plugins": { + "description": "plugins passed into the visualisation", "optional": true, "kind": "array", "items": { "type": "#/definitions/Plugin" } - } - } - }, - "GetConfig": { - "description": "Rendering configuration for rendering an existing object", - "extends": [ - { - "type": "#/definitions/BaseConfig" - } - ], - "kind": "interface", - "entries": { + }, "id": { + "description": "For existing objects: Engine identifier of object to render", + "optional": true, "type": "string" + }, + "type": { + "description": "For creating objects: Type of visualisation to render", + "optional": true, + "type": "string" + }, + "version": { + "description": "For creating objects: Version of visualization to render", + "optional": true, + "type": "string" + }, + "fields": { + "description": "For creating objects: Data fields to use", + "optional": true, + "kind": "union", + "items": [ + { + "kind": "array", + "items": { + "type": "#/definitions/Field" + } + } + ] + }, + "extendProperties": { + "description": "For creating objects: Whether to deeply extend properties or not. If false then subtrees will be overwritten.", + "optional": true, + "defaultValue": false, + "type": "boolean" + }, + "properties": { + "description": "For creating objects: Explicit properties to set", + "optional": true, + "type": "EngineAPI.IGenericObjectProperties" } - } + }, + "examples": [ + "// A config for Creating objects:\nconst createConfig = {\n type: 'bar',\n element: document.querySelector('.bar'),\n extendProperties: true,\n fields: ['[Country names]', '=Sum(Sales)'],\n properties: {\n legend: {\n show: false,\n },\n }\n};\nnebbie.render(createConfig);\n// A config for rendering an existing object:\nconst createConfig = {\n id: 'jG5LP',\n element: document.querySelector('.line'),\n};\nnebbie.render(createConfig);" + ] }, "LibraryField": { "kind": "interface", diff --git a/apis/stardust/types/index.d.ts b/apis/stardust/types/index.d.ts index 0fc3f7f98..1096405dc 100644 --- a/apis/stardust/types/index.d.ts +++ b/apis/stardust/types/index.d.ts @@ -235,10 +235,23 @@ declare namespace stardust { /** * Renders a visualization or sheet into an HTMLElement. + * Visualizations can either be existing objects or created on the fly. * Support for sense sheets is experimental. * @param cfg The render configuration. */ - render(cfg: stardust.CreateConfig | stardust.GetConfig): Promise; + render(cfg: stardust.RenderConfig): Promise; + + /** + * Creates a visualization model + * @param cfg The create configuration. + */ + create(cfg: stardust.CreateConfig): Promise; + + /** + * Generates properties for a visualization object + * @param cfg The create configuration. + */ + generateProperties(cfg: stardust.CreateConfig): Promise; /** * Updates the current context of this embed instance. @@ -357,7 +370,7 @@ declare namespace stardust { id: string; - model: string; + model: EngineAPI.IGenericObject; /** * Destroys the visualization and removes it from the the DOM. @@ -460,28 +473,26 @@ declare namespace stardust { /** * Rendering configuration for creating and rendering a new object */ - interface CreateConfig extends stardust.BaseConfig{ + interface CreateConfig { type: string; version?: string; fields?: stardust.Field[]; - extendProperties?: boolean; properties?: EngineAPI.IGenericObjectProperties; } /** - * Basic rendering configuration for rendering an object + * Configuration for rendering a visualisation, either creating or fetching an existing object. */ - interface BaseConfig { + interface RenderConfig { element: HTMLElement; options?: object; plugins?: stardust.Plugin[]; - } - - /** - * Rendering configuration for rendering an existing object - */ - interface GetConfig extends stardust.BaseConfig{ - id: string; + id?: string; + type?: string; + version?: string; + fields?: stardust.Field[]; + extendProperties?: boolean; + properties?: EngineAPI.IGenericObjectProperties; } interface LibraryField { diff --git a/apis/supernova/src/hooks.js b/apis/supernova/src/hooks.js index 774857f39..257fbc5a1 100644 --- a/apis/supernova/src/hooks.js +++ b/apis/supernova/src/hooks.js @@ -1010,7 +1010,6 @@ export function onTakeSnapshot(cb) { * @ignore * @example * import { onContextMenu } from '@nebula.js/stardust'; - * onContextMenu((menu, event) => { * menu.addItem(item, index); * });