fix(cli-sense): assume supernova wrapping is done in Sense (#358)

This commit is contained in:
Miralem Drek
2020-03-13 11:42:29 +01:00
committed by GitHub
parent f82a3a0b6e
commit 098c052113
12 changed files with 166 additions and 413 deletions

View File

@@ -36,6 +36,16 @@
"global-require": 0
}
},
{
"files": ["commands/sense/src/*.js"],
"rules": {
"no-var": 0,
"import/no-amd": 0
},
"globals": {
"define": false
}
},
{
"files": ["**/*.{int,comp,spec}.{js,jsx}"],
"env": {

View File

@@ -14,9 +14,61 @@ nebula sense
Build a supernova as a Qlik Sense extension
Options:
--version Show version number [boolean]
--ext Extension definition [string]
--meta Extension meta information [string]
--output Output directory of extension
-h, --help Show help [boolean]
--version Show version number [boolean]
--ext Extension definition [string]
--meta Extension meta information [string]
--minify Minify and uglify code [boolean] [default: true]
--sourcemap Generate sourcemaps [boolean] [default: false]
-h, --help Show help [boolean]
```
### Extension
You can provide some additional information as part of the Qlik Sense Extension API by creating a separate file for the extension info and providing it as argument to `--ext`:
```js
// def.js
export default {
definition: {
// Property panel definition
},
support: {
exportData: true,
},
};
```
```bash
nebula sense --ext def.js
```
The provided file will be transpiled and placed in the folder `/dist-ext`. Two additional files will be created which are the entrypoints for the extension in Qlik Sense; If your supernova module is named `banana-chart`, the files `banana-chart.js` and `banana-chart.qext` will be created in the root of your project. If you have a `files` property in your `package.json` you should include these files in addition to the already existing ones:
```json
{
"name": "banana-chart",
"files": [
"// other files",
"dist-ext",
"banana-chart.js"
"banana-chart.qext"
]
}
```
### Meta
You can add more meta about the extension by providing a `.json` formatted file with `--meta`:
```json
{
"name": "My tasty banana extension",
"icon": "barchart"
}
```
```bash
nebula sense --meta meta.json
```
The rest of the required information will be populated automatically.

View File

@@ -20,10 +20,11 @@ module.exports = {
default: true,
desc: 'Minify and uglify code',
});
yargs.option('output', {
type: 'string',
yargs.option('sourcemap', {
type: 'boolean',
required: false,
desc: 'Specify the output location',
default: false,
desc: 'Generate sourcemaps',
});
},
handler(argv) {

View File

@@ -1,5 +1,5 @@
const path = require('path');
const fs = require('fs');
const fs = require('fs-extra');
const rollup = require('rollup');
const nodeResolve = require('@rollup/plugin-node-resolve');
@@ -9,98 +9,97 @@ const { terser } = require('rollup-plugin-terser');
async function build(argv) {
const cwd = process.cwd();
const supernovaPkg = require(path.resolve(cwd, 'package.json')); // eslint-disable-line
const extName = supernovaPkg.name.replace(/\//, '-').replace('@', '');
const outputDirectory = argv.output ? argv.output : undefined;
// define targetDirectory: use outputDirectory if defined, otherwise create extension in CWD
const targetDirectory = outputDirectory
? path.resolve(argv.output, `${extName}-ext`)
: path.resolve(cwd, `${extName}-ext`);
const { main } = supernovaPkg;
let extDefinition = path.resolve(__dirname, '../src/ext-definition');
const qextTargetDir = path.resolve(cwd, 'dist-ext');
const qextFileName = path.resolve(cwd, `${extName}.qext`);
const qextFileNameJs = qextFileName.replace(/\.qext$/, '.js');
if (argv.ext) {
extDefinition = path.resolve(argv.ext);
fs.removeSync(qextTargetDir);
fs.removeSync(qextFileName);
fs.removeSync(qextFileNameJs);
const extDefinition = argv.ext ? path.resolve(argv.ext) : undefined;
const createQextFiles = () => {
const qext = supernovaPkg.qext || {};
if (argv.meta) {
const meta = require(path.resolve(cwd, argv.meta)); // eslint-disable-line
Object.assign(qext, meta);
}
const contents = {
name: qext.name || extName,
version: supernovaPkg.version,
description: supernovaPkg.description,
author: supernovaPkg.author,
icon: qext.icon || 'extension',
preview: qext.preview,
type: 'visualization',
supernova: true,
};
let qextjs = fs.readFileSync(path.resolve(__dirname, extDefinition ? '../src/ext.js' : '../src/empty-ext.js'), {
encoding: 'utf8',
});
qextjs = qextjs.replace('{{DIST}}', `./${main.replace(/^[./]*/, '').replace(/\.js$/, '')}`);
fs.writeFileSync(qextFileName, JSON.stringify(contents, null, 2));
fs.writeFileSync(qextFileNameJs, qextjs);
if (supernovaPkg.files) {
[extDefinition ? path.basename(qextTargetDir) : false, path.basename(qextFileNameJs), path.basename(qextFileName)]
.filter(Boolean)
.forEach(f => {
if (!supernovaPkg.files.includes(f)) {
console.warn(` \x1b[33mwarn:\x1b[0m \x1b[36m${f}\x1b[0m should be included in package.json 'files' array`);
}
});
}
};
if (extDefinition) {
const bundle = await rollup.rollup({
input: extDefinition,
plugins: [
nodeResolve(),
common(),
babel({
babelrc: false,
exclude: [/node_modules/],
presets: [
[
'@babel/preset-env',
{
modules: false,
targets: {
browsers: ['ie 11', 'chrome 47'],
},
},
],
],
}),
argv.minify &&
terser({
output: {
comments: /@license|@preserve|Copyright|license/,
},
}),
],
});
await bundle.write({
file: path.resolve(qextTargetDir, 'ext.js'),
format: 'amd',
sourcemap: argv.ext && argv.sourcemap,
});
}
const meta = argv.meta ? require(path.resolve(argv.meta)) : {}; // eslint-disable-line
const { module, main } = supernovaPkg;
const bundle = await rollup.rollup({
input: {
supernova: module || main,
extDefinition,
[extName]: path.resolve(__dirname, '../src/supernova-wrapper'),
},
external: ['snDefinition', 'extDefinition'],
plugins: [
nodeResolve(),
common(),
babel({
babelrc: false,
exclude: [/node_modules/],
presets: [
[
'@babel/preset-env',
{
modules: false,
targets: {
browsers: ['ie 11', 'chrome 47'],
},
},
],
],
}),
argv.minify &&
terser({
output: {
comments: /@license|@preserve|Copyright|license/,
},
}),
],
});
await bundle.write({
dir: targetDirectory,
format: 'amd',
sourcemap: true,
paths: {
snDefinition: './supernova',
extDefinition: './extDefinition',
},
chunkFileNames: '[name]-[hash]',
});
// NOTE: chunkFileNames above must not contain '.js' at the end
// since that would cause requirejs in sense-client to interpret the request as text/html
// so we trim off the file extension in the modules, but then attach it to the files
const files = fs.readdirSync(targetDirectory);
files.forEach(f => {
if (/^chunk-/.test(f) && !/\.js$/.test(f)) {
// attach file extension
fs.renameSync(path.resolve(targetDirectory, f), path.resolve(targetDirectory, `${f}.js`));
}
});
// write .qext for the extension
fs.writeFileSync(
path.resolve(targetDirectory, `${extName}.qext`),
JSON.stringify(
{
name: extName,
description: supernovaPkg.description,
author: supernovaPkg.author,
version: supernovaPkg.version,
...meta,
type: 'visualization',
},
null,
2
)
);
createQextFiles();
}
module.exports = build;

View File

@@ -30,13 +30,9 @@
"@babel/cli": "7.8.3",
"@babel/core": "7.8.3",
"@babel/preset-env": "7.8.3",
"@nebula.js/locale": "0.1.0",
"@nebula.js/supernova": "0.1.1",
"@nebula.js/theme": "0.1.0",
"@rollup/plugin-commonjs": "11.0.1",
"@rollup/plugin-node-resolve": "7.0.0",
"chalk": "3.0.0",
"node-event-emitter": "0.0.1",
"fs-extra": "8.1.0",
"rollup": "1.30.0",
"rollup-plugin-babel": "4.3.3",
"rollup-plugin-terser": "5.2.0",

View File

@@ -1,101 +0,0 @@
import permissions from '../permissions';
const MODES = {
static: {
interactionState: 0,
},
analyis: {
interactionState: 1,
},
edit: {
interactionState: 2,
},
editstory: {
interactionState: 1,
// navigation: false,
tooltips: false,
selections: false,
limitedInteraction: false,
},
playstory: {
interactionState: 1,
// navigation: false,
tooltips: true,
selections: false,
limitedInteraction: true,
},
};
describe('permissions', () => {
describe('with live engine', () => {
it('in STATIC mode should only allow model SELECT and FETCH', () => {
expect(
permissions(
{
interactionState: 0,
},
{}
)
).to.eql(['select', 'fetch']);
});
it('in ANALYSIS mode should allow everything', () => {
expect(
permissions(
{
interactionState: 1,
},
{}
)
).to.eql(['passive', 'interact', 'select', 'fetch']);
});
it('in EDIT mode should only allow model SELECT and FETCH', () => {
expect(
permissions(
{
interactionState: 2,
},
{}
)
).to.eql(['select', 'fetch']);
});
it('in ANALYSIS mode with tooltips and selections off, should only allow FETCH', () => {
expect(
permissions(
{
interactionState: 1,
tooltips: false,
selections: false,
},
{}
)
).to.eql(['fetch']);
});
});
describe('for a snapshot', () => {
// interactive chart snapshot (shared chart)
it('in ANALYSIS mode should only allow PASSIVE and INTERACT', () => {
expect(
permissions(
{
interactionState: 1,
},
{ isSnapshot: true }
)
).to.eql(['passive', 'interact']);
});
// edit story
it('in ANALYSIS mode with tooltips and selections off, should not allow anything', () => {
expect(permissions(MODES.editstory, { isSnapshot: true })).to.eql([]);
});
// play story
it('in ANALYSIS mode with selections off, should ONLY allow PASSIVE', () => {
expect(permissions(MODES.playstory, { isSnapshot: true })).to.eql(['passive']);
});
});
});

View File

@@ -0,0 +1,3 @@
define(['{{DIST}}'], function m(supernova) {
return supernova;
});

View File

@@ -1,20 +0,0 @@
export default {
definition: {
type: 'items',
component: 'accordion',
items: {
data: {
uses: 'data',
},
// settings: {
// uses: 'settings',
// },
},
},
support: {
export: false,
exportData: false,
snapshot: false,
viewData: false,
},
};

View File

@@ -0,0 +1,7 @@
define(['{{DIST}}', './dist-ext/ext'], function m(supernova, ext) {
return function supernovaExtension(env) {
var v = supernova(env);
v.ext = typeof ext === 'function' ? ext(env) : ext;
return v;
};
});

View File

@@ -1,36 +0,0 @@
const INTERACTION_STATES = {
STATIC: 0,
ANALYSIS: 1,
EDIT: 2,
};
function showTooltip(options) {
if (options.tooltips) {
return true;
}
return options.interactionState === INTERACTION_STATES.ANALYSIS && options.tooltips !== false;
}
function permissions(options, backendApi) {
const p = [];
if (showTooltip(options)) {
p.push('passive');
}
if (
options.interactionState === INTERACTION_STATES.ANALYSIS &&
options.tooltips !== false &&
options.limitedInteraction !== true
) {
p.push('interact');
}
if (!backendApi.isSnapshot) {
if (options.selections !== false) {
p.push('select');
}
p.push('fetch');
}
return p;
}
export default permissions;

View File

@@ -1,68 +0,0 @@
import EventEmitter from 'node-event-emitter';
export default scope => {
/* eslint no-param-reassign: 0 */
const mixin = obj => {
Object.keys(EventEmitter.prototype).forEach(key => {
obj[key] = EventEmitter.prototype[key];
});
EventEmitter.init(obj);
return obj;
};
// TODO one and only one
const selectionAPI = {
begin() {
scope.selectionsApi.activated();
selectionAPI.emit('activated');
},
clear() {
scope.backendApi.model.resetMadeSelections();
},
confirm() {
scope.selectionsApi.confirm();
},
cancel() {
scope.selectionsApi.cancel();
},
select(s) {
scope.backendApi.model[s.method](...s.params).then(qSuccess => {
if (!qSuccess) {
scope.selectionsApi.selectionsMade = false;
this.clear();
}
});
scope.selectionsApi.selectionsMade = s.method !== 'resetMadeSelections';
},
isActive() {
return scope.selectionsApi.active;
},
refreshToolbar() {
scope.selectionsApi.refreshToolbar();
},
};
mixin(selectionAPI);
scope.selectionsApi.confirm = () => {
scope.backendApi.endSelections(true).then(() => {
scope.selectionsApi.deactivated();
selectionAPI.emit('deactivated');
});
};
scope.selectionsApi.clear = () => {
scope.backendApi.clearSelections();
scope.selectionsApi.selectionsMade = false;
selectionAPI.emit('cleared');
};
scope.selectionsApi.cancel = () => {
scope.backendApi.endSelections(false);
scope.selectionsApi.deactivated();
selectionAPI.emit('canceled');
};
return selectionAPI;
};

View File

@@ -1,90 +0,0 @@
import { generator as supernova } from '@nebula.js/supernova';
import themeFn from '@nebula.js/theme';
import localeFn from '@nebula.js/locale';
import permissions from './permissions';
import selectionsApi from './selections';
import snDefinition from 'snDefinition'; // eslint-disable-line
import extDefinition from 'extDefinition'; // eslint-disable-line
// TODO - how to initiate with the language used in Sense without importing the translator module from Sense?
const loc = localeFn();
const env = {
translator: loc.translator,
};
const snGenerator = supernova(snDefinition, env);
const ext = typeof extDefinition === 'function' ? extDefinition(env) : extDefinition;
export default {
// overridable properties
definition: {
type: 'items',
component: 'accordion',
items: {
data: {
uses: 'data',
},
},
},
support: {
export: false,
exportData: false,
snapshot: false,
viewData: false,
},
// override with user config
...ext,
// =============================================
// non-overridable properties
initialProperties: snGenerator.qae.properties.initial,
importProperties: null, // Disable conversion to/from this object
exportProperties: null, // Disable conversion to/from this object
template: '<div style="height: 100%;position: relative"></div>',
mounted($element) {
// create a theme api with default nebula theme
// note that this will not consume a Sense theme
this.theme = themeFn();
const element = $element[0].children[0];
const selectionAPI = selectionsApi(this.$scope);
const sn = snGenerator.create({
model: this.backendApi.model,
app: this.backendApi.model.session.app,
selections: selectionAPI,
});
this.sn = sn;
this.snComponent = sn.component;
this.snComponent.created({
options: this.options || {},
});
this.snComponent.mounted(element);
},
paint($element, layout) {
const perms = permissions(this.options, this.backendApi);
const constraints = {
passive: perms.indexOf('passive') === -1 || undefined,
active: perms.indexOf('interact') === -1 || undefined,
select: perms.indexOf('select') === -1 || undefined,
};
return this.snComponent.render({
layout,
context: {
appLayout: {
qLocaleInfo: this.backendApi.localeInfo,
},
constraints,
theme: this.theme.externalAPI,
},
});
},
destroy() {
this.sn.destroy();
if (this.snComponent) {
this.snComponent.willUnmount();
this.snComponent.destroy();
}
},
};