refactor: internal packages structure (#94)

* refactor: internal packages structure

* refactor: internal packages structure
This commit is contained in:
Christoffer Åström
2019-08-27 09:57:04 +02:00
committed by GitHub
parent 45eae91837
commit a57abf1ead
144 changed files with 38 additions and 40 deletions

21
commands/build/README.md Normal file
View File

@@ -0,0 +1,21 @@
# @nebula.js/cli-build
## Install
```sh
npm install @nebula.js/cli@next
```
## Usage
```sh
nebula build
Build supernova
Options:
--version Show version number [boolean]
--watch, -w [boolean] [default: false]
-h, --help Show help [boolean]
```

137
commands/build/build.js Normal file
View File

@@ -0,0 +1,137 @@
const path = require('path');
const rollup = require('rollup');
const babel = require('rollup-plugin-babel');
const postcss = require('rollup-plugin-postcss');
const replace = require('rollup-plugin-replace');
const node = require('rollup-plugin-node-resolve');
const { terser } = require('rollup-plugin-terser');
const config = ({ mode = 'production', format = 'umd', cwd = process.cwd() } = {}) => {
const pkg = require(path.resolve(cwd, 'package.json')); // eslint-disable-line
const { name, version, license, author } = pkg;
const auth = typeof author === 'object' ? `${author.name} <${author.email}>` : author || '';
const moduleName = name.split('/').reverse()[0];
const banner = `/*
* ${name} v${version}
* Copyright (c) ${new Date().getFullYear()} ${auth}
* Released under the ${license} license.
*/
`;
// all peers should be externals for esm bundle
const external = format === 'esm' ? Object.keys(pkg.peerDependencies || {}) : [];
return {
input: {
input: path.resolve(cwd, 'src/index'),
external,
plugins: [
replace({
'process.env.NODE_ENV': JSON.stringify(mode === 'development' ? 'development' : 'production'),
}),
node({
customResolveOptions: {
moduleDirectory: path.resolve(cwd, 'node_modules'),
},
}),
babel({
babelrc: false,
presets: [
[
'@babel/preset-env',
{
modules: false,
targets: {
browsers: ['ie 11', 'chrome 47'],
},
},
],
],
}),
postcss({}),
...[
mode === 'production'
? terser({
output: {
preamble: banner,
},
})
: false,
],
].filter(Boolean),
},
output: {
banner,
format,
file: format === 'esm' && pkg.module ? pkg.module : pkg.main,
name: moduleName,
sourcemap: true,
output: {
preamble: banner,
},
},
};
};
const minified = async () => {
const c = config({
mode: 'production',
format: 'umd',
});
const bundle = await rollup.rollup(c.input);
await bundle.write(c.output);
};
const esm = async () => {
const c = config({
mode: 'development',
format: 'esm',
});
const bundle = await rollup.rollup(c.input);
await bundle.write(c.output);
};
const watch = async () => {
const c = config({
mode: 'development',
format: 'esm',
});
const watcher = rollup.watch({
...c.input,
output: c.output,
});
return new Promise((resolve, reject) => {
watcher.on('event', event => {
if (event.code === 'FATAL') {
console.error(event);
reject();
}
if (event.code === 'ERROR') {
console.error(event);
reject();
}
if (event.code === 'END') {
resolve(watcher);
}
});
});
};
async function build(argv) {
if (argv.watch) {
watch();
} else {
await minified();
await esm();
}
}
module.exports = {
build,
watch,
};

16
commands/build/command.js Normal file
View File

@@ -0,0 +1,16 @@
const { build } = require('./build');
module.exports = {
command: 'build',
desc: 'Build supernova',
builder(yargs) {
yargs.option('watch', {
type: 'boolean',
alias: 'w',
default: false,
});
},
handler(argv) {
build(argv);
},
};

View File

@@ -0,0 +1,35 @@
{
"name": "@nebula.js/cli-build",
"version": "0.1.0-alpha.18",
"description": "",
"license": "MIT",
"author": "QlikTech International AB",
"keywords": [],
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "https://github.com/qlik-oss/nebula.js.git"
},
"main": "build.js",
"files": [
"command.js"
],
"scripts": {
"lint": "eslint src"
},
"dependencies": {
"@babel/cli": "^7.5.5",
"@babel/core": "^7.5.5",
"@babel/preset-env": "^7.5.5",
"chalk": "^2.4.2",
"rollup": "^1.20.2",
"rollup-plugin-babel": "^4.3.3",
"rollup-plugin-node-resolve": "^5.2.0",
"rollup-plugin-postcss": "^2.0.3",
"rollup-plugin-replace": "^2.2.0",
"rollup-plugin-terser": "^5.1.1",
"yargs": "^14.0.0"
}
}

22
commands/cli/README.md Normal file
View File

@@ -0,0 +1,22 @@
# @nebula.js/cli
## Install
```sh
npm install -g @nebula.js/cli@next
```
## Usage
```sh
nebula <command> [options]
Commands:
nebula build Build supernova
nebula create <name> Create a supernova
nebula serve Dev server
Options:
--version Show version number [boolean]
-h, --help Show help [boolean]
```

17
commands/cli/lib/index.js Executable file
View File

@@ -0,0 +1,17 @@
#!/usr/bin/env node
const yargs = require('yargs');
const build = require('@nebula.js/cli-build/command');
const create = require('@nebula.js/cli-create/command');
const serve = require('@nebula.js/cli-serve/command');
const sense = require('@nebula.js/cli-sense/command');
yargs
.usage('nebula <command> [options]')
.command(build)
.command(create)
.command(serve)
.command(sense)
.demandCommand()
.alias('h', 'help')
.wrap(Math.min(80, yargs.terminalWidth())).argv;

32
commands/cli/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "@nebula.js/cli",
"version": "0.1.0-alpha.18",
"description": "",
"license": "MIT",
"author": "QlikTech International AB",
"keywords": [],
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "https://github.com/qlik-oss/nebula.js.git"
},
"files": [
"lib"
],
"bin": {
"nebula": "lib/index.js"
},
"scripts": {
"lint": "eslint src"
},
"dependencies": {
"@nebula.js/cli-build": "0.1.0-alpha.18",
"@nebula.js/cli-create": "0.1.0-alpha.18",
"@nebula.js/cli-sense": "0.1.0-alpha.18",
"@nebula.js/cli-serve": "0.1.0-alpha.18",
"chalk": "^2.4.2",
"yargs": "^14.0.0"
}
}

32
commands/create/README.md Normal file
View File

@@ -0,0 +1,32 @@
# @nebula.js/cli-create
## Install
```sh
npm install -g @nebula.js/cli@next
```
## Usage
### CLI
```sh
nebula create <name>
Create a supernova
Positionals:
name name of the project [string] [required]
Options:
--version Show version number [boolean]
--install Run package installation step [boolean] [default: true]
--pkgm Package manager [string] [choices: "npm", "yarn"]
-h, --help Show help [boolean]
```
#### Example
```sh
nebula create hello-sunshine
```

View File

@@ -0,0 +1,34 @@
const create = require('./lib/create');
module.exports = {
command: 'create <name>',
desc: 'Create a supernova',
builder(yargs) {
yargs.positional('name', {
type: 'string',
description: 'name of the project',
});
yargs.option('install', {
type: 'boolean',
default: true,
description: 'Run package installation step',
});
yargs.option('pkgm', {
type: 'string',
choices: ['npm', 'yarn'],
description: 'Package manager',
});
yargs.option('picasso', {
type: 'string',
choices: ['none', 'minimal', 'barchart'],
description: 'Picasso template',
});
yargs.option('author', {
type: 'string',
description: 'Package author',
});
},
handler(argv) {
create(argv);
},
};

View File

@@ -0,0 +1,198 @@
const chalk = require('chalk');
const path = require('path');
const fs = require('fs');
const fse = require('fs-extra');
const ejs = require('ejs');
const inquirer = require('inquirer');
const { execSync } = require('child_process');
const pkg = require('../package.json');
const hasYarn = () => {
try {
execSync('yarnpkg --version', { stdio: 'ignore' });
return true;
} catch (e) {
return false;
}
};
const author = async cwd => {
try {
const email = execSync('git config --get user.email', { cwd })
.toString()
.trim();
const name = execSync('git config --get user.name', { cwd })
.toString()
.trim();
return {
email,
name,
};
} catch (e) {
return {
email: '',
name: '',
};
}
};
const parseAuthor = (str = '') => {
const m = str.match(/([^<(]+)?(<([^\s]+)>)?/);
return {
name: m && m[1] ? m[1].trim() : '',
email: m && m[3] ? m[3].trim() : '',
};
};
function cpy(root, destination) {
return (source, target, data) => {
if (data) {
const content = fs.readFileSync(path.resolve(root, ...source.split('/')), { encoding: 'utf8' });
const rendered = ejs.render(content, data);
fs.writeFileSync(path.resolve(destination, ...target.split('/')), rendered);
} else {
fse.copyFileSync(path.resolve(root, ...source.split('/')), path.resolve(destination, ...target.split('/')));
}
};
}
const create = async argv => {
const { name } = argv;
const projectFolder = name;
const packageName = name.split('/').slice(-1)[0];
const cwd = process.cwd();
const templatesRoot = path.resolve(__dirname, '..', 'templates');
const destination = path.resolve(cwd, projectFolder);
let options = {
install: true,
...argv,
pkgm: argv.pkgm || ((await hasYarn()) ? 'yarn' : 'npm'),
author: argv.author ? parseAuthor(argv.author) : await author(),
};
const results = {};
if (await fse.exists(destination)) {
console.error(chalk.red(`Oopsie, looks like '${projectFolder}' already exists. Try a different name.`));
process.exit(1);
}
const prompt = async () => {
const answers = await inquirer.prompt([
{
type: 'list',
name: 'picasso',
message: 'Pick a picasso template',
default: 'none',
choices: ['none', 'minimal', 'barchart'],
when: !argv.picasso,
},
]);
options = { ...options, ...answers };
};
const write = async () => {
console.log('\n');
console.log('> Generating files...');
const { picasso } = options;
fse.ensureDirSync(destination);
// ==== common files ====
// copy raw files
['editorconfig', 'eslintignore', 'gitignore', 'eslintrc.json'].forEach(filename =>
fs.copyFileSync(
path.resolve(templatesRoot, 'common', `_${filename}`), // copying dotfiles may not always work, so they are prefixed with an underline
path.resolve(destination, `.${filename}`)
)
);
const copy = cpy(templatesRoot, destination);
copy('common/README.md', 'README.md', { name: packageName });
// ==== template files ====
const folders = [];
if (picasso !== 'none') {
folders.push('picasso/common');
folders.push(`picasso/${picasso}`);
} else {
folders.push('none');
}
const traverse = (sourceFolder, targetFolder = '') => {
const files = fs.readdirSync(path.resolve(templatesRoot, sourceFolder));
files.forEach(file => {
const p = `${sourceFolder}/${file}`;
const stats = fs.lstatSync(path.resolve(templatesRoot, p));
const next = `${targetFolder}/${file}`.replace(/^\//, '');
if (stats.isDirectory()) {
fse.ensureDirSync(path.resolve(destination, next));
traverse(p, next);
} else if (file === '_package.json') {
copy(`${sourceFolder}/_package.json`, 'package.json', {
name: packageName,
description: '',
user: options.author.name,
email: options.author.email,
nebulaVersion: pkg.version,
});
} else {
copy(`${sourceFolder}/${file}`, next);
}
});
};
folders.forEach(folder => {
traverse(folder);
});
};
const install = async () => {
if (options.install !== false) {
console.log('> Installing dependencies...');
console.log('\n');
const command = `${options.pkgm} install`;
try {
execSync(command, {
cwd: destination,
stdio: 'inherit',
});
console.log('\n');
} catch (e) {
console.log('\n');
console.log(`> Something went wrong when running ${chalk.yellow(command)}, try running the command yourself.`);
results.fail = true;
results.failedInstall = true;
}
}
};
const end = async () => {
const p = options.pkgm;
if (!results.fail) {
console.log(`> Successfully created project ${chalk.yellow(options.name)}`);
}
console.log('> Get started with the following commands:');
console.log('\n');
console.log(chalk.cyan(` cd ${projectFolder}`));
if (options.install === false || results.failedInstall) {
console.log(chalk.cyan(` ${p} install`));
}
console.log(chalk.cyan(` ${p} run start`));
console.log('\n');
};
await prompt();
await write();
await install();
await end();
};
module.exports = create;

View File

@@ -0,0 +1,30 @@
{
"name": "@nebula.js/cli-create",
"version": "0.1.0-alpha.18",
"description": "",
"license": "MIT",
"author": "QlikTech International AB",
"keywords": [],
"publishConfig": {
"access": "public"
},
"main": "lib/create.js",
"repository": {
"type": "git",
"url": "https://github.com/qlik-oss/nebula.js.git"
},
"files": [
"command.js",
"templates",
"lib"
],
"scripts": {
"generate": "yo ./generator/index.js"
},
"dependencies": {
"chalk": "^2.4.2",
"ejs": "^2.6.2",
"fs-extra": "^8.1.0",
"inquirer": "^7.0.0"
}
}

View File

@@ -0,0 +1,7 @@
# <%= name %>
## Usage
```js
npm install <%= name %>
```

View File

@@ -0,0 +1,15 @@
# http://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
insert_final_newline = false
trim_trailing_whitespace = false

View File

@@ -0,0 +1,3 @@
dist
coverage
node_modules

View File

@@ -0,0 +1,42 @@
{
"root": true,
"env": {
"browser": true
},
"parserOptions": {
"sourceType": "module"
},
"extends": [
"airbnb-base"
],
"rules": {
"max-len": 0,
"no-plusplus": 0,
"no-bitwise" : 0,
"no-unused-expressions": 0,
"import/no-extraneous-dependencies": [2, { "devDependencies": true }]
},
"overrides": [
{
"files": ["**/*.spec.js"],
"env": {
"browser": false,
"node": true,
"mocha": true
},
"globals": {
"chai": false,
"expect": false,
"sinon": false,
"aw": false,
"page": false
},
"plugins": [
"mocha"
],
"rules": {
"mocha/no-exclusive-tests": "error"
}
}
]
}

View File

@@ -0,0 +1,11 @@
*.log
*.log
.cache
.DS_Store
.idea
.vscode
.npmrc
node_modules
coverage
dist/

View File

@@ -0,0 +1,31 @@
{
"name": "<%= name %>",
"version": "0.1.0",
"description": "<%= description %>",
"license": "MIT",
"author": {
"name": "<%= user %>",
"email": "<%= email %>"
},
"keywords": ["qlik", "nebula", "supernova"],
"files": ["dist"],
"engines": {
"node": ">=8"
},
"main": "dist/<%= name %>.js",
"module": "dist/<%= name %>.esm.js",
"scripts": {
"build": "nebula build",
"lint": "eslint src",
"start": "nebula serve",
"test:integration": "aw puppet --testExt '*.int.js' --glob 'test/integration/**/*.int.js' --chrome.headless true --mocha.timeout 15000"
},
"devDependencies": {
"@after-work.js/aw": "^6.0.3",
"@nebula.js/cli": "<%= nebulaVersion %>",
"eslint": "^5.12.1",
"eslint-config-airbnb-base": "^13.1.0",
"eslint-plugin-import": "^2.15.0",
"eslint-plugin-mocha": "^5.2.1"
}
}

View File

@@ -0,0 +1,3 @@
export default {
targets: [],
};

View File

@@ -0,0 +1,25 @@
import properties from './object-properties';
import data from './data';
export default function supernova(env) {
return {
qae: {
properties,
data,
},
component: {
created() {
console.log('created', env);
},
mounted(element) {
element.innerHTML = '<div>Hello!</div>'; // eslint-disable-line
},
render({ layout, context }) {
console.log('render', layout, context);
},
resize() {},
willUnmount() {},
destroy() {},
},
};
}

View File

@@ -0,0 +1,8 @@
const properties = {
showTitles: true,
title: '',
subtitle: '',
footnote: '',
};
export default properties;

View File

@@ -0,0 +1,10 @@
describe('sn', () => {
const content = '.nebulajs-sn';
it('should say hello', async () => {
const app = encodeURIComponent(process.env.APP_ID || '/apps/ctrl00.qvf');
await page.goto(`${process.testServer.url}/render/app/${app}`);
await page.waitForFunction(`!!document.querySelector('${content}')`);
const text = await page.$eval(content, el => el.textContent);
expect(text).to.equal('Hello!');
});
});

View File

@@ -0,0 +1,19 @@
const serve = require('@nebula.js/cli-serve'); // eslint-disable-line
let s;
before(async () => {
s = await serve({
open: false,
});
process.testServer = s;
page.on('pageerror', e => {
console.log('Error:', e.message, e.stack);
});
});
after(() => {
s.close();
});

View File

@@ -0,0 +1,15 @@
export default {
targets: [
{
path: 'qHyperCubeDef',
dimensions: {
min: 1,
max: 1,
},
measures: {
min: 1,
max: 1,
},
},
],
};

View File

@@ -0,0 +1,15 @@
const properties = {
qHyperCubeDef: {
qDimensions: [],
qMeasures: [],
qInitialDataFetch: [{ qWidth: 2, qHeight: 5000 }],
qSuppressZero: false,
qSuppressMissing: true,
},
showTitles: true,
title: '',
subtitle: '',
footnote: '',
};
export default properties;

View File

@@ -0,0 +1,68 @@
export default function picassoDefinition({ layout, context }) {
if (!layout.qHyperCube) {
throw new Error('Layout is missing a hypercube');
}
return {
scales: {
x: { data: { extract: { field: 'qDimensionInfo/0' } } },
y: {
data: { field: 'qMeasureInfo/0' },
expand: 0.2,
include: [0],
invert: true,
},
},
components: [
{
type: 'axis',
dock: 'left',
scale: 'y',
},
{
type: 'axis',
dock: 'bottom',
scale: 'x',
},
{
type: 'box',
data: {
extract: {
field: 'qDimensionInfo/0',
props: {
start: 0,
end: { field: 'qMeasureInfo/0' },
},
},
},
settings: {
major: { scale: 'x' },
minor: { scale: 'y' },
box: {
width: 0.7,
},
},
brush:
context.permissions.indexOf('interact') !== -1 && context.permissions.indexOf('select') !== -1
? {
trigger: [
{
contexts: ['selection'],
},
],
consume: [
{
context: 'selection',
data: ['', 'end'],
style: {
inactive: {
opacity: 0.3,
},
},
},
],
}
: {},
},
],
};
}

View File

@@ -0,0 +1,17 @@
describe('interaction', () => {
const content = '.nebulajs-sn';
it('should select two bars', async () => {
const app = encodeURIComponent(process.env.APP_ID || '/apps/ctrl00.qvf');
await page.goto(
`${process.testServer.url}/render/app/${app}?cols=Alpha,=5+avg(Expression1)&&permissions=interact,select`
);
await page.waitForFunction(`!!document.querySelector('${content}')`);
await page.click('rect[data-label="K"]');
await page.click('rect[data-label="S"]');
await page.click('button[title="Confirm selection"]');
const rects = await page.$$eval('rect[data-label]', sel => sel.map(r => r.getAttribute('data-label')));
expect(rects).to.eql(['K', 'S']);
});
});

View File

@@ -0,0 +1,33 @@
{
"name": "<%= name %>",
"version": "0.1.0",
"description": "<%= description %>",
"license": "MIT",
"author": {
"name": "<%= user %>",
"email": "<%= email %>"
},
"keywords": ["qlik", "nebula", "supernova"],
"files": ["dist"],
"engines": {
"node": ">=8"
},
"main": "dist/<%= name %>.js",
"module": "dist/<%= name %>.esm.js",
"scripts": {
"build": "nebula build",
"lint": "eslint src",
"start": "nebula serve",
"test:integration": "aw puppet --testExt '*.int.js' --glob 'test/integration/**/*.int.js' --chrome.headless true --chrome.slowMo 10"
},
"devDependencies": {
"@after-work.js/aw": "^6.0.3",
"@nebula.js/cli": "<%= nebulaVersion %>",
"eslint": "^5.12.1",
"eslint-config-airbnb-base": "^13.1.0",
"eslint-plugin-import": "^2.15.0",
"eslint-plugin-mocha": "^5.2.1",
"picasso.js": "^0.25.2",
"picasso-plugin-q": "^0.25.2"
}
}

View File

@@ -0,0 +1,49 @@
import picassojs from 'picasso.js';
import picassoQ from 'picasso-plugin-q';
import properties from './object-properties';
import data from './data';
import picSelections from './pic-selections';
import definition from './pic-definition';
export default function supernova(/* env */) {
const picasso = picassojs();
picasso.use(picassoQ);
return {
qae: {
properties,
data,
},
component: {
created() {},
mounted(element) {
this.pic = picasso.chart({
element,
data: [],
settings: {},
});
this.picsel = picSelections({
selections: this.selections,
brush: this.pic.brush('selection'),
picassoQ,
});
},
render({ layout, context }) {
this.pic.update({
data: [
{
type: 'q',
key: 'qHyperCube',
data: layout.qHyperCube,
},
],
settings: definition({ layout, context }),
});
},
resize() {},
willUnmount() {},
destroy() {},
},
};
}

View File

@@ -0,0 +1,131 @@
/* eslint no-param-reassign: 0 */
// --- enable keyboard accessibility ---
// pressing enter (escape) key should confirm (cancel) selections
const KEYS = {
ENTER: 'Enter',
ESCAPE: 'Escape',
IE11_ESC: 'Esc',
SHIFT: 'Shift',
};
const instances = [];
let expando = 0;
const confirmOrCancelSelection = e => {
const active = instances.filter(a => a.selections && a.selections.isActive());
if (!active.length) {
return;
}
if (e.key === KEYS.ENTER) {
active.forEach(a => a.selections.confirm());
} else if (e.key === KEYS.ESCAPE || e.key === KEYS.IE11_ESC) {
active.forEach(a => a.selections.cancel());
}
};
const setup = () => {
document.addEventListener('keyup', confirmOrCancelSelection);
};
const teardown = () => {
document.removeEventListener('keyup', confirmOrCancelSelection);
};
// ------------------------------------------------------
const addListeners = (emitter, listeners) => {
Object.keys(listeners).forEach(type => {
emitter.on(type, listeners[type]);
});
};
const removeListeners = (emitter, listeners) => {
Object.keys(listeners).forEach(type => {
emitter.removeListener(type, listeners[type]);
});
};
export default function({ selections, brush, picassoQ } = {}, { path = '/qHyperCubeDef' } = {}) {
if (!selections) {
return {
release: () => {},
};
}
const key = ++expando;
let layout = null;
// interceptors primary job is to ensure selections only occur on either values OR ranges
const valueInterceptor = added => {
const brushes = brush.brushes();
brushes.forEach(b => {
if (b.type === 'range') {
// has range selections
brush.clear([]);
} else if (added[0] && added[0].key !== b.id) {
// has selections in another dimension
brush.clear([]);
}
});
return added.filter(t => t.value !== -2); // do not allow selection on null value
};
const rangeInterceptor = a => {
const v = brush.brushes().filter(b => b.type === 'value');
if (v.length) {
// has dimension values selected
brush.clear([]);
return a;
}
return a;
};
brush.intercept('set-ranges', rangeInterceptor);
brush.intercept('toggle-ranges', rangeInterceptor);
brush.intercept('toggle-values', valueInterceptor);
brush.intercept('set-values', valueInterceptor);
brush.intercept('add-values', valueInterceptor);
brush.on('start', () => selections.begin(path));
const selectionListeners = {
activate: () => {
// TODO - check if we can select in the current chart,
},
deactivated: () => brush.end(),
cleared: () => brush.clear(),
canceled: () => brush.end(),
};
addListeners(selections, selectionListeners);
brush.on('update', () => {
const generated = picassoQ.selections(brush, {}, layout);
generated.forEach(s => selections.select(s));
});
if (instances.length === 0) {
setup();
}
instances.push({
key,
selections,
});
return {
layout: lt => {
layout = lt;
},
release: () => {
layout = null;
const idx = instances.indexOf(instances.filter(i => i.key === key)[0]);
if (idx !== -1) {
instances.splice(idx, 1);
}
if (!instances.length) {
teardown();
}
removeListeners(selections, selectionListeners);
},
};
}

View File

@@ -0,0 +1,19 @@
const serve = require('@nebula.js/cli-serve'); // eslint-disable-line
let s;
before(async () => {
s = await serve({
build: false,
});
process.testServer = s;
page.on('pageerror', e => {
console.log('Error:', e.message, e.stack);
});
});
after(() => {
s.close();
});

View File

@@ -0,0 +1,13 @@
export default {
targets: [
{
path: 'qHyperCubeDef',
dimensions: {
min: 1,
},
measures: {
min: 1,
},
},
],
};

View File

@@ -0,0 +1,15 @@
const properties = {
qHyperCubeDef: {
qDimensions: [],
qMeasures: [],
qInitialDataFetch: [{ qWidth: 10, qHeight: 500 }],
qSuppressZero: false,
qSuppressMissing: true,
},
showTitles: true,
title: '',
subtitle: '',
footnote: '',
};
export default properties;

View File

@@ -0,0 +1,9 @@
export default function({
layout, // eslint-disable-line no-unused-vars
context, // eslint-disable-line no-unused-vars
}) {
return {
scales: {},
components: [],
};
}

21
commands/sense/README.md Normal file
View File

@@ -0,0 +1,21 @@
# @nebula.js/cli-sense
## Install
```sh
npm install @nebula.js/cli@next
```
## Usage
```sh
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]
-h, --help Show help [boolean]
```

27
commands/sense/command.js Normal file
View File

@@ -0,0 +1,27 @@
const build = require('./lib/build');
module.exports = {
command: 'sense',
desc: 'Build a supernova as a Qlik Sense extension',
builder(yargs) {
yargs.option('ext', {
type: 'string',
required: false,
desc: 'Extension definition',
});
yargs.option('meta', {
type: 'string',
required: false,
desc: 'Extension meta information',
});
yargs.option('minify', {
type: 'boolean',
required: false,
default: true,
desc: 'Minify and uglify code',
});
},
handler(argv) {
build(argv);
},
};

101
commands/sense/lib/build.js Normal file
View File

@@ -0,0 +1,101 @@
const path = require('path');
const fs = require('fs');
const rollup = require('rollup');
const nodeResolve = require('rollup-plugin-node-resolve');
const common = require('rollup-plugin-commonjs');
const babel = require('rollup-plugin-babel');
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 targetDirectory = path.resolve(cwd, `${extName}-ext`);
let extDefinition = path.resolve(__dirname, '../src/ext-definition');
if (argv.ext) {
extDefinition = path.resolve(argv.ext);
}
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
)
);
}
module.exports = build;

View File

@@ -0,0 +1,43 @@
{
"name": "@nebula.js/cli-sense",
"version": "0.1.0-alpha.18",
"description": "Build a supernova as a Qlik Sense extension",
"license": "MIT",
"author": "QlikTech International AB",
"keywords": [
"nebula",
"sense",
"qlik",
"extension"
],
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "https://github.com/qlik-oss/nebula.js.git"
},
"main": "lib/build.js",
"files": [
"command.js",
"lib",
"src"
],
"scripts": {
"lint": "eslint src"
},
"dependencies": {
"@babel/cli": "^7.5.5",
"@babel/core": "^7.5.5",
"@babel/preset-env": "^7.5.5",
"@nebula.js/supernova": "0.1.0-alpha.18",
"chalk": "^2.4.2",
"node-event-emitter": "^0.0.1",
"rollup": "^1.20.2",
"rollup-plugin-babel": "^4.3.3",
"rollup-plugin-commonjs": "^10.0.2",
"rollup-plugin-node-resolve": "^5.2.0",
"rollup-plugin-terser": "^5.1.1",
"yargs": "^14.0.0"
}
}

View File

@@ -0,0 +1,101 @@
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,20 @@
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,36 @@
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

@@ -0,0 +1,72 @@
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();
scope.backendApi.beginSelections();
selectionAPI.emit('activated');
},
clear() {
scope.backendApi.model.resetMadeSelections();
},
confirm() {
scope.selectionsApi.confirm();
},
cancel() {
scope.selectionsApi.cancel();
},
select(s) {
if (s.method !== 'resetMadeSelections' && !scope.selectionsApi.selectionsMade) {
scope.backendApi.beginSelections();
}
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

@@ -0,0 +1,71 @@
import supernova from '@nebula.js/supernova';
import permissions from './permissions';
import selectionsApi from './selections';
import snDefinition from 'snDefinition'; // eslint-disable-line
import extDefinition from 'extDefinition'; // eslint-disable-line
const env = {
Promise,
};
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,
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) {
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.snComponent = sn.component;
this.snComponent.created({
options: this.options || {},
});
this.snComponent.mounted(element);
},
paint($element, layout) {
return this.snComponent.render({
layout,
context: {
permissions: permissions(this.options, this.backendApi),
},
});
},
destroy() {
if (this.snComponent) {
this.snComponent.willUnmount();
this.snComponent.destroy();
}
},
};

53
commands/serve/README.md Normal file
View File

@@ -0,0 +1,53 @@
# @nebula.js/cli-serve
Basic web development server for supernovas.
## Install
```sh
npm install @nebula.js/cli@next -g
```
## Usage
### CLI
```sh
nebula serve -h
Dev server
Options:
--version Show version number [boolean]
--entry File entrypoint [string]
--build [boolean] [default: true]
--host [string]
--port [number]
--enigma.host [string]
--enigma.port [default: 9076]
-h, --help Show help [boolean]
```
#### Example
Start the server and connect to enigma on port `9077`
```sh
nebula serve --enigma.port 9077
```
### node.js API
```js
const serve = require('@nebula.js/cli-serve');
serve({
port: 3000,
entry: path.resolve(__dirname, 'sn.js') // custom entrypoint
enigma: {
port: 9077
}
}).then(s => {
s.url; // serve url
s.close(); // close the server
});
```

32
commands/serve/command.js Normal file
View File

@@ -0,0 +1,32 @@
const serve = require('./lib/serve');
module.exports = {
command: 'serve',
desc: 'Dev server',
builder(yargs) {
yargs.option('entry', {
type: 'string',
description: 'File entrypoint',
});
yargs.option('build', {
type: 'boolean',
default: true,
});
yargs.option('host', {
type: 'string',
});
yargs.option('port', {
type: 'number',
});
yargs.option('enigma.host', {
type: 'string',
});
yargs.option('enigma.port', {
type: 'port',
default: 9076,
}).argv;
},
handler(argv) {
serve(argv);
},
};

Binary file not shown.

View File

@@ -0,0 +1,11 @@
version: "3.1"
services:
engine:
image: qlikcore/engine:${ENGINE_VERSION:-12.368.0}
# restart: always
command: -S AcceptEULA=${ACCEPT_EULA:-no} -S DocumentDirectory=/apps
ports:
- ${ENGINE_PORT:-9076}:9076
volumes:
- ${APPS_PATH:-./data/apps}:/apps

View File

@@ -0,0 +1,55 @@
const path = require('path');
const execa = require('execa');
/* eslint no-use-before-define:0 */
const startEngine = () => {
if (process.env.ACCEPT_EULA == null || process.env.ACCEPT_EULA.toLowerCase() !== 'yes') {
throw new Error('Need to accept EULA in order to start engine container');
}
console.log('Starting engine container ...');
return new Promise((resolve, reject) => {
const c = execa.command('cross-env ACCEPT_EULA=yes docker-compose up -d --build', {
cwd: path.resolve(__dirname, '../'),
stdio: 'inherit',
shell: true,
});
const ping = setInterval(() => {
const { stdout } = execa.command('docker ps -q -f name=engine -f status=running');
if (stdout) {
console.log('... engine container running');
clear();
resolve();
}
}, 1000);
const timeout = setTimeout(() => {
clear();
reject();
}, 60000);
function clear() {
clearInterval(ping);
clearTimeout(timeout);
}
c.on('exit', code => {
if (code !== 0) {
clear();
reject();
}
});
});
};
const stopEngine = () => {
execa.shellSync('docker-compose down', {
cwd: path.resolve(__dirname, '../'),
stdio: 'inherit',
});
};
module.exports = {
startEngine,
stopEngine,
};

View File

@@ -0,0 +1,7 @@
export default {
component: {
mounted() {
console.log('mounted');
},
},
};

View File

@@ -0,0 +1,78 @@
const path = require('path');
const fs = require('fs');
const chalk = require('chalk');
const portfinder = require('portfinder');
const { watch } = require('@nebula.js/cli-build');
const webpackServe = require('./webpack.serve.js');
const { startEngine, stopEngine } = require('./engine');
module.exports = async argv => {
if (process.env.ACCEPT_EULA === 'yes') {
await startEngine();
}
const port = argv.port || (await portfinder.getPortPromise());
const host = argv.host || 'localhost';
const enigmaConfig = {
port: 9076,
host: 'localhost',
...argv.enigma,
};
const context = process.cwd();
let snPath;
let snName;
let watcher;
if (argv.entry) {
snPath = path.resolve(context, argv.entry);
const parsed = path.parse(snPath);
snName = parsed.name;
} else {
if (argv.build !== false) {
watcher = await watch();
}
try {
const externalPkg = require(path.resolve(context, 'package.json')); // eslint-disable-line global-require
const externalEntry = externalPkg.module || externalPkg.main;
snName = externalPkg.name;
snPath = path.resolve(context, externalEntry);
} catch (e) {
//
}
}
if (!fs.existsSync(snPath)) {
const rel = path.relative(context, snPath);
console.log(chalk.red(`The specified entry point ${chalk.yellow(rel)} does not exist`));
return;
}
const server = await webpackServe({
host,
port,
enigmaConfig,
snName,
snPath,
dev: process.env.MONO === 'true',
open: argv.open !== false,
watcher,
});
const close = () => {
if (process.env.ACCEPT_EULA === 'yes') {
stopEngine();
}
if (watcher) {
watcher.close();
}
server.close();
};
['SIGINT', 'SIGTERM'].forEach(signal => {
process.on(signal, close);
});
return { //eslint-disable-line
url: server.url,
close,
};
};

15
commands/serve/lib/sn.js Normal file
View File

@@ -0,0 +1,15 @@
import def from 'snDefinition'; // eslint-disable-line
window.snDefinition = def;
let cb = () => {};
window.hotReload = fn => {
cb = fn;
};
if (module.hot) {
module.hot.accept('snDefinition', () => {
window.snDefinition = def;
cb();
});
}

View File

@@ -0,0 +1,85 @@
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const babelPath = require.resolve('babel-loader');
const babelPresetEnvPath = require.resolve('@babel/preset-env');
const babelPresetReactPath = require.resolve('@babel/preset-react');
const cfg = ({ srcDir, distDir, snPath, dev = false }) => {
const config = {
mode: dev ? 'development' : 'production',
entry: {
eRender: [path.resolve(srcDir, 'eRender')],
eDev: [path.resolve(srcDir, 'eDev')],
eHub: [path.resolve(srcDir, 'eHub')],
},
devtool: 'source-map',
output: {
path: distDir,
filename: '[name].js',
},
resolve: {
alias: {
snDefinition: snPath,
},
extensions: ['.js', '.jsx'],
},
externals: dev ? {} : 'snDefinition',
module: {
rules: [
{
test: /\.jsx?$/,
sideEffects: false,
include: [srcDir, /nucleus/, /ui\/icons/],
use: {
loader: babelPath,
options: {
presets: [
[
babelPresetEnvPath,
{
modules: false,
targets: {
browsers: ['last 2 chrome versions'],
},
},
],
babelPresetReactPath,
],
},
},
},
],
},
plugins: [
new HtmlWebpackPlugin({
template: path.resolve(srcDir, 'eRender.html'),
filename: 'eRender.html',
chunks: ['eRender'],
}),
new HtmlWebpackPlugin({
template: path.resolve(srcDir, 'eDev.html'),
filename: 'eDev.html',
chunks: ['eDev'],
}),
new HtmlWebpackPlugin({
template: path.resolve(srcDir, 'eHub.html'),
filename: 'eHub.html',
chunks: ['eHub'],
}),
],
};
return config;
};
if (!process.env.DEFAULTS) {
module.exports = cfg;
} else {
module.exports = cfg({
srcDir: path.resolve(__dirname, '../web'),
distDir: path.resolve(__dirname, '../dist'),
snPath: path.resolve(__dirname, 'placeholder'),
dev: false,
});
}

View File

@@ -0,0 +1,48 @@
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const webpack = require('webpack');
const sourceMapLoaderPath = require.resolve('source-map-loader');
const cfg = ({ srcDir = path.resolve(__dirname, '../dist'), snPath = path.resolve(__dirname, 'placeholder') }) => {
const config = {
mode: 'development',
entry: path.resolve(__dirname, './sn.js'),
devtool: 'source-map',
output: {
path: path.resolve(srcDir, 'temp'),
filename: '[name].js',
},
resolve: {
alias: {
snDefinition: snPath,
},
},
module: {
rules: [
{
enforce: 'pre',
test: /\.js$/,
loader: sourceMapLoaderPath,
},
],
},
plugins: [
new HtmlWebpackPlugin({
template: path.resolve(srcDir, 'eRender.html'),
filename: 'eRender.html',
inject: 'head',
}),
new HtmlWebpackPlugin({
template: path.resolve(srcDir, 'eDev.html'),
filename: 'eDev.html',
inject: 'head',
}),
new webpack.HotModuleReplacementPlugin(),
],
};
return config;
};
module.exports = cfg;

View File

@@ -0,0 +1,128 @@
/* eslint global-require: 0 */
const path = require('path');
const chalk = require('chalk');
const webpack = require('webpack');
const WebpackDevServer = require('webpack-dev-server');
module.exports = async ({ host, port, enigmaConfig, snPath, snName, dev = false, open = true, watcher }) => {
let config;
let contentBase;
if (dev) {
const webpackConfig = require('./webpack.build.js');
const srcDir = path.resolve(__dirname, '../web');
const distDir = path.resolve(srcDir, '../dist');
contentBase = distDir;
config = webpackConfig({
srcDir,
distDir,
dev: true,
snPath,
});
} else {
const webpackConfig = require('./webpack.prod.js');
const srcDir = path.resolve(__dirname, '../dist');
contentBase = srcDir;
config = webpackConfig({
srcDir,
snPath,
});
}
const options = {
clientLogLevel: 'none',
hot: true,
host,
port,
overlay: {
warnings: false,
errors: true,
},
quiet: true,
open,
contentBase: [contentBase],
historyApiFallback: {
index: '/eHub.html',
},
before(app) {
app.get('/info', (req, res) => {
res.json({
enigma: enigmaConfig,
supernova: {
name: snName,
},
});
});
},
proxy: [
{
context: '/render',
target: `http://${host}:${port}/eRender.html`,
ignorePath: true,
},
{
context: '/dev',
target: `http://${host}:${port}/eDev.html`,
ignorePath: true,
},
],
watchOptions: {
ignored: /node_modules/,
},
};
console.log('Starting development server...');
WebpackDevServer.addDevServerEntrypoints(config, options);
const compiler = webpack(config);
const server = new WebpackDevServer(compiler, options);
const close = () => {
server.close();
};
['SIGINT', 'SIGTERM'].forEach(signal => {
process.on(signal, close);
});
if (watcher) {
watcher.on('event', event => {
if (event.code === 'ERROR') {
server.sockWrite(server.sockets, 'errors', [event.error.stack]);
}
});
}
let initiated = false;
return new Promise((resolve, reject) => {
// eslint-disable-line consistent-return
compiler.hooks.done.tap('nebula serve', stats => {
if (!initiated) {
initiated = true;
const url = `http://${host}:${port}`;
console.log(`...running at ${chalk.green(url)}`);
resolve({
context: '',
url,
close,
});
if (stats.hasErrors()) {
stats.compilation.errors.forEach(e => {
console.log(chalk.red(e));
});
process.exit(1);
}
}
});
server.listen(port, host, err => {
if (err) {
reject(err);
}
});
});
};

View File

@@ -0,0 +1,53 @@
{
"name": "@nebula.js/cli-serve",
"version": "0.1.0-alpha.18",
"description": "",
"license": "MIT",
"author": "QlikTech International AB",
"keywords": [],
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "https://github.com/qlik-oss/nebula.js.git"
},
"main": "lib/serve.js",
"files": [
"command.js",
"data",
"docker-compose.yml",
"lib",
"dist"
],
"scripts": {
"build": "cross-env NODE_ENV=production DEFAULTS=true webpack --config ./lib/webpack.build.js",
"lint": "eslint web",
"prepublishOnly": "rm -rf dist && yarn run build"
},
"dependencies": {
"@nebula.js/cli-build": "0.1.0-alpha.18",
"chalk": "^2.4.2",
"cross-env": "^5.2.0",
"execa": "^2.0.4",
"html-webpack-plugin": "^3.2.0",
"portfinder": "^1.0.23",
"source-map-loader": "^0.2.4",
"webpack": "^4.39.2",
"webpack-dev-server": "^3.8.0",
"yargs": "^14.0.0"
},
"devDependencies": {
"@babel/core": "^7.5.5",
"@babel/preset-env": "^7.5.5",
"@babel/preset-react": "^7.0.0",
"@material-ui/core": "^4.3.3",
"@nebula.js/nucleus": "0.1.0-alpha.18",
"@nebula.js/ui": "0.1.0-alpha.18",
"babel-loader": "^8.0.6",
"enigma.js": "^2.4.0",
"react": "^16.9.0",
"react-dom": "^16.9.0",
"webpack-cli": "^3.3.7"
}
}

View File

@@ -0,0 +1,23 @@
{
"root": true,
"env": {
"browser": true
},
"parserOptions": {
"sourceType": "module"
},
"extends": [
"airbnb"
],
"rules": {
"max-len": 0,
"no-plusplus": 0,
"no-bitwise" : 0,
"no-unused-expressions": 0,
"react/destructuring-assignment": [0, "always"],
"react/prop-types": 0,
"react/no-deprecated": 0,
"import/no-extraneous-dependencies": [2, { "devDependencies": true }],
"import/no-dynamic-require": 0
}
}

View File

@@ -0,0 +1,209 @@
import React, {
useEffect,
useLayoutEffect,
useState,
useRef,
useMemo,
} from 'react';
import nucleus from '@nebula.js/nucleus';
import snDefinition from 'snDefinition'; // eslint-disable-line
import {
createTheme,
ThemeProvider,
} from '@nebula.js/ui/theme';
import {
ChevronLeft,
WbSunny,
Brightness3,
} from '@nebula.js/ui/icons';
import {
Grid,
Toolbar,
Button,
Divider,
Switch,
FormControlLabel,
} from '@nebula.js/ui/components';
import { observe } from '@nebula.js/nucleus/src/object/observer';
import Properties from './Properties';
import Stage from './Stage';
import AppContext from '../contexts/AppContext';
import NebulaContext from '../contexts/NebulaContext';
const storage = (() => {
const stored = window.localStorage.getItem('nebula-dev');
const parsed = stored ? JSON.parse(stored) : {};
const s = {
save(name, value) {
parsed[name] = value;
window.localStorage.setItem('nebula-dev', JSON.stringify(parsed));
},
get(name) {
return parsed[name];
},
props(name, v) {
if (v) {
s.save(`props:${name}`, JSON.stringify(v)); // TODO add app id to key to avoid using fields that don't exist
return undefined;
}
const p = s.get(`props:${name}`);
return p ? JSON.parse(p) : {};
},
};
return s;
})();
export default function App({
app,
info,
}) {
const [viz, setViz] = useState(null);
const [sn, setSupernova] = useState(null);
const [isReadCacheEnabled, setReadCacheEnabled] = useState(storage.get('readFromCache') !== false);
const [darkMode, setDarkMode] = useState(storage.get('darkMode') === true);
const currentSelectionsRef = useRef(null);
const uid = useRef();
const themeName = darkMode ? 'dark' : 'light';
const theme = useMemo(() => createTheme(themeName), [themeName]);
const nebbie = useMemo(() => {
const n = nucleus(app, {
load: (type, config) => config.Promise.resolve(window.snDefinition || snDefinition),
theme: themeName,
});
return n;
}, [app]);
useLayoutEffect(() => {
nebbie.theme(themeName);
}, [nebbie, theme]);
useEffect(() => {
let propertyObserver = () => {};
const create = () => {
uid.current = String(Date.now());
nebbie.create({
type: info.supernova.name,
}, {
context: {
permissions: ['passive', 'interact', 'select', 'fetch'],
},
properties: {
...(storage.get('readFromCache') !== false ? storage.props(info.supernova.name) : {}),
qInfo: {
qId: uid.current,
qType: info.supernova.name,
},
},
}).then((v) => {
setViz(v);
propertyObserver = observe(v.model, (p) => {
storage.props(info.supernova.name, p);
}, 'properties');
});
};
nebbie.types.get({
name: info.supernova.name,
}).supernova().then(setSupernova);
nebbie.selections().mount(currentSelectionsRef.current);
if (window.hotReload) {
window.hotReload(() => {
propertyObserver();
nebbie.types.clearFromCache(info.supernova.name);
app.destroySessionObject(uid.current).then(create);
});
}
create();
const unload = () => {
app.destroySessionObject(uid.current);
};
window.addEventListener('beforeunload', unload);
return () => {
propertyObserver();
window.removeEventListener('beforeunload', unload);
};
}, []);
const handleCacheChange = (e) => {
storage.save('readFromCache', e.target.checked);
setReadCacheEnabled(e.target.checked);
};
const handleThemeChange = (e) => {
storage.save('darkMode', e.target.checked);
setDarkMode(e.target.checked);
};
return (
<AppContext.Provider value={app}>
<ThemeProvider theme={theme}>
<NebulaContext.Provider value={nebbie}>
<Grid container wrap="nowrap" direction="column" style={{ background: theme.palette.background.darkest }}>
<Grid item>
<Toolbar variant="dense" style={{ background: theme.palette.background.paper }}>
<Grid container>
<Grid item>
<Button variant="outlined" href={window.location.origin}>
<ChevronLeft style={{ marginLeft: -theme.spacing(1 * 1.5) }} />
Hub
</Button>
</Grid>
<Grid item xs />
<Grid item>
<FormControlLabel
control={
<Switch checked={isReadCacheEnabled} onChange={handleCacheChange} value="isReadFromCacheEnabled" />
}
label="Cache"
/>
<FormControlLabel
label=""
control={(
<>
<WbSunny fontSize="small" style={{ color: theme.palette.text.secondary, marginLeft: theme.spacing(2) }} />
<Switch checked={darkMode} onChange={handleThemeChange} value="darkMode" />
<Brightness3 fontSize="small" style={{ color: theme.palette.text.secondary }} />
</>
)}
/>
</Grid>
</Grid>
</Toolbar>
<Divider />
</Grid>
<Grid item>
<div ref={currentSelectionsRef} style={{ flex: '0 0 auto' }} />
<Divider />
</Grid>
<Grid item xs>
<Grid container wrap="nowrap" style={{ height: '100%' }}>
<Grid item xs style={{ overflow: 'hidden' }}>
<Stage viz={viz} />
</Grid>
<Grid item style={{ background: theme.palette.background.paper }}>
<Properties sn={sn} viz={viz} />
</Grid>
</Grid>
</Grid>
</Grid>
</NebulaContext.Provider>
</ThemeProvider>
</AppContext.Provider>
);
}

View File

@@ -0,0 +1,29 @@
import React from 'react';
import {
Grid,
Card,
} from '@nebula.js/ui/components';
import Chart from './Chart';
export default function ({
object,
onSelected,
}) {
return (
<Card style={{ minHeight: 400, position: 'relative', overflow: 'visible' }}>
<Grid
container
style={{
position: 'absolute',
height: '100%',
}}
>
<Grid item xs={12}>
<Chart id={object.qInfo.qId} onSelected={onSelected} />
</Grid>
</Grid>
</Card>
);
}

View File

@@ -0,0 +1,44 @@
import React, {
useEffect,
useContext,
useRef,
// useState,
} from 'react';
import NebulaContext from '../contexts/NebulaContext';
export default function Chart({
id,
// onSelected,
}) {
const nebbie = useContext(NebulaContext);
const el = useRef();
// const [viz, setViz] = useState(null);
useEffect(() => {
const n = nebbie.get({
id,
}, {
context: {
permissions: ['passive', 'interact', 'select', 'fetch'],
},
element: el.current,
});
// n.then(setViz);
return () => {
n.then((v) => {
v.close();
// v.unmount();
});
};
}, [id]);
return (
<div
ref={el}
// onClick={() => onSelected(viz)}
style={{
height: '100%',
}}
/>
);
}

View File

@@ -0,0 +1,45 @@
import React, {
useEffect,
useContext,
useState,
} from 'react';
import useLayout from '@nebula.js/nucleus/src/hooks/useLayout';
import {
Grid,
} from '@nebula.js/ui/components';
import AppContext from '../contexts/AppContext';
import Cell from './Cell';
export default function Collection({
type,
onSelectedCell,
}) {
const app = useContext(AppContext);
const [layout] = useLayout(app);
const [objects, setObjects] = useState([]);
useEffect(() => {
app.getObjects({
qTypes: [type],
qIncludeSessionObjects: true,
qData: {
title: '/qMetaDef/title',
},
}).then((list) => {
setObjects(list);
});
}, [layout, type]);
return (
<Grid container spacing={2} style={{ padding: '12px' }}>
{objects.map((c) => (
<Grid item xs={12} md={6} lg={4} key={c.qInfo.qId}>
<Cell object={c} onSelected={onSelectedCell} />
</Grid>
))}
</Grid>
);
}

View File

@@ -0,0 +1,165 @@
import React, {
useContext,
useState,
useMemo,
} from 'react';
import {
Popover,
List,
ListSubheader,
ListItem,
ListItemText,
ListItemIcon,
Divider,
} from '@nebula.js/ui/components';
import {
ChevronRight,
ChevronLeft,
} from '@nebula.js/ui/icons';
import {
useTheme,
} from '@nebula.js/ui/theme';
import useModel from '@nebula.js/nucleus/src/hooks/useModel';
import useLayout from '@nebula.js/nucleus/src/hooks/useLayout';
import useLibraryList from '../hooks/useLibraryList';
import AppContext from '../contexts/AppContext';
const Field = ({ field, onSelect, sub }) => (
<ListItem button onClick={() => onSelect(field.qName)} data-key={field.qName}>
<ListItemText>{field.qName}</ListItemText>
{sub && <ChevronRight fontSize="small" />}
</ListItem>
);
const LibraryItem = ({ item, onSelect }) => (
<ListItem button onClick={() => onSelect(item.qInfo)} data-key={item.qInfo.qId}>
<ListItemText>{item.qData.title}</ListItemText>
</ListItem>
);
const Aggr = ({ aggr, field, onSelect }) => (
<ListItem button onClick={() => onSelect(aggr)} data-key={aggr}>
<ListItemText>{`${aggr}(${field})`}</ListItemText>
</ListItem>
);
const LibraryList = ({
app,
onSelect,
title = '',
type = 'dimension',
}) => {
const [libraryItems] = useLibraryList(app, type);
const sortedLibraryItems = useMemo(() => libraryItems
.slice()
.sort((a, b) => a.qData.title.toLowerCase().localeCompare(b.qData.title.toLowerCase())),
[libraryItems]);
return libraryItems.length > 0 ? (
<>
<ListSubheader component="div" style={{ backgroundColor: 'inherit' }}>{title}</ListSubheader>
{sortedLibraryItems.map((item) => <LibraryItem key={item.qInfo.qId} item={item} onSelect={onSelect} />)}
</>
) : null;
};
export default function FieldsPopover({
alignTo,
show,
close,
onSelected,
type,
}) {
const app = useContext(AppContext);
const [selectedField, setSelectedField] = useState(null);
const theme = useTheme();
const [model] = useModel({
qInfo: {
qType: 'FieldList',
qId: 'FieldList',
},
qFieldListDef: {
qShowDerivedFelds: false,
qShowHidden: false,
qShowSemantic: true,
qShowSrcTables: true,
qShowSystem: false,
},
}, app);
const [layout] = useLayout(model, app);
const fields = useMemo(() => (layout ? (layout.qFieldList.qItems || []) : [])
.slice()
.sort((a, b) => a.qName.toLowerCase().localeCompare(b.qName.toLowerCase())),
[layout]);
const onSelect = (s) => {
if (s && s.qId) {
onSelected(s);
close();
} else if (type === 'measure') {
setSelectedField(s);
} else {
onSelected({
field: s,
});
close();
}
};
const onAggregateSelected = (s) => {
onSelected({
field: selectedField,
aggregation: s,
});
close();
};
return (
<Popover
open={show}
onClose={close}
anchorEl={alignTo.current}
marginThreshold={theme.spacing(1)}
elevation={3}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'center',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'center',
}}
PaperProps={{
style: { minWidth: '250px', maxHeight: '300px', background: theme.palette.background.lightest },
}}
>
{selectedField && (
<List dense component="nav">
<ListItem button onClick={() => setSelectedField(null)}>
<ListItemIcon>
<ChevronLeft />
</ListItemIcon>
<ListItemText>Back</ListItemText>
</ListItem>
<Divider />
<ListSubheader component="div">Aggregation</ListSubheader>
{['sum', 'count', 'avg', 'min', 'max'].map((v) => <Aggr key={v} aggr={v} field={selectedField} onSelect={onAggregateSelected} />)}
</List>
)}
{!selectedField && fields.length > 0 && (
<List dense component="nav" style={{ background: theme.palette.background.lightest }}>
<LibraryList app={app} onSelect={onSelect} type={type} title={type === 'measure' ? 'Measures' : 'Dimensions'} />
<ListSubheader component="div" style={{ backgroundColor: 'inherit' }}>Fields</ListSubheader>
{fields.map((field) => <Field key={field.qName} field={field} onSelect={onSelect} sub={type === 'measure'} />)}
</List>
)}
</Popover>
);
}

View File

@@ -0,0 +1,42 @@
import React from 'react';
import {
Typography,
} from '@nebula.js/ui/components';
import useProperties from '@nebula.js/nucleus/src/hooks/useProperties';
import Data from './property-panel/Data';
export default function Properties({
viz,
sn,
}) {
const [properties] = useProperties(viz ? viz.model : null);
if (!sn) {
return null;
}
if (!viz || !properties) {
return (
<div style={{
minWidth: '250px',
padding: '8px',
}}
>
<Typography>Nothing selected</Typography>
</div>
);
}
return (
<div style={{
minWidth: '250px',
padding: '8px',
}}
>
<Data properties={properties} model={viz.model} sn={sn} />
</div>
);
}

View File

@@ -0,0 +1,87 @@
import React, {
useEffect,
useRef,
useState,
useCallback,
} from 'react';
import {
Button,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
} from '@nebula.js/ui/components';
export default function PropertiesDialog({
model,
show,
close,
}) {
const text = useRef(null);
const [objectProps, setObjectProps] = useState('');
const onConfirm = useCallback(() => {
if (text.current) {
model.setProperties(JSON.parse(objectProps)).then(close);
}
});
const onChange = (e) => {
setObjectProps(e.target.value);
};
useEffect(() => {
if (!show) {
return undefined;
}
const onChanged = () => {
model && show && model.getProperties().then((props) => {
show && setObjectProps(JSON.stringify(props || {}, null, 2));
});
};
model.on('changed', onChanged);
onChanged();
return () => {
model && model.removeListener('changed', onChanged);
};
}, [model && model.id, show]);
return (
<Dialog
open={show}
scroll="paper"
maxWidth="md"
onClose={close}
PaperProps={{
style: {
width: '80%',
},
}}
>
<DialogTitle>
Modify object properties
</DialogTitle>
<DialogContent dividers>
<TextField
value={objectProps}
onChange={onChange}
ref={text}
multiline
row="40"
fullWidth
InputProps={{
style: { fontFamily: 'Monaco, monospace', fontSize: '0.8rem' },
}}
/>
</DialogContent>
<DialogActions>
<Button variant="outlined" onClick={close}>Cancel</Button>
<Button variant="outlined" onClick={onConfirm}>Confirm</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,56 @@
import React, {
useEffect,
useRef,
useCallback,
useState,
} from 'react';
import {
Button,
Grid,
Card,
Toolbar,
Divider,
} from '@nebula.js/ui/components';
import PropsDialog from './PropertiesDialog';
export default function Stage({
viz,
}) {
const c = useRef(null);
const model = viz && viz.model;
const [dialogOpen, setDialogOpen] = useState(false);
useEffect(() => {
viz && viz.mount(c.current);
}, [viz]);
const closeDialog = useCallback(() => { setDialogOpen(false); }, []);
return (
<div style={{ padding: '12px', height: '100%', boxSizing: 'border-box' }}>
<Card style={{ height: '100%' }}>
<Grid container direction="column" style={{ height: '100%' }}>
<Grid item>
<Toolbar>
<Button
variant="outlined"
disabled={!model}
onClick={() => setDialogOpen(true)}
>
Props
</Button>
<PropsDialog model={model} show={dialogOpen} close={closeDialog} />
</Toolbar>
<Divider />
</Grid>
<Grid item xs>
<div ref={c} style={{ position: 'relative', height: '100%' }} />
</Grid>
</Grid>
</Card>
</div>
);
}

View File

@@ -0,0 +1,37 @@
import React from 'react';
import {
List,
ListItem,
Typography,
} from '@nebula.js/ui/components';
import HyperCube from './HyperCube';
export default function Data({
model,
sn,
properties,
}) {
if (!sn) {
return null;
}
const { targets } = sn.qae.data;
if (!targets.length) {
return (
<Typography>No data targets found</Typography>
);
}
return (
<List>
{targets.map((t) => (
<ListItem key={t.path} divider disableGutters>
<HyperCube target={t} properties={properties} model={model} />
</ListItem>
))}
</List>
);
}

View File

@@ -0,0 +1,111 @@
import React, {
useRef,
useState,
useContext,
} from 'react';
import {
IconButton,
Button,
List,
ListItem,
ListItemText,
ListItemSecondaryAction,
// Divider,
Typography,
} from '@nebula.js/ui/components';
import Remove from '@nebula.js/ui/icons/Remove';
import useLibraryList from '../../hooks/useLibraryList';
import AppContext from '../../contexts/AppContext';
import FieldsPopover from '../FieldsPopover';
const FieldTitle = ({
field,
libraryItems,
}) => {
if (field.qLibraryId) {
const f = libraryItems.filter((ff) => ff.qInfo.qId === field.qLibraryId)[0];
return f ? f.qData.title : '!!!';
}
if (field.qDef && field.qDef.qFieldDefs) {
return field.qDef.qFieldDefs[0];
}
if (field.qDef && field.qDef.qDef) {
return field.qDef.qDef;
}
return '???';
};
export default function Fields({
items,
type = 'dimension',
label = '',
addLabel = 'Add',
onAdded,
onRemoved,
canAdd = true,
}) {
const [isActive, setIsActive] = useState(false);
const btn = useRef(null);
const app = useContext(AppContext);
const [libraryItems] = useLibraryList(app, type);
const onAdd = () => {
setIsActive(!isActive);
};
const onSelected = (o) => {
if (o.qId) {
onAdded(o);
} else if (o) {
if (type === 'dimension') {
onAdded(o.field);
} else {
onAdded(`${o.aggregation || 'sum'}([${o.field}])`);
}
}
};
const onRemove = (idx) => {
onRemoved(idx);
};
return (
<div>
<Typography variant="overline">{label}</Typography>
<List dense>
{items.map((d, i) => (
<ListItem disableGutters key={d.qDef.cId}>
<ListItemText>
<FieldTitle field={d} libraryItems={libraryItems} type={type} />
</ListItemText>
<ListItemSecondaryAction>
<IconButton edge="end" onClick={() => onRemove(i)}><Remove /></IconButton>
</ListItemSecondaryAction>
</ListItem>
))}
</List>
<Button
variant="outlined"
fullWidth
onClick={onAdd}
ref={btn}
disabled={!canAdd}
>
{addLabel}
</Button>
{isActive && (
<FieldsPopover
alignTo={btn}
show={isActive}
close={() => setIsActive(false)}
onSelected={onSelected}
type={type}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,86 @@
import React, {
useMemo,
} from 'react';
import hcHandler from '@nebula.js/nucleus/src/object/hc-handler';
import {
Typography,
} from '@nebula.js/ui/components';
import Fields from './Fields';
const getValue = (data, reference, defaultValue) => {
const steps = reference.split('/');
let dataContainer = data;
if (dataContainer === undefined) {
return defaultValue;
}
for (let i = 0; i < steps.length; ++i) {
if (steps[i] === '') {
continue; // eslint-disable-line no-continue
}
if (typeof dataContainer[steps[i]] === 'undefined') {
return defaultValue;
}
dataContainer = dataContainer[steps[i]];
}
return dataContainer;
};
export default function HyperCube({
model,
target,
properties,
}) {
const handler = useMemo(() => hcHandler({
def: target,
hc: getValue(properties, target.path),
}), [properties]);
const onDimensionAdded = (a) => {
handler.addDimension(typeof a === 'object' ? { qLibraryId: a.qId } : a);
model.setProperties(properties);
};
const onDimensionRemoved = (idx) => {
handler.removeDimension(idx);
model.setProperties(properties);
};
const onMeasureAdded = (a) => {
handler.addMeasure(typeof a === 'object' ? { qLibraryId: a.qId } : a);
model.setProperties(properties);
};
const onMeasureRemoved = (idx) => {
handler.removeMeasure(idx);
model.setProperties(properties);
};
return (
<div style={{ width: '100%' }}>
<Typography color="textSecondary" style={{ fontFamily: 'Monaco, monospace', fontSize: '0.7rem' }}>{target.path}</Typography>
<Fields
onAdded={onDimensionAdded}
onRemoved={onDimensionRemoved}
canAdd={handler.canAddDimension()}
items={handler.dimensions()}
type="dimension"
label="Dimensions"
addLabel="Add dimension"
/>
<Fields
onAdded={onMeasureAdded}
onRemoved={onMeasureRemoved}
canAdd={handler.canAddMeasure()}
items={handler.measures()}
type="measure"
label="Measures"
addLabel="Add measures"
/>
</div>
);
}

View File

@@ -0,0 +1,62 @@
import enigma from 'enigma.js';
import qixSchema from 'enigma.js/schemas/12.34.11.json';
import SenseUtilities from 'enigma.js/sense-utilities';
const params = (() => {
const opts = {};
const { pathname } = window.location;
const am = pathname.match(/\/app\/([^/?&]+)/);
if (am) {
opts.app = decodeURIComponent(am[1]);
}
window.location.search.substring(1).split('&').forEach((pair) => {
const idx = pair.indexOf('=');
const name = pair.substr(0, idx);
let value = decodeURIComponent(pair.substring(idx + 1));
if (name === 'cols') {
value = value.split(',');
}
opts[name] = value;
});
return opts;
})();
const requestInfo = fetch('/info').then((response) => response.json());
const defaultConfig = {
host: window.location.hostname || 'localhost',
port: 9076,
secure: false,
};
let connection;
const connect = () => {
if (!connection) {
connection = requestInfo.then((info) => enigma.create({
schema: qixSchema,
url: SenseUtilities.buildUrl({
...defaultConfig,
...info.enigma,
}),
}).open());
}
return connection;
};
const openApp = (id) => requestInfo.then((info) => enigma.create({
schema: qixSchema,
url: SenseUtilities.buildUrl({
...defaultConfig,
...info.enigma,
appId: id,
}),
}).open().then((global) => global.openDoc(id)));
export {
connect,
openApp,
params,
requestInfo as info,
};

View File

@@ -0,0 +1,5 @@
import React from 'react';
const AppContext = React.createContext();
export default AppContext;

View File

@@ -0,0 +1,5 @@
import React from 'react';
const NebulaContext = React.createContext();
export default NebulaContext;

View File

@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<base href="/">
<title>Nebula dev</title>
<link href="https://fonts.googleapis.com/css?family=Source+Sans+Pro" rel="stylesheet">
<link href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:light" rel="stylesheet">
<link href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:semibold" rel="stylesheet">
<style>
html, body {
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
body {
background: #ddd;
-webkit-font-smoothing: antialiased;
overflow: auto;
}
#app, #app > * {
height: 100%;
}
</style>
</head>
<body>
<div id="app"></div>
</body>
</html>

View File

@@ -0,0 +1,18 @@
import React from 'react';
import ReactDOM from 'react-dom';
import App from './components/App';
import {
openApp,
params,
info,
} from './connect';
if (!params.app) {
location.href = location.origin; //eslint-disable-line
}
info.then(($) => openApp(params.app).then((app) => {
ReactDOM.render(<App app={app} info={$} />, document.querySelector('#app'));
}));

View File

@@ -0,0 +1,73 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Nebula hub</title>
<style>
html, body {
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
body {
background: linear-gradient(110deg, #91298C 0%, #45B3B2 100%);
color: #404040;
font: normal 14px/16px "Source Sans Pro", Arial, sans-serif;
-webkit-font-smoothing: antialiased;
}
#apps {
background-color: white;
max-width: 400px;
width: 80%;
margin: 0 auto;
margin-top: 32px;
border-radius: 4px;
box-shadow: 0 32px 32px -16px rgba(0, 0, 0, 0.2);
display: flex;
flex-direction: column;
max-height: calc(100% - 64px);
}
.title {
padding: 16px;
font-size: 1.2em;
font-weight: bold;
}
#apps ul {
list-style: none;
padding: 0;
overflow-y: auto;
}
#apps li {
border-top: 1px solid #eee;
}
#apps li a {
padding: 16px;
display: block;
text-decoration: none;
color: #666;
}
#apps li a[href]:hover {
padding: 16px;
background: #eee;
}
</style>
</head>
<body>
<div id="apps">
<div class="title">Apps</div>
<ul>
<li><a>Loading...</a></li>
</ul>
</div>
</body>
</html>

View File

@@ -0,0 +1,14 @@
import { connect } from './connect';
const ul = document.querySelector('#apps ul');
connect().then((qix) => {
qix.getDocList().then((list) => {
const items = list.map((doc) => `
<li>
<a href="/dev/app/${encodeURIComponent(doc.qDocId)}">${doc.qTitle}</a>
</li>`).join('');
ul.innerHTML = items;
});
});

View File

@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<base href="/">
<title>Nebula render</title>
<link href="https://fonts.googleapis.com/css?family=Source+Sans+Pro" rel="stylesheet">
<link href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:light" rel="stylesheet">
<link href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:semibold" rel="stylesheet">
<style>
html, body {
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
body {
background: linear-gradient(110deg, #92498F 0%, #45B3B2 100%);
font: normal 14px/16px "Source Sans Pro", Arial, sans-serif;
-webkit-font-smoothing: antialiased;
}
#chart-container {
position: absolute;
left: 8px;
bottom: 8px;
right: 8px;
top: 64px;
background: #fff;
}
</style>
</head>
<body>
<div id="chart-container"></div>
</body>
</html>

View File

@@ -0,0 +1,64 @@
import nucleus from '@nebula.js/nucleus';
import snDefinition from 'snDefinition'; // eslint-disable-line
import {
openApp,
params,
info as serverInfo,
} from './connect';
if (!params.app) {
location.href = location.origin; //eslint-disable-line
}
serverInfo.then((info) => openApp(params.app).then((app) => {
let obj;
let objType;
const nebbie = nucleus(app, {
load: (type, config) => {
objType = type.name;
return config.Promise.resolve(snDefinition);
},
});
const create = () => {
obj = nebbie.create({
type: info.supernova.name,
fields: params.cols || [],
}, {
element: document.querySelector('#chart-container'),
context: {
permissions: params.permissions || [],
},
});
};
const get = () => {
obj = nebbie.get({
id: params.object,
}, {
element: document.querySelector('#chart-container'),
});
};
const render = () => {
if (params.object) {
get();
} else {
create();
}
};
render();
if (module.hot) {
module.hot.accept('snDefinition', () => {
nebbie.types.clearFromCache(objType);
obj.then((viz) => {
viz.close();
render();
});
});
}
}));

View File

@@ -0,0 +1,38 @@
import useModel from '@nebula.js/nucleus/src/hooks/useModel';
import useLayout from '@nebula.js/nucleus/src/hooks/useLayout';
const D = {
qInfo: {
qType: 'DimensionList',
qId: 'DimensionList',
},
qDimensionListDef: {
qType: 'dimension',
qData: {
labelExpression: '/qDimension/qLabelExpression',
title: '/qMetaDef/title',
},
},
};
const M = {
qInfo: {
qType: 'MeasureList',
qId: 'MeasureList',
},
qMeasureListDef: {
qType: 'measure',
qData: {
labelExpression: '/qMeasure/qLabelExpression',
title: '/qMetaDef/title',
},
},
};
export default function list(app, type = 'dimension') {
const def = type === 'dimension' ? D : M;
const [model] = useModel(def, app);
const [layout] = useLayout(model, app);
return [layout ? ((layout.qDimensionList || layout.qMeasureList).qItems || []) : []];
}