diff --git a/.eslintignore b/.eslintignore index 53679787bdfd4..a9c0861b093d0 100644 --- a/.eslintignore +++ b/.eslintignore @@ -27,5 +27,8 @@ bower_components /x-pack/coverage /x-pack/build /x-pack/plugins/**/__tests__/fixtures/** +/x-pack/plugins/canvas/common/lib/grammar.js +/x-pack/plugins/canvas/canvas_plugin +/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/* **/*.js.snap !/.eslintrc.js diff --git a/.eslintrc.js b/.eslintrc.js index 86fbcfb13d0ab..27e18d74aecc4 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -330,5 +330,115 @@ module.exports = { files: ['x-pack/plugins/monitoring/public/**/*'], env: { browser: true }, }, + + /** + * Canvas overrides + */ + { + files: ['x-pack/plugins/canvas/**/*'], + plugins: ['prettier'], + rules: { + // preferences + 'comma-dangle': [2, 'always-multiline'], + 'no-multiple-empty-lines': [2, { max: 1, maxEOF: 1 }], + 'no-multi-spaces': 2, + radix: 2, + curly: [2, 'multi-or-nest', 'consistent'], + + // annoying rules that conflict with prettier + 'space-before-function-paren': 0, + indent: 0, + 'wrap-iife': 0, + 'max-len': 0, + + // module importing + 'import/order': [ + 2, + { groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'] }, + ], + 'import/extensions': [2, 'never', { json: 'always', less: 'always', svg: 'always' }], + + // prettier + 'prettier/prettier': 2, + + // react + 'jsx-quotes': 2, + 'react/no-did-mount-set-state': 2, + 'react/no-did-update-set-state': 2, + 'react/no-multi-comp': [2, { ignoreStateless: true }], + 'react/self-closing-comp': 2, + 'react/sort-comp': 2, + 'react/jsx-boolean-value': 2, + 'react/jsx-wrap-multilines': 2, + 'react/no-unescaped-entities': [2, { forbid: ['>', '}'] }], + 'react/forbid-elements': [ + 2, + { + forbid: [ + { + element: 'EuiConfirmModal', + message: 'Use instead', + }, + { + element: 'EuiPopover', + message: 'Use instead', + }, + { + element: 'EuiIconTip', + message: 'Use instead', + }, + ], + }, + ], + }, + }, + { + files: ['x-pack/plugins/canvas/*', 'x-pack/plugins/canvas/**/*'], + rules: { + 'import/no-extraneous-dependencies': [ + 'error', + { + packageDir: resolve(__dirname, 'x-pack/package.json'), + }, + ], + }, + }, + { + files: [ + 'x-pack/plugins/canvas/gulpfile.js', + 'x-pack/plugins/canvas/tasks/*.js', + 'x-pack/plugins/canvas/tasks/**/*.js', + 'x-pack/plugins/canvas/__tests__/**/*', + 'x-pack/plugins/canvas/**/{__tests__,__test__,__jest__,__fixtures__,__mocks__}/**/*', + ], + rules: { + 'import/no-extraneous-dependencies': [ + 'error', + { + devDependencies: true, + peerDependencies: true, + packageDir: resolve(__dirname, 'x-pack/package.json'), + }, + ], + }, + }, + { + files: ['x-pack/plugins/canvas/canvas_plugin_src/**/*'], + globals: { canvas: true, $: true }, + rules: { + 'import/no-extraneous-dependencies': [ + 'error', + { + packageDir: resolve(__dirname, 'x-pack/package.json'), + }, + ], + 'import/no-unresolved': [ + 'error', + { + ignore: ['!!raw-loader.+.svg$'], + }, + ], + }, + }, ], }; diff --git a/src/dev/license_checker/config.js b/src/dev/license_checker/config.js index c879f7b0bb4ad..f51f89d323319 100644 --- a/src/dev/license_checker/config.js +++ b/src/dev/license_checker/config.js @@ -61,6 +61,9 @@ export const LICENSE_WHITELIST = [ ]; export const LICENSE_OVERRIDES = { + 'scriptjs@2.5.8': ['MIT'], // license header appended in the dist + 'react-lib-adler32@1.0.1': ['BSD'], // adler32 extracted from react source + // TODO can be removed once we upgrade past elasticsearch-browser@14.0.0 'elasticsearch-browser@13.0.1': ['Apache-2.0'], diff --git a/src/dev/precommit_hook/casing_check_config.js b/src/dev/precommit_hook/casing_check_config.js index b3a62a3e47255..dbe953f5513a2 100644 --- a/src/dev/precommit_hook/casing_check_config.js +++ b/src/dev/precommit_hook/casing_check_config.js @@ -36,6 +36,8 @@ export const IGNORE_FILE_GLOBS = [ 'tasks/config/**/*', '**/{Dockerfile,docker-compose.yml}', 'x-pack/plugins/apm/**/*', + 'x-pack/plugins/canvas/tasks/**/*', + 'x-pack/plugins/canvas/canvas_plugin_src/**/*', '**/.*', '**/{webpackShims,__mocks__}/**/*', 'x-pack/docs/**/*', diff --git a/x-pack/gulpfile.js b/x-pack/gulpfile.js index 859f0c675621d..47848b7c16320 100644 --- a/x-pack/gulpfile.js +++ b/x-pack/gulpfile.js @@ -10,6 +10,8 @@ require('dotenv').config({ silent: true }); const path = require('path'); const gulp = require('gulp'); const mocha = require('gulp-mocha'); +const pegjs = require('gulp-pegjs'); +const multiProcess = require('gulp-multi-process'); const fancyLog = require('fancy-log'); const ansiColors = require('ansi-colors'); const pkg = require('./package.json'); @@ -20,14 +22,16 @@ const packageDir = path.resolve(buildDir, 'distributions'); const coverageDir = path.resolve(__dirname, 'coverage'); const gulpHelpers = { - log: fancyLog, - colors: ansiColors, - mocha, - pkg, buildDir, buildTarget, - packageDir, + colors: ansiColors, coverageDir, + log: fancyLog, + mocha, + multiProcess, + packageDir, + pegjs, + pkg, }; require('./tasks/build')(gulp, gulpHelpers); @@ -36,3 +40,4 @@ require('./tasks/dev')(gulp, gulpHelpers); require('./tasks/prepare')(gulp, gulpHelpers); require('./tasks/report')(gulp, gulpHelpers); require('./tasks/test')(gulp, gulpHelpers); +require('./plugins/canvas/tasks')(gulp, gulpHelpers); diff --git a/x-pack/index.js b/x-pack/index.js index a98af06dde131..3f48ca4de922a 100644 --- a/x-pack/index.js +++ b/x-pack/index.js @@ -23,6 +23,7 @@ import { indexManagement } from './plugins/index_management'; import { consoleExtensions } from './plugins/console_extensions'; import { notifications } from './plugins/notifications'; import { kueryAutocomplete } from './plugins/kuery_autocomplete'; +import { canvas } from './plugins/canvas'; module.exports = function (kibana) { return [ @@ -39,6 +40,7 @@ module.exports = function (kibana) { dashboardMode(kibana), logstash(kibana), apm(kibana), + canvas(kibana), licenseManagement(kibana), cloud(kibana), indexManagement(kibana), diff --git a/x-pack/package.json b/x-pack/package.json index 096aa89a28e9e..297934dbc0ebf 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -33,6 +33,11 @@ "aws-sdk": "2.2.33", "axios": "^0.18.0", "babel-jest": "^23.4.2", + "babel-plugin-inline-react-svg": "^0.5.4", + "babel-plugin-mock-imports": "^0.0.5", + "babel-plugin-pegjs-inline-precompile": "^0.1.0", + "babel-plugin-transform-react-remove-prop-types": "^0.4.14", + "babel-register": "^6.26.0", "chalk": "^2.4.1", "chance": "1.0.10", "checksum": "0.1.1", @@ -47,10 +52,13 @@ "fetch-mock": "^5.13.1", "gulp": "3.9.1", "gulp-mocha": "2.2.0", + "gulp-pegjs": "^0.1.0", + "gulp-multi-process": "^1.3.1", "hapi": "14.2.0", "jest": "^23.5.0", "jest-cli": "^23.5.0", "jest-styled-components": "^6.1.1", + "jsdom": "9.9.1", "mocha": "3.3.0", "mustache": "^2.3.0", "mutation-observer": "^1.0.3", @@ -63,8 +71,10 @@ "redux-test-utils": "0.2.2", "rsync": "0.4.0", "run-sequence": "^2.2.1", + "sass-loader": "^7.1.0", "simple-git": "1.37.0", "sinon": "^5.0.7", + "squel": "^5.12.2", "supertest": "^3.1.0", "supertest-as-promised": "^4.0.2", "tmp": "0.0.31", @@ -76,6 +86,7 @@ "yargs": "4.7.1" }, "dependencies": { + "@elastic/datemath": "^4.0.2", "@elastic/eui": "4.0.1", "@elastic/node-crypto": "0.1.2", "@elastic/node-phantom-simple": "2.2.4", @@ -84,47 +95,67 @@ "@kbn/i18n": "link:../packages/kbn-i18n", "@kbn/ui-framework": "link:../packages/kbn-ui-framework", "@samverschueren/stream-to-observable": "^0.3.0", + "@scant/router": "^0.1.0", "@slack/client": "^4.2.2", "@types/moment-timezone": "^0.5.8", "angular-paging": "2.2.1", "angular-resource": "1.4.9", "angular-sanitize": "1.4.9", "angular-ui-ace": "0.2.3", + "axios": "^0.18.0", "babel-core": "^6.26.0", "babel-preset-es2015": "^6.24.1", "babel-runtime": "^6.26.0", + "base64-js": "^1.2.1", "bluebird": "3.1.1", "boom": "3.1.1", "brace": "0.11.1", + "chroma-js": "^1.3.6", "classnames": "2.2.5", "concat-stream": "1.5.1", + "copy-to-clipboard": "^3.0.8", "d3": "3.5.6", "d3-scale": "1.0.6", "dedent": "^0.7.0", "dragselect": "1.7.17", "elasticsearch": "^14.1.0", "extract-zip": "1.5.0", + "file-saver": "^1.3.8", "font-awesome": "4.4.0", "get-port": "2.1.0", "getos": "^3.1.0", "glob": "6.0.4", + "handlebars": "^4.0.10", "hapi-auth-cookie": "6.1.1", "history": "4.7.2", "humps": "2.0.1", "icalendar": "0.7.1", + "inline-style": "^2.0.0", "isomorphic-fetch": "2.2.1", "joi": "6.10.1", "jquery": "^3.3.1", "jstimezonedetect": "1.0.5", "lodash": "3.10.1", + "lodash.clone": "^4.5.0", + "lodash.keyby": "^4.6.0", + "lodash.lowercase": "^4.3.0", "lodash.mean": "^4.1.0", + "lodash.omitby": "^4.6.0", "lodash.orderby": "4.6.0", + "lodash.pickby": "^4.6.0", + "lodash.topath": "^4.5.2", + "lodash.uniqby": "^4.7.0", + "lz-string": "^1.4.4", + "markdown-it": "^8.4.1", + "mime": "^2.2.2", "mkdirp": "0.5.1", "moment": "^2.20.1", "moment-duration-format": "^1.3.0", "moment-timezone": "^0.5.14", "ngreact": "^0.5.1", "nodemailer": "^4.6.4", + "object-path-immutable": "^0.5.3", + "papaparse": "^4.6.0", "pdfmake": "0.1.33", "pivotal-ui": "13.0.1", "pluralize": "3.1.0", @@ -133,9 +164,13 @@ "prop-types": "^15.6.0", "puid": "1.0.5", "puppeteer-core": "^1.7.0", + "raw-loader": "0.5.1", "react": "^16.3.0", + "react-beautiful-dnd": "^8.0.7", "react-clipboard.js": "^1.1.2", + "react-datetime": "^2.14.0", "react-dom": "^16.3.0", + "react-dropzone": "^4.2.9", "react-markdown-renderer": "^1.4.0", "react-portal": "^3.2.0", "react-redux": "^5.0.7", @@ -143,21 +178,31 @@ "react-router-breadcrumbs-hoc": "1.1.2", "react-router-dom": "^4.2.2", "react-select": "^1.2.1", + "react-shortcuts": "^2.0.0", "react-sticky": "^6.0.1", "react-syntax-highlighter": "^5.7.0", "react-vis": "^1.8.1", + "recompose": "^0.26.0", + "reduce-reducers": "^0.1.2", "redux": "4.0.0", "redux-actions": "2.2.1", "redux-thunk": "2.3.0", + "redux-thunks": "^1.0.0", "request": "^2.85.0", "reselect": "3.0.1", "rimraf": "^2.6.2", "rison-node": "0.3.1", "rxjs": "^6.2.1", + "scriptjs": "^2.5.8", "semver": "5.1.0", + "socket.io": "^1.7.3", + "socket.io-client": "^1.7.3", + "stream-stream": "^1.2.6", + "style-it": "^1.6.12", "styled-components": "3.3.3", "tar-fs": "1.13.0", "tinycolor2": "1.3.0", + "tinymath": "^0.5.0", "tslib": "^1.9.3", "ui-select": "0.19.4", "unbzip2-stream": "1.0.9", diff --git a/x-pack/plugins/canvas/.gitignore b/x-pack/plugins/canvas/.gitignore new file mode 100644 index 0000000000000..dbcb4afba52e6 --- /dev/null +++ b/x-pack/plugins/canvas/.gitignore @@ -0,0 +1,54 @@ +# OS +.DS_Store + +# Logs +logs +*.log +npm-debug.log* + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules +jspm_packages + +# Optional npm cache directory +.npm + +# Optional REPL history +.node_repl_history + +target +build +.kibana-plugin-helpers.dev.json + +# JetBrains IDEs +.idea +*.iml + +# TEMPORARY: sass build output +public/style/index.css + +# Don't commit built plugin files +canvas_plugin/* \ No newline at end of file diff --git a/x-pack/plugins/canvas/.prettierrc b/x-pack/plugins/canvas/.prettierrc new file mode 100644 index 0000000000000..f03fb6e0e808c --- /dev/null +++ b/x-pack/plugins/canvas/.prettierrc @@ -0,0 +1,6 @@ +{ + "singleQuote": true, + "semi": true, + "printWidth": 100, + "trailingComma": "es5" +} diff --git a/x-pack/plugins/canvas/README.md b/x-pack/plugins/canvas/README.md new file mode 100644 index 0000000000000..bca2a7740fac5 --- /dev/null +++ b/x-pack/plugins/canvas/README.md @@ -0,0 +1,184 @@ +# kibana-canvas + +"Never look back. The past is done. The future is a blank canvas." ― Suzy Kassem, Rise Up and Salute the Sun + +### Getting Started + +Use the following directory structure to run Canvas: + +```bash +$ ls $PATH_TO_REPOS + ├── kibana + └── kibana-extra/kibana-canvas +``` + +Setup `kibana` and `elasticsearch`. See instructions [here](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#setting-up-your-development-environment). + +Fork, then clone the [Canvas](https://github.com/elastic/kibana-canvas) repo into `kibana-extra/` and change directory into it. + +```bash +# cd kibana-extra/ +git clone https://github.com/[YOUR_USERNAME]/kibana-canvas.git +cd kibana-canvas +``` + +Install dependencies + +```bash +# in kibana-canvas/ +yarn kbn bootstrap +``` + +Start Canvas + +```bash +# in kibana-canvas/ +yarn start +``` + +### Feature Questions + +**Why are there no tooltips** + +We've opted for always available data labels instead, for now. While there exists much functionality that can be used for analytical purposes in Canvas our core concern in presentational. In a hands-off presentation format, such as a report or a slideshow, there is no facility for user to mouseover a chart to see a tooltip; data labels are a better fit for us. + +### Background + +**What is Kibana Canvas?** + +Kibana Canvas is a new visualization application on top of Elasticsearch data. Canvas is extremely versatile, but particularly differentiating example use cases include live infographics, presentations with live-updating charts, and highly customized reports. + +**Why did we build it? How does this align with the larger Kibana vision?** + +We realized early on that we are not trying to build one UI “to rule them all” in Kibana. Elasticsearch caters to a wide variety of use cases, users, and audiences and Kibana provides different experiences for these users to explore and interact with their data. Canvas is one of such applications, in particular catering to users looking for desktop-publishing level of control for the presentation of their data summaries. + +**Does Canvas replace any part of Kibana?** + +No, it is an alternative experience that does not conflict with other parts of Kibana. + +**Isn’t there overlap between Canvas and Dashboard?** + +While both can be used as a way to build up reports, Canvas and Dashboard have different goals. Canvas focuses on highly customizable layout more suited to highly curated presentations, while Dashboard provides a fast and efficient way to build up and manage business analytics and operational dashboards that don’t require a high degree of layout control and customizability. + +**Where can I see a demo of Canvas?** + +Internal demo at dev demo day (starts at 00:02:04) +https://drive.google.com/file/d/0B1QVAZnA-FxtdGNNRW9vY09fTkE/view + +Elasticon 2017 keynote (starts at 01:27:00) +https://www.elastic.co/elasticon/conf/2017/sf/opening-keynote + +**How can I get an early build?** + +No internal build available yet. + +**OK, fine, be like that. Where can I get screenshots?** + +If you want a stream of conciousness of the absolute latest development, scroll to the end of Rashid's "blog issue" +https://github.com/elastic/kibana-canvas/issues/109 + +Screenshots from the ElasticON talk are available here: +https://drive.google.com/drive/u/0/folders/0B1DdqIqU4qUNZklhU0xaM1lRYUE + +### Engineering + +**Where does Canvas code live?** + +For now all of the code lives in this repo: https://github.com/elastic/kibana-canvas + +**Where can I find Canvas milestones / roadmap?** + +Some notes [here](https://docs.google.com/document/d/1UPHeTqugEo0CbCKGK-afNK1iEbQtWQv6t7DTDumRY14/edit?pli=1#), permanent place TBD. The roadmap is, as usual, subject to change. + +**How will embeddability work? Will it be possible to embed visualizations (including Timelion and TSVB) in Canvas? Will it be possible to embed Canvas visualizations in Dashboard?** + +We plan to allow for saved Kibana visualizations to be embedded within Canvas. Going the other direction is less certain and requires review of the benefits, engineering and tradeoffs. + +**How will Canvas work with “Dashboard only” mode?** + +Canvas work pads have an editable and non-editable mode. In dashboard only mode there will be no option to enable editing of the work pad. + +**How will Canvas work with reporting?** + +We plan to allow Canvas work pads to be exportable to PDF via reporting. Canvas pages can be setup as paper-sized to allow for pixel perfect printing + +### Go-to-market + +**Will this be Open Source? Basic? Gold? Platinum?** + +The current plan is X-Pack Basic (not to share externally). Some parts and plugins may be open source but the core will part of X-Pack + +**We demoed this internally and then at Elastic{ON}, and it looked pretty finished. When will this be released in GA?** + +What you saw in the previous demos was a well-polished prototype. There are still a number of important engineering considerations to work out, which we are in the process of doing, so GA is TBD. + +**What are the next planned milestones?** + +Refer to details of planned milestones [here](https://docs.google.com/document/d/1UPHeTqugEo0CbCKGK-afNK1iEbQtWQv6t7DTDumRY14/edit?pli=1#). + +**Will there be an internal and external testing / beta testing period?** + +Yes, here is the tentative release process for Milestone 1 + +- Internal release (a few weeks?) + - Goal: Make Milestone 1 candidate build good enough that we could release it publicly if we so choose, but to get feedback internally first + - Decide if it’s good enough for public release +- Public “research” build (a couple of months?) + - Goal: Fast iterations as feedback comes in (daily, if necessary) + - Separate plugin that requires X-Pack + - We’ll enforce it on the plugin layer, so it won't install or run without x-pack, but it will be distributed separately +- Public beta or GA distributed with the stack + - Details TBD + +### Contact + +**Who should I contact internally to talk about Canvas engineering or go-to-market questions?** + +Canvas is a functional area within Kibana with Rashid Khan as lead, Joe Fleming as engineer, and Alex Francouer & Tanya Bragin as product managers. + +**Can customers that saw a demo at Canvas at Keynote provide feedback and get an update?** + +Absolutely. Kibana team is open to feedback on the concept of Canvas. Please contact pm@elastic.co to schedule a conversation about your use case and how you envision using Canvas. + +### Releases + +Releases are uploaded to AWS S3. These instructions assume you have already setup MFA and have installed the AWS CLI tools. To get your release credentials run: + +``` +aws sts get-session-token --serial-number --token-code +``` + +You can find the MFA Device ID at: + +``` +AWS Console -> IAM -> Find your username -> Security Credentials -> Assign MFA Device (string starting with arn:aws....) +``` + +That will dump something that looks like: + +``` +[default] +{ + "Credentials": { + "SecretAccessKey": "", + "SessionToken": "", + "Expiration": "2018-01-31T09:22:34Z", + "AccessKeyId": "" + } +} +``` + +You can then move this information to your `~/.aws/credentials` file: + +``` +[default] +aws_secret_access_key = +aws_session_token = +aws_access_key_id = +``` + +To publish the release run: + +``` +npm run release +``` diff --git a/x-pack/plugins/canvas/__tests__/fixtures/elasticsearch.js b/x-pack/plugins/canvas/__tests__/fixtures/elasticsearch.js new file mode 100644 index 0000000000000..dbce8695fd808 --- /dev/null +++ b/x-pack/plugins/canvas/__tests__/fixtures/elasticsearch.js @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export default function Client() { + this.indices = { + putMapping: () => Promise.resolve({ acknowledged: true }), + exists: () => Promise.resolve(false), + refresh: () => Promise.resolve(), + }; + + this.transport = {}; +} diff --git a/x-pack/plugins/canvas/__tests__/fixtures/elasticsearch_plugin.js b/x-pack/plugins/canvas/__tests__/fixtures/elasticsearch_plugin.js new file mode 100644 index 0000000000000..f69655aa000a4 --- /dev/null +++ b/x-pack/plugins/canvas/__tests__/fixtures/elasticsearch_plugin.js @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import MockElasticsearchClient from './elasticsearch'; + +export default { + getCluster: () => ({ + getClient: () => new MockElasticsearchClient(), + }), + status: { + once: () => Promise.resolve(), + }, +}; diff --git a/x-pack/plugins/canvas/__tests__/fixtures/kibana.js b/x-pack/plugins/canvas/__tests__/fixtures/kibana.js new file mode 100644 index 0000000000000..ed83dbfcb75b7 --- /dev/null +++ b/x-pack/plugins/canvas/__tests__/fixtures/kibana.js @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get, has, noop } from 'lodash'; +import mockElasticsearch from './elasticsearch_plugin'; + +const config = { + canvas: { + enabled: true, + indexPrefix: '.canvas', + }, +}; + +export class Plugin { + constructor(props) { + this.props = props; + this.routes = []; + this.server = { + plugins: { + [this.props.name]: {}, + elasticsearch: mockElasticsearch, + }, + injectUiAppVars: noop, + config: () => ({ + get: key => get(config, key), + has: key => has(config, key), + }), + route: def => this.routes.push(def), + usage: { + collectorSet: { + makeUsageCollector: () => {}, + register: () => {}, + }, + }, + }; + + const { init } = this.props; + + this.init = () => init(this.server); + } +} + +export default { + Plugin, +}; diff --git a/x-pack/plugins/canvas/__tests__/fixtures/workpads.js b/x-pack/plugins/canvas/__tests__/fixtures/workpads.js new file mode 100644 index 0000000000000..6541e962d8292 --- /dev/null +++ b/x-pack/plugins/canvas/__tests__/fixtures/workpads.js @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const workpads = [ + { + pages: [ + { + elements: [ + { + expression: ` + demodata | + ply by=age fn={rowCount | as count} | + staticColumn total value={math 'sum(count)'} | + mapColumn percentage fn={math 'count/total * 100'} | + sort age | + pointseries x=age y=percentage | + plot defaultStyle={seriesStyle points=0 lines=5}`, + }, + ], + }, + ], + }, + { + pages: [{ elements: [{ expression: 'filters | demodata | markdown "hello" | render' }] }], + }, + { + pages: [ + { + elements: [ + { expression: 'demodata | pointseries | getCell | repeatImage | render' }, + { expression: 'demodata | pointseries | getCell | repeatImage | render' }, + { expression: 'demodata | pointseries | getCell | repeatImage | render' }, + { expression: 'filters | demodata | markdown "hello" | render' }, + { expression: 'filters | demodata | pointseries | pie | render' }, + ], + }, + { elements: [{ expression: 'filters | demodata | table | render' }] }, + { elements: [{ expression: 'image | render' }] }, + { elements: [{ expression: 'image | render' }] }, + ], + }, + { + pages: [ + { + elements: [ + { expression: 'filters | demodata | markdown "hello" | render' }, + { expression: 'filters | demodata | markdown "hello" | render' }, + { expression: 'image | render' }, + ], + }, + { + elements: [ + { expression: 'demodata | pointseries | getCell | repeatImage | render' }, + { expression: 'filters | demodata | markdown "hello" | render' }, + { expression: 'filters | demodata | pointseries | pie | render' }, + { expression: 'image | render' }, + ], + }, + { + elements: [ + { expression: 'filters | demodata | pointseries | pie | render' }, + { + expression: + 'filters | demodata | pointseries | plot defaultStyle={seriesStyle points=0 lines=5} | render', + }, + ], + }, + ], + }, + { + pages: [ + { + elements: [ + { expression: 'demodata | render as=debug' }, + { expression: 'filters | demodata | pointseries | plot | render' }, + { expression: 'filters | demodata | table | render' }, + { expression: 'filters | demodata | table | render' }, + ], + }, + { + elements: [ + { expression: 'demodata | pointseries | getCell | repeatImage | render' }, + { expression: 'filters | demodata | pointseries | pie | render' }, + { expression: 'image | render' }, + ], + }, + { + elements: [ + { expression: 'demodata | pointseries | getCell | repeatImage | render' }, + { expression: 'demodata | render as=debug' }, + { expression: 'shape "square" | render' }, + ], + }, + ], + }, + { + pages: [ + { + elements: [ + { expression: 'demodata | pointseries | getCell | repeatImage | render' }, + { expression: 'filters | demodata | markdown "hello" | render' }, + ], + }, + { elements: [{ expression: 'image | render' }] }, + { elements: [{ expression: 'image | render' }] }, + { elements: [{ expression: 'filters | demodata | table | render' }] }, + ], + }, +]; diff --git a/x-pack/plugins/canvas/__tests__/helpers/function_wrapper.js b/x-pack/plugins/canvas/__tests__/helpers/function_wrapper.js new file mode 100644 index 0000000000000..4f078169f699f --- /dev/null +++ b/x-pack/plugins/canvas/__tests__/helpers/function_wrapper.js @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { mapValues } from 'lodash'; + +// It takes a function spec and passes in default args into the spec fn +export const functionWrapper = (fnSpec, mockReduxStore) => { + const spec = fnSpec(); + const defaultArgs = mapValues(spec.args, argSpec => { + return argSpec.default; + }); + + return (context, args, handlers) => + spec.fn(context, { ...defaultArgs, ...args }, handlers, mockReduxStore); +}; diff --git a/x-pack/plugins/canvas/canvas_plugin/.empty b/x-pack/plugins/canvas/canvas_plugin/.empty new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/x-pack/plugins/canvas/canvas_plugin_src/elements/area_chart/header.png b/x-pack/plugins/canvas/canvas_plugin_src/elements/area_chart/header.png new file mode 100644 index 0000000000000..93456066429d9 Binary files /dev/null and b/x-pack/plugins/canvas/canvas_plugin_src/elements/area_chart/header.png differ diff --git a/x-pack/plugins/canvas/canvas_plugin_src/elements/area_chart/index.js b/x-pack/plugins/canvas/canvas_plugin_src/elements/area_chart/index.js new file mode 100644 index 0000000000000..2f6f98df993aa --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/elements/area_chart/index.js @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const areaChart = () => { + return { + name: 'areaChart', + displayName: 'Area Chart', + help: 'A line chart with a filled body', + image: require('./header.png'), + expression: `filters + | demodata + | pointseries x="time" y="mean(price)" + | plot defaultStyle={seriesStyle lines=1 fill=1} + | render`, + }; +}; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/elements/bubble_chart/header.png b/x-pack/plugins/canvas/canvas_plugin_src/elements/bubble_chart/header.png new file mode 100644 index 0000000000000..db541fe7c53b8 Binary files /dev/null and b/x-pack/plugins/canvas/canvas_plugin_src/elements/bubble_chart/header.png differ diff --git a/x-pack/plugins/canvas/canvas_plugin_src/elements/bubble_chart/index.js b/x-pack/plugins/canvas/canvas_plugin_src/elements/bubble_chart/index.js new file mode 100644 index 0000000000000..9eff32cbb484d --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/elements/bubble_chart/index.js @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import header from './header.png'; + +export const bubbleChart = () => ({ + name: 'bubbleChart', + displayName: 'Bubble Chart', + help: 'A customizable bubble chart', + width: 700, + height: 300, + image: header, + expression: `filters +| demodata +| pointseries x="project" y="sum(price)" color="state" size="size(username)" +| plot defaultStyle={seriesStyle points=5 fill=1} +| render`, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/elements/debug/header.png b/x-pack/plugins/canvas/canvas_plugin_src/elements/debug/header.png new file mode 100644 index 0000000000000..37ab329a49bb8 Binary files /dev/null and b/x-pack/plugins/canvas/canvas_plugin_src/elements/debug/header.png differ diff --git a/x-pack/plugins/canvas/canvas_plugin_src/elements/debug/index.js b/x-pack/plugins/canvas/canvas_plugin_src/elements/debug/index.js new file mode 100644 index 0000000000000..c88ca3663b067 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/elements/debug/index.js @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import header from './header.png'; + +export const debug = () => ({ + name: 'debug', + displayName: 'Debug', + help: 'Just dumps the configuration of the element', + image: header, + expression: `demodata +| render as=debug`, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/elements/donut/header.png b/x-pack/plugins/canvas/canvas_plugin_src/elements/donut/header.png new file mode 100644 index 0000000000000..4bbfb6f8f68fc Binary files /dev/null and b/x-pack/plugins/canvas/canvas_plugin_src/elements/donut/header.png differ diff --git a/x-pack/plugins/canvas/canvas_plugin_src/elements/donut/index.js b/x-pack/plugins/canvas/canvas_plugin_src/elements/donut/index.js new file mode 100644 index 0000000000000..78f64bd0286f5 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/elements/donut/index.js @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import header from './header.png'; + +export const donut = () => ({ + name: 'donut', + displayName: 'Donut Chart', + help: 'A customizable donut chart', + image: header, + expression: `filters +| demodata +| pointseries color="project" size="max(price)" +| pie hole=50 labels=false legend="ne" +| render`, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/elements/dropdown_filter/header.png b/x-pack/plugins/canvas/canvas_plugin_src/elements/dropdown_filter/header.png new file mode 100644 index 0000000000000..727b4d23941fd Binary files /dev/null and b/x-pack/plugins/canvas/canvas_plugin_src/elements/dropdown_filter/header.png differ diff --git a/x-pack/plugins/canvas/canvas_plugin_src/elements/dropdown_filter/index.js b/x-pack/plugins/canvas/canvas_plugin_src/elements/dropdown_filter/index.js new file mode 100644 index 0000000000000..35896882c0d46 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/elements/dropdown_filter/index.js @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import header from './header.png'; + +export const dropdownFilter = () => ({ + name: 'dropdown_filter', + displayName: 'Dropdown Filter', + help: 'A dropdown from which you can select values for an "exactly" filter', + image: header, + height: 50, + expression: `demodata +| dropdownControl valueColumn=project filterColumn=project | render`, + filter: '', +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/elements/horiz_bar_chart/header.png b/x-pack/plugins/canvas/canvas_plugin_src/elements/horiz_bar_chart/header.png new file mode 100644 index 0000000000000..9b6ee47d88698 Binary files /dev/null and b/x-pack/plugins/canvas/canvas_plugin_src/elements/horiz_bar_chart/header.png differ diff --git a/x-pack/plugins/canvas/canvas_plugin_src/elements/horiz_bar_chart/index.js b/x-pack/plugins/canvas/canvas_plugin_src/elements/horiz_bar_chart/index.js new file mode 100644 index 0000000000000..3c6f6669d43ff --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/elements/horiz_bar_chart/index.js @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import header from './header.png'; + +export const horizontalBarChart = () => ({ + name: 'horizontalBarChart', + displayName: 'Horizontal Bar Chart', + help: 'A customizable horizontal bar chart', + image: header, + expression: `filters +| demodata +| pointseries x="size(cost)" y="project" color="project" +| plot defaultStyle={seriesStyle bars=0.75 horizontalBars=true} legend=false +| render`, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/elements/image/header.png b/x-pack/plugins/canvas/canvas_plugin_src/elements/image/header.png new file mode 100644 index 0000000000000..7f29fc64c36b9 Binary files /dev/null and b/x-pack/plugins/canvas/canvas_plugin_src/elements/image/header.png differ diff --git a/x-pack/plugins/canvas/canvas_plugin_src/elements/image/index.js b/x-pack/plugins/canvas/canvas_plugin_src/elements/image/index.js new file mode 100644 index 0000000000000..e4345d8cffa7b --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/elements/image/index.js @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import header from './header.png'; + +export const image = () => ({ + name: 'image', + displayName: 'Image', + help: 'A static image.', + image: header, + expression: `image mode="contain" +| render`, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/elements/index.js b/x-pack/plugins/canvas/canvas_plugin_src/elements/index.js new file mode 100644 index 0000000000000..15d98394e0475 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/elements/index.js @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { areaChart } from './area_chart'; +import { bubbleChart } from './bubble_chart'; +import { debug } from './debug'; +import { donut } from './donut'; +import { dropdownFilter } from './dropdown_filter'; +import { image } from './image'; +import { horizontalBarChart } from './horiz_bar_chart'; +import { lineChart } from './line_chart'; +import { markdown } from './markdown'; +import { metric } from './metric'; +import { pie } from './pie'; +import { plot } from './plot'; +import { repeatImage } from './repeatImage'; +import { revealImage } from './revealImage'; +import { shape } from './shape'; +import { table } from './table'; +import { tiltedPie } from './tilted_pie'; +import { timeFilter } from './time_filter'; +import { verticalBarChart } from './vert_bar_chart'; + +export const elementSpecs = [ + areaChart, + bubbleChart, + debug, + donut, + dropdownFilter, + image, + horizontalBarChart, + lineChart, + markdown, + metric, + pie, + plot, + repeatImage, + revealImage, + shape, + table, + tiltedPie, + timeFilter, + verticalBarChart, +]; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/elements/line_chart/header.png b/x-pack/plugins/canvas/canvas_plugin_src/elements/line_chart/header.png new file mode 100644 index 0000000000000..eea133ee3680b Binary files /dev/null and b/x-pack/plugins/canvas/canvas_plugin_src/elements/line_chart/header.png differ diff --git a/x-pack/plugins/canvas/canvas_plugin_src/elements/line_chart/index.js b/x-pack/plugins/canvas/canvas_plugin_src/elements/line_chart/index.js new file mode 100644 index 0000000000000..0c8e910ed1cc8 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/elements/line_chart/index.js @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import header from './header.png'; + +export const lineChart = () => ({ + name: 'lineChart', + displayName: 'Line Chart', + help: 'A customizable line chart', + image: header, + expression: `filters +| demodata +| pointseries x="time" y="mean(price)" +| plot defaultStyle={seriesStyle lines=3} +| render`, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/elements/markdown/header.png b/x-pack/plugins/canvas/canvas_plugin_src/elements/markdown/header.png new file mode 100644 index 0000000000000..a8b8550f5baea Binary files /dev/null and b/x-pack/plugins/canvas/canvas_plugin_src/elements/markdown/header.png differ diff --git a/x-pack/plugins/canvas/canvas_plugin_src/elements/markdown/index.js b/x-pack/plugins/canvas/canvas_plugin_src/elements/markdown/index.js new file mode 100644 index 0000000000000..48a161d8d20fc --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/elements/markdown/index.js @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import header from './header.png'; + +export const markdown = () => ({ + name: 'markdown', + displayName: 'Markdown', + help: 'Markup from Markdown', + image: header, + expression: `filters +| demodata +| markdown "### Welcome to the Markdown Element. + +Good news! You're already connected to some demo data! + +The datatable contains +**{{rows.length}} rows**, each containing + the following columns: +{{#each columns}} + **{{name}}** +{{/each}} + +You can use standard Markdown in here, but you can also access your piped-in data using Handlebars. If you want to know more, check out the [Handlebars Documentation](http://handlebarsjs.com/expressions.html) + +#### Enjoy!" | render`, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/elements/metric/header.png b/x-pack/plugins/canvas/canvas_plugin_src/elements/metric/header.png new file mode 100644 index 0000000000000..0510342cdc54a Binary files /dev/null and b/x-pack/plugins/canvas/canvas_plugin_src/elements/metric/header.png differ diff --git a/x-pack/plugins/canvas/canvas_plugin_src/elements/metric/index.js b/x-pack/plugins/canvas/canvas_plugin_src/elements/metric/index.js new file mode 100644 index 0000000000000..258aa2dc0dc23 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/elements/metric/index.js @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { openSans } from '../../../common/lib/fonts'; +import header from './header.png'; + +export const metric = () => ({ + name: 'metric', + displayName: 'Metric', + help: 'A number with a label', + width: 200, + height: 100, + image: header, + expression: `filters +| demodata +| math "unique(country)" +| metric "Countries" + metricFont={font size=48 family="${openSans.value}" color="#000000" align="center" lHeight=48} + labelFont={font size=14 family="${openSans.value}" color="#000000" align="center"} +| render`, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/elements/pie/header.png b/x-pack/plugins/canvas/canvas_plugin_src/elements/pie/header.png new file mode 100644 index 0000000000000..deecd1067427c Binary files /dev/null and b/x-pack/plugins/canvas/canvas_plugin_src/elements/pie/header.png differ diff --git a/x-pack/plugins/canvas/canvas_plugin_src/elements/pie/index.js b/x-pack/plugins/canvas/canvas_plugin_src/elements/pie/index.js new file mode 100644 index 0000000000000..d0359f5084550 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/elements/pie/index.js @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import header from './header.png'; + +export const pie = () => ({ + name: 'pie', + displayName: 'Pie chart', + width: 300, + height: 300, + help: 'A simple pie chart', + image: header, + expression: `filters +| demodata +| pointseries color="state" size="max(price)" +| pie +| render`, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/elements/plot/header.png b/x-pack/plugins/canvas/canvas_plugin_src/elements/plot/header.png new file mode 100644 index 0000000000000..d48c789ae5a92 Binary files /dev/null and b/x-pack/plugins/canvas/canvas_plugin_src/elements/plot/header.png differ diff --git a/x-pack/plugins/canvas/canvas_plugin_src/elements/plot/index.js b/x-pack/plugins/canvas/canvas_plugin_src/elements/plot/index.js new file mode 100644 index 0000000000000..35c6d0e748f70 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/elements/plot/index.js @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import header from './header.png'; + +export const plot = () => ({ + name: 'plot', + displayName: 'Coordinate plot', + help: 'Mixed line, bar or dot charts', + image: header, + expression: `filters +| demodata +| pointseries x="time" y="sum(price)" color="state" +| plot defaultStyle={seriesStyle points=5} +| render`, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/elements/register.js b/x-pack/plugins/canvas/canvas_plugin_src/elements/register.js new file mode 100644 index 0000000000000..535bc6ee6edfe --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/elements/register.js @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { elementSpecs } from './index'; + +elementSpecs.forEach(canvas.register); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/elements/repeatImage/header.png b/x-pack/plugins/canvas/canvas_plugin_src/elements/repeatImage/header.png new file mode 100644 index 0000000000000..9843c9a6d02c0 Binary files /dev/null and b/x-pack/plugins/canvas/canvas_plugin_src/elements/repeatImage/header.png differ diff --git a/x-pack/plugins/canvas/canvas_plugin_src/elements/repeatImage/index.js b/x-pack/plugins/canvas/canvas_plugin_src/elements/repeatImage/index.js new file mode 100644 index 0000000000000..f2316f30b0581 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/elements/repeatImage/index.js @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import header from './header.png'; + +export const repeatImage = () => ({ + name: 'repeatImage', + displayName: 'Image Repeat', + help: 'Repeats an image N times', + image: header, + expression: `filters +| demodata +| math "mean(cost)" +| repeatImage image=null +| render`, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/elements/revealImage/header.png b/x-pack/plugins/canvas/canvas_plugin_src/elements/revealImage/header.png new file mode 100644 index 0000000000000..8dc33b5a7259e Binary files /dev/null and b/x-pack/plugins/canvas/canvas_plugin_src/elements/revealImage/header.png differ diff --git a/x-pack/plugins/canvas/canvas_plugin_src/elements/revealImage/index.js b/x-pack/plugins/canvas/canvas_plugin_src/elements/revealImage/index.js new file mode 100644 index 0000000000000..f651f1e20bec5 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/elements/revealImage/index.js @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import header from './header.png'; + +export const revealImage = () => ({ + name: 'revealImage', + displayName: 'Image Reveal', + help: 'Reveals a percentage of an image', + image: header, + expression: `filters +| demodata +| math "sum(min(cost) / max(cost))" +| revealImage origin=bottom image=null +| render`, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/elements/shape/header.png b/x-pack/plugins/canvas/canvas_plugin_src/elements/shape/header.png new file mode 100644 index 0000000000000..3212d47591c07 Binary files /dev/null and b/x-pack/plugins/canvas/canvas_plugin_src/elements/shape/header.png differ diff --git a/x-pack/plugins/canvas/canvas_plugin_src/elements/shape/index.js b/x-pack/plugins/canvas/canvas_plugin_src/elements/shape/index.js new file mode 100644 index 0000000000000..e85c6742ab11b --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/elements/shape/index.js @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import header from './header.png'; + +export const shape = () => ({ + name: 'shape', + displayName: 'Shape', + help: 'A customizable shape', + width: 200, + height: 200, + image: header, + expression: + 'shape "square" fill="#4cbce4" border="rgba(255,255,255,0)" borderWidth=0 maintainAspect=true | render', +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/elements/table/header.png b/x-pack/plugins/canvas/canvas_plugin_src/elements/table/header.png new file mode 100644 index 0000000000000..a883faa693c1f Binary files /dev/null and b/x-pack/plugins/canvas/canvas_plugin_src/elements/table/header.png differ diff --git a/x-pack/plugins/canvas/canvas_plugin_src/elements/table/index.js b/x-pack/plugins/canvas/canvas_plugin_src/elements/table/index.js new file mode 100644 index 0000000000000..6794500903c75 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/elements/table/index.js @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import header from './header.png'; + +export const table = () => ({ + name: 'table', + displayName: 'Data Table', + help: 'A scrollable grid for displaying data in a tabular format', + image: header, + expression: `filters +| demodata +| table +| render`, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/elements/tilted_pie/header.png b/x-pack/plugins/canvas/canvas_plugin_src/elements/tilted_pie/header.png new file mode 100644 index 0000000000000..b3329f991158c Binary files /dev/null and b/x-pack/plugins/canvas/canvas_plugin_src/elements/tilted_pie/header.png differ diff --git a/x-pack/plugins/canvas/canvas_plugin_src/elements/tilted_pie/index.js b/x-pack/plugins/canvas/canvas_plugin_src/elements/tilted_pie/index.js new file mode 100644 index 0000000000000..2bf2bf3974cdd --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/elements/tilted_pie/index.js @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import header from './header.png'; + +export const tiltedPie = () => ({ + name: 'tiltedPie', + displayName: 'Tilted Pie Chart', + width: 500, + height: 250, + help: 'A customizable tilted pie chart', + image: header, + expression: `filters +| demodata +| pointseries color="project" size="max(price)" +| pie tilt=0.5 +| render`, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/elements/time_filter/header.png b/x-pack/plugins/canvas/canvas_plugin_src/elements/time_filter/header.png new file mode 100644 index 0000000000000..2fbfabd61a41b Binary files /dev/null and b/x-pack/plugins/canvas/canvas_plugin_src/elements/time_filter/header.png differ diff --git a/x-pack/plugins/canvas/canvas_plugin_src/elements/time_filter/index.js b/x-pack/plugins/canvas/canvas_plugin_src/elements/time_filter/index.js new file mode 100644 index 0000000000000..eba2bf90bcef8 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/elements/time_filter/index.js @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import header from './header.png'; + +export const timeFilter = () => ({ + name: 'time_filter', + displayName: 'Time Filter', + help: 'Set a time window', + image: header, + height: 50, + expression: `timefilterControl compact=true column=@timestamp +| render`, + filter: 'timefilter column=@timestamp from=now-24h to=now', +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/elements/vert_bar_chart/header.png b/x-pack/plugins/canvas/canvas_plugin_src/elements/vert_bar_chart/header.png new file mode 100644 index 0000000000000..90505dd0dc77d Binary files /dev/null and b/x-pack/plugins/canvas/canvas_plugin_src/elements/vert_bar_chart/header.png differ diff --git a/x-pack/plugins/canvas/canvas_plugin_src/elements/vert_bar_chart/index.js b/x-pack/plugins/canvas/canvas_plugin_src/elements/vert_bar_chart/index.js new file mode 100644 index 0000000000000..a62610ba56fff --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/elements/vert_bar_chart/index.js @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import header from './header.png'; + +export const verticalBarChart = () => ({ + name: 'verticalBarChart', + displayName: 'Vertical Bar Chart', + help: 'A customizable vertical bar chart', + image: header, + expression: `filters +| demodata +| pointseries x="project" y="size(cost)" color="project" +| plot defaultStyle={seriesStyle bars=0.75} legend=false +| render`, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/__tests__/markdown.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/__tests__/markdown.js new file mode 100644 index 0000000000000..d4ea050951c9a --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/__tests__/markdown.js @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { markdown } from '../markdown'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; +import { testTable } from '../../common/__tests__/fixtures/test_tables'; +import { fontStyle } from '../../common/__tests__/fixtures/test_styles'; + +describe('markdown', () => { + const fn = functionWrapper(markdown); + + it('returns a render as markdown', () => { + const result = fn(null, { expression: [''], font: fontStyle }); + expect(result) + .to.have.property('type', 'render') + .and.to.have.property('as', 'markdown'); + }); + + describe('args', () => { + describe('expression', () => { + it('sets the content to all strings in expression concatenated', () => { + const result = fn(null, { + expression: ['# this ', 'is ', 'some ', 'markdown'], + font: fontStyle, + }); + + expect(result.value).to.have.property('content', '# this is some markdown'); + }); + + it('compiles and concatenates handlebars expressions using context', () => { + let expectedContent = 'Columns:'; + testTable.columns.map(col => (expectedContent += ` ${col.name}`)); + + const result = fn(testTable, { + expression: ['Columns:', '{{#each columns}} {{name}}{{/each}}'], + }); + + expect(result.value).to.have.property('content', expectedContent); + }); + + // it('returns a markdown object with no content', () => { + // const result = fn(null, { font: fontStyle }); + + // expect(result.value).to.have.property('content', ''); + // }); + }); + + describe('font', () => { + it('sets the font style for the markdown', () => { + const result = fn(null, { + expression: ['some ', 'markdown'], + font: fontStyle, + }); + + expect(result.value).to.have.property('font', fontStyle); + }); + + // TODO: write test when using an instance of the interpreter + // it("defaults to the expression '{font}'", () => {}); + }); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/browser.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/browser.js new file mode 100644 index 0000000000000..3e6fa268ad49e --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/browser.js @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const browser = () => ({ + name: 'browser', + help: 'Force the interpreter to return to the browser', + args: {}, + fn: context => context, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/index.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/index.js new file mode 100644 index 0000000000000..3a5fa0c573316 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/index.js @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { browser } from './browser'; +import { location } from './location'; +import { urlparam } from './urlparam'; +import { markdown } from './markdown'; + +export const functions = [browser, location, urlparam, markdown]; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/location.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/location.js new file mode 100644 index 0000000000000..cd21d51015133 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/location.js @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +const noop = () => {}; + +export const location = () => ({ + name: 'location', + type: 'datatable', + context: { + types: ['null'], + }, + help: + "Use the browser's location functionality to get your current location. Usually quite slow, but fairly accurate", + fn: () => { + return new Promise(resolve => { + function createLocation(geoposition) { + const { latitude, longitude } = geoposition.coords; + return resolve({ + type: 'datatable', + columns: [{ name: 'latitude', type: 'number' }, { name: 'longitude', type: 'number' }], + rows: [{ latitude, longitude }], + }); + } + return navigator.geolocation.getCurrentPosition(createLocation, noop, { + maximumAge: 5000, + }); + }); + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/markdown.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/markdown.js new file mode 100644 index 0000000000000..8f35a1eb74951 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/markdown.js @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Handlebars } from '../../../common/lib/handlebars'; + +export const markdown = () => ({ + name: 'markdown', + aliases: [], + type: 'render', + help: + 'An element for rendering markdown text. Great for single numbers, metrics or paragraphs of text.', + context: { + types: ['datatable', 'null'], + }, + args: { + expression: { + aliases: ['_'], + types: ['string'], + help: 'A markdown expression. You can pass this multiple times to achieve concatenation', + default: '""', + multi: true, + }, + font: { + types: ['style'], + help: 'Font settings. Technically you can stick other styles in here too!', + default: '{font}', + }, + }, + fn: (context, args) => { + const compileFunctions = args.expression.map(str => Handlebars.compile(String(str))); + const ctx = { + columns: [], + rows: [], + type: null, + ...context, + }; + + return { + type: 'render', + as: 'markdown', + value: { + content: compileFunctions.map(fn => fn(ctx)).join(''), + font: args.font, + }, + }; + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/register.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/register.js new file mode 100644 index 0000000000000..f4e7fa4b467b5 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/register.js @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { functions } from './index'; + +functions.forEach(canvas.register); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/urlparam.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/urlparam.js new file mode 100644 index 0000000000000..71c231070336d --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/urlparam.js @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { parse } from 'url'; + +export const urlparam = () => ({ + name: 'urlparam', + aliases: [], + type: 'string', + help: + 'Access URL parameters and use them in expressions. Eg https://localhost:5601/app/canvas?myVar=20. This will always return a string', + context: { + types: ['null'], + }, + args: { + param: { + types: ['string'], + aliases: ['_', 'var', 'variable'], + help: 'The URL hash parameter to access', + multi: false, + }, + default: { + types: ['string'], + default: '""', + help: 'Return this string if the url parameter is not defined', + }, + }, + fn: (context, args) => { + const query = parse(window.location.href, true).query; + return query[args.param] || args.default; + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/all.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/all.js new file mode 100644 index 0000000000000..90558c26d5c87 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/all.js @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { all } from '../all'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; + +describe('all', () => { + const fn = functionWrapper(all); + + it('should return true with no conditions', () => { + expect(fn(null, {})).to.be(true); + expect(fn(null, { condition: [] })).to.be(true); + }); + + it('should return true when all conditions are true', () => { + expect(fn(null, { condition: [true] })).to.be(true); + expect(fn(null, { condition: [true, true, true] })).to.be(true); + }); + + it('should return true when all conditions are truthy', () => { + expect(fn(null, { condition: [true, 1, 'hooray', {}] })).to.be(true); + }); + + it('should return false when at least one condition is false', () => { + expect(fn(null, { condition: [false, true, true] })).to.be(false); + expect(fn(null, { condition: [false, false, true] })).to.be(false); + expect(fn(null, { condition: [false, false, false] })).to.be(false); + }); + + it('should return false when at least one condition is falsy', () => { + expect(fn(null, { condition: [true, 0, 'hooray', {}] })).to.be(false); + expect(fn(null, { condition: [true, 1, 'hooray', null] })).to.be(false); + expect(fn(null, { condition: [true, 1, '', {}] })).to.be(false); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/alterColumn.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/alterColumn.js new file mode 100644 index 0000000000000..763fe0fe57200 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/alterColumn.js @@ -0,0 +1,216 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { alterColumn } from '../alterColumn'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; +import { emptyTable, testTable } from './fixtures/test_tables'; + +describe('alterColumn', () => { + const fn = functionWrapper(alterColumn); + const nameColumnIndex = testTable.columns.findIndex(({ name }) => name === 'name'); + const timeColumnIndex = testTable.columns.findIndex(({ name }) => name === 'time'); + const priceColumnIndex = testTable.columns.findIndex(({ name }) => name === 'price'); + const inStockColumnIndex = testTable.columns.findIndex(({ name }) => name === 'in_stock'); + + it('returns a datatable', () => { + const alteredTable = fn(testTable, { column: 'price', type: 'string', name: 'priceString' }); + + expect(alteredTable.type).to.be('datatable'); + }); + + describe('args', () => { + it('returns original context if no args are provided', () => { + expect(fn(testTable)).to.eql(testTable); + }); + + describe('column', () => { + // ISO 8601 string -> date + it('specifies which column to alter', () => { + const dateToString = fn(testTable, { column: 'time', type: 'string', name: 'timeISO' }); + const originalColumn = testTable.columns[timeColumnIndex]; + const newColumn = dateToString.columns[timeColumnIndex]; + const arbitraryRowIndex = 6; + + expect(newColumn.name).to.not.be(originalColumn.name); + expect(newColumn.type).to.not.be(originalColumn.type); + expect(dateToString.rows[arbitraryRowIndex].timeISO).to.be.a('string'); + expect(new Date(dateToString.rows[arbitraryRowIndex].timeISO)).to.eql( + new Date(testTable.rows[arbitraryRowIndex].time) + ); + }); + + it('returns original context if column is not specified', () => { + expect(fn(testTable, { type: 'date', name: 'timeISO' })).to.eql(testTable); + }); + + it('throws if column does not exists', () => { + expect(() => fn(emptyTable, { column: 'foo', type: 'number' })).to.throwException(e => { + expect(e.message).to.be("Column not found: 'foo'"); + }); + }); + }); + + describe('type', () => { + it('converts the column to the specified type', () => { + const dateToString = fn(testTable, { column: 'time', type: 'string', name: 'timeISO' }); + + expect(dateToString.columns[timeColumnIndex].type).to.be('string'); + expect(dateToString.rows[timeColumnIndex].timeISO).to.be.a('string'); + expect(new Date(dateToString.rows[timeColumnIndex].timeISO)).to.eql( + new Date(testTable.rows[timeColumnIndex].time) + ); + }); + + it('does not change column if type is not specified', () => { + const unconvertedColumn = fn(testTable, { column: 'price', name: 'foo' }); + const originalType = testTable.columns[priceColumnIndex].type; + const arbitraryRowIndex = 2; + + expect(unconvertedColumn.columns[priceColumnIndex].type).to.be(originalType); + expect(unconvertedColumn.rows[arbitraryRowIndex].foo).to.be.a( + originalType, + testTable.rows[arbitraryRowIndex].price + ); + }); + + it('throws when converting to an invalid type', () => { + expect(() => fn(testTable, { column: 'name', type: 'foo' })).to.throwException(e => { + expect(e.message).to.be('Cannot convert to foo'); + }); + }); + }); + + describe('name', () => { + it('changes column name to specified name', () => { + const dateToString = fn(testTable, { column: 'time', type: 'date', name: 'timeISO' }); + const arbitraryRowIndex = 8; + + expect(dateToString.columns[timeColumnIndex].name).to.be('timeISO'); + expect(dateToString.rows[arbitraryRowIndex]).to.have.property('timeISO'); + }); + + it('overwrites existing column if provided an existing column name', () => { + const overwriteName = fn(testTable, { column: 'time', type: 'string', name: 'name' }); + const originalColumn = testTable.columns[timeColumnIndex]; + const newColumn = overwriteName.columns[nameColumnIndex]; + const arbitraryRowIndex = 5; + + expect(newColumn.name).to.not.be(originalColumn.name); + expect(newColumn.type).to.not.be(originalColumn.type); + expect(overwriteName.rows[arbitraryRowIndex].name).to.be.a('string'); + expect(new Date(overwriteName.rows[arbitraryRowIndex].name)).to.eql( + new Date(testTable.rows[arbitraryRowIndex].time) + ); + }); + + it('retains original column name if name is not provided', () => { + const unchangedName = fn(testTable, { column: 'price', type: 'string' }); + + expect(unchangedName.columns[priceColumnIndex].name).to.be( + testTable.columns[priceColumnIndex].name + ); + }); + }); + }); + + describe('valid type conversions', () => { + it('converts number <-> string', () => { + const arbitraryRowIndex = 4; + const numberToString = fn(testTable, { column: 'price', type: 'string' }); + + expect(numberToString.columns[priceColumnIndex]) + .to.have.property('name', 'price') + .and.to.have.property('type', 'string'); + expect(numberToString.rows[arbitraryRowIndex].price) + .to.be.a('string') + .and.to.eql(testTable.rows[arbitraryRowIndex].price); + + const stringToNumber = fn(numberToString, { column: 'price', type: 'number' }); + + expect(stringToNumber.columns[priceColumnIndex]) + .to.have.property('name', 'price') + .and.to.have.property('type', 'number'); + expect(stringToNumber.rows[arbitraryRowIndex].price) + .to.be.a('number') + .and.to.eql(numberToString.rows[arbitraryRowIndex].price); + }); + + it('converts date <-> string', () => { + const arbitraryRowIndex = 4; + const dateToString = fn(testTable, { column: 'time', type: 'string' }); + + expect(dateToString.columns[timeColumnIndex]) + .to.have.property('name', 'time') + .and.to.have.property('type', 'string'); + expect(dateToString.rows[arbitraryRowIndex].time).to.be.a('string'); + expect(new Date(dateToString.rows[arbitraryRowIndex].time)).to.eql( + new Date(testTable.rows[arbitraryRowIndex].time) + ); + + const stringToDate = fn(dateToString, { column: 'time', type: 'date' }); + + expect(stringToDate.columns[timeColumnIndex]) + .to.have.property('name', 'time') + .and.to.have.property('type', 'date'); + expect(new Date(stringToDate.rows[timeColumnIndex].time)) + .to.be.a(Date) + .and.to.eql(new Date(dateToString.rows[timeColumnIndex].time)); + }); + + it('converts date <-> number', () => { + const dateToNumber = fn(testTable, { column: 'time', type: 'number' }); + const arbitraryRowIndex = 1; + + expect(dateToNumber.columns[timeColumnIndex]) + .to.have.property('name', 'time') + .and.to.have.property('type', 'number'); + expect(dateToNumber.rows[arbitraryRowIndex].time) + .to.be.a('number') + .and.to.eql(testTable.rows[arbitraryRowIndex].time); + + const numberToDate = fn(dateToNumber, { column: 'time', type: 'date' }); + + expect(numberToDate.columns[timeColumnIndex]) + .to.have.property('name', 'time') + .and.to.have.property('type', 'date'); + expect(new Date(numberToDate.rows[arbitraryRowIndex].time)) + .to.be.a(Date) + .and.to.eql(testTable.rows[arbitraryRowIndex].time); + }); + + it('converts bool <-> number', () => { + const booleanToNumber = fn(testTable, { column: 'in_stock', type: 'number' }); + const arbitraryRowIndex = 7; + + expect(booleanToNumber.columns[inStockColumnIndex]) + .to.have.property('name', 'in_stock') + .and.to.have.property('type', 'number'); + expect(booleanToNumber.rows[arbitraryRowIndex].in_stock) + .to.be.a('number') + .and.to.eql(booleanToNumber.rows[arbitraryRowIndex].in_stock); + + const numberToBoolean = fn(booleanToNumber, { column: 'in_stock', type: 'boolean' }); + + expect(numberToBoolean.columns[inStockColumnIndex]) + .to.have.property('name', 'in_stock') + .and.to.have.property('type', 'boolean'); + expect(numberToBoolean.rows[arbitraryRowIndex].in_stock) + .to.be.a('boolean') + .and.to.eql(numberToBoolean.rows[arbitraryRowIndex].in_stock); + }); + + it('converts any type -> null', () => { + const stringToNull = fn(testTable, { column: 'name', type: 'null' }); + const arbitraryRowIndex = 0; + + expect(stringToNull.columns[nameColumnIndex]) + .to.have.property('name', 'name') + .and.to.have.property('type', 'null'); + expect(stringToNull.rows[arbitraryRowIndex].name).to.be(null); + }); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/any.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/any.js new file mode 100644 index 0000000000000..bc67aa377e49d --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/any.js @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { any } from '../any'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; + +describe('any', () => { + const fn = functionWrapper(any); + + it('should return false with no conditions', () => { + expect(fn(null, {})).to.be(false); + expect(fn(null, { condition: [] })).to.be(false); + }); + + it('should return false when no conditions are true', () => { + expect(fn(null, null, { condition: [false] })).to.be(false); + expect(fn(null, { condition: [false, false, false] })).to.be(false); + }); + + it('should return false when all conditions are falsy', () => { + expect(fn(null, { condition: [false, 0, '', null] })).to.be(false); + }); + + it('should return true when at least one condition is true', () => { + expect(fn(null, { condition: [false, false, true] })).to.be(true); + expect(fn(null, { condition: [false, true, true] })).to.be(true); + expect(fn(null, { condition: [true, true, true] })).to.be(true); + }); + + it('should return true when at least one condition is truthy', () => { + expect(fn(null, { condition: [false, 0, '', null, 1] })).to.be(true); + expect(fn(null, { condition: [false, 0, 'hooray', null] })).to.be(true); + expect(fn(null, { condition: [false, 0, {}, null] })).to.be(true); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/as.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/as.js new file mode 100644 index 0000000000000..82a8d29742279 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/as.js @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { asFn } from '../as'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; + +describe('as', () => { + const fn = functionWrapper(asFn); + + it('returns a datatable with a single column and single row', () => { + expect(fn('foo', { name: 'bar' })).to.eql({ + type: 'datatable', + columns: [{ name: 'bar', type: 'string' }], + rows: [{ bar: 'foo' }], + }); + + expect(fn(2, { name: 'num' })).to.eql({ + type: 'datatable', + columns: [{ name: 'num', type: 'number' }], + rows: [{ num: 2 }], + }); + + expect(fn(true, { name: 'bool' })).to.eql({ + type: 'datatable', + columns: [{ name: 'bool', type: 'boolean' }], + rows: [{ bool: true }], + }); + }); + + describe('args', () => { + describe('name', () => { + it('sets the column name of the resulting datatable', () => { + expect(fn(null, { name: 'foo' }).columns[0].name).to.eql('foo'); + }); + + it("returns a datatable with the column name 'value'", () => { + expect(fn(null).columns[0].name).to.eql('value'); + }); + }); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/axis_config.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/axis_config.js new file mode 100644 index 0000000000000..f74f568080dc9 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/axis_config.js @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { axisConfig } from '../axisConfig'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; +import { testTable } from '../__tests__/fixtures/test_tables'; + +describe('axisConfig', () => { + const fn = functionWrapper(axisConfig); + + it('returns an axisConfig', () => { + const result = fn(testTable, { show: true, position: 'right' }); + expect(result).to.have.property('type', 'axisConfig'); + }); + + describe('args', () => { + describe('show', () => { + it('hides labels', () => { + const result = fn(testTable, { show: false }); + expect(result).to.have.property('show', false); + }); + + it('shows labels', () => { + const result = fn(testTable, { show: true }); + expect(result).to.have.property('show', true); + }); + + it('defaults to true', () => { + const result = fn(testTable); + expect(result).to.have.property('show', true); + }); + }); + + describe('position', () => { + it('sets the position of the axis labels', () => { + let result = fn(testTable, { position: 'left' }); + expect(result).to.have.property('position', 'left'); + + result = fn(testTable, { position: 'top' }); + expect(result).to.have.property('position', 'top'); + + result = fn(testTable, { position: 'right' }); + expect(result).to.have.property('position', 'right'); + + result = fn(testTable, { position: 'bottom' }); + expect(result).to.have.property('position', 'bottom'); + }); + + it('defaults to an empty string if not provided', () => { + const result = fn(testTable); + expect(result).to.have.property('position', ''); + }); + + it('throws when given an invalid position', () => { + expect(fn) + .withArgs(testTable, { position: 'foo' }) + .to.throwException(e => { + expect(e.message).to.be('Invalid position foo'); + }); + }); + }); + + describe('min', () => { + it('sets the minimum value shown of the axis', () => { + let result = fn(testTable, { min: -100 }); + expect(result).to.have.property('min', -100); + result = fn(testTable, { min: 1010101010101 }); + expect(result).to.have.property('min', 1010101010101); + result = fn(testTable, { min: '2017-09-01T00:00:00Z' }); + expect(result).to.have.property('min', 1504224000000); + result = fn(testTable, { min: '2017-09-01' }); + expect(result).to.have.property('min', 1504224000000); + result = fn(testTable, { min: '1 Sep 2017' }); + expect(result).to.have.property('min', 1504224000000); + }); + + it('throws when given an invalid date string', () => { + expect(fn) + .withArgs(testTable, { min: 'foo' }) + .to.throwException(e => { + expect(e.message).to.be( + `Invalid date string 'foo' found. 'min' must be a number, date in ms, or ISO8601 date string` + ); + }); + }); + }); + + describe('max', () => { + it('sets the maximum value shown of the axis', () => { + let result = fn(testTable, { max: 2000 }); + expect(result).to.have.property('max', 2000); + result = fn(testTable, { max: 1234567000000 }); + expect(result).to.have.property('max', 1234567000000); + result = fn(testTable, { max: '2018-10-06T00:00:00Z' }); + expect(result).to.have.property('max', 1538784000000); + result = fn(testTable, { max: '10/06/2018' }); + expect(result).to.have.property('max', 1538784000000); + result = fn(testTable, { max: 'October 6 2018' }); + expect(result).to.have.property('max', 1538784000000); + }); + + it('throws when given an invalid date string', () => { + expect(fn) + .withArgs(testTable, { max: '20/02/17' }) + .to.throwException(e => { + expect(e.message).to.be( + `Invalid date string '20/02/17' found. 'max' must be a number, date in ms, or ISO8601 date string` + ); + }); + }); + }); + + describe('tickSize ', () => { + it('sets the increment size between ticks of the axis', () => { + const result = fn(testTable, { tickSize: 100 }); + expect(result).to.have.property('tickSize', 100); + }); + }); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/case.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/case.js new file mode 100644 index 0000000000000..c5fd1f14e79db --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/case.js @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { caseFn } from '../case'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; + +describe('case', () => { + const fn = functionWrapper(caseFn); + + describe('spec', () => { + it('is a function', () => { + expect(fn).to.be.a('function'); + }); + }); + + describe('function', () => { + describe('no args', () => { + it('should return a case object that matches with the result as the context', async () => { + const context = null; + const args = {}; + expect(await fn(context, args)).to.eql({ + type: 'case', + matches: true, + result: context, + }); + }); + }); + + describe('no if or value', () => { + it('should return the result if provided', async () => { + const context = null; + const args = { + then: () => 'foo', + }; + expect(await fn(context, args)).to.eql({ + type: 'case', + matches: true, + result: args.then(), + }); + }); + }); + + describe('with if', () => { + it('should return as the matches prop', async () => { + const context = null; + const args = { if: false }; + expect(await fn(context, args)).to.eql({ + type: 'case', + matches: args.if, + result: context, + }); + }); + }); + + describe('with value', () => { + it('should return whether it matches the context as the matches prop', async () => { + const args = { + when: () => 'foo', + then: () => 'bar', + }; + expect(await fn('foo', args)).to.eql({ + type: 'case', + matches: true, + result: args.then(), + }); + expect(await fn('bar', args)).to.eql({ + type: 'case', + matches: false, + result: null, + }); + }); + }); + + describe('with if and value', () => { + it('should return the if as the matches prop', async () => { + const context = null; + const args = { + when: () => 'foo', + if: true, + }; + expect(await fn(context, args)).to.eql({ + type: 'case', + matches: args.if, + result: context, + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/columns.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/columns.js new file mode 100644 index 0000000000000..ca1e647cbe9e7 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/columns.js @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { columns } from '../columns'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; +import { emptyTable, testTable } from './fixtures/test_tables'; + +describe('columns', () => { + const fn = functionWrapper(columns); + + it('returns a datatable', () => { + expect(fn(testTable, { include: 'name' }).type).to.be('datatable'); + }); + + describe('args', () => { + it('returns a datatable with included columns and without excluded columns', () => { + const arbitraryRowIndex = 7; + const result = fn(testTable, { + include: 'name, price, quantity, foo, bar', + exclude: 'price, quantity, fizz, buzz', + }); + + expect(result.columns[0]).to.have.property('name', 'name'); + expect(result.rows[arbitraryRowIndex]) + .to.have.property('name', testTable.rows[arbitraryRowIndex].name) + .and.to.not.have.property('price') + .and.to.not.have.property('quantity') + .and.to.not.have.property('foo') + .and.to.not.have.property('bar') + .and.to.not.have.property('fizz') + .and.to.not.have.property('buzz'); + }); + + it('returns original context if args are not provided', () => { + expect(fn(testTable)).to.eql(testTable); + }); + + it('returns an empty datatable if include and exclude both reference the same column(s)', () => { + expect(fn(testTable, { include: 'price', exclude: 'price' })).to.eql(emptyTable); + + expect( + fn(testTable, { + include: 'price, quantity, in_stock', + exclude: 'price, quantity, in_stock', + }) + ).to.eql(emptyTable); + }); + + describe('include', () => { + it('returns a datatable with included columns only', () => { + const arbitraryRowIndex = 3; + const result = fn(testTable, { + include: 'name, time, in_stock', + }); + + expect(result.columns).to.have.length(3); + expect(Object.keys(result.rows[0])).to.have.length(3); + + expect(result.columns[0]).to.have.property('name', 'name'); + expect(result.columns[1]).to.have.property('name', 'time'); + expect(result.columns[2]).to.have.property('name', 'in_stock'); + expect(result.rows[arbitraryRowIndex]) + .to.have.property('name', testTable.rows[arbitraryRowIndex].name) + .and.to.have.property('time', testTable.rows[arbitraryRowIndex].time) + .and.to.have.property('in_stock', testTable.rows[arbitraryRowIndex].in_stock); + }); + + it('ignores invalid columns', () => { + const arbitraryRowIndex = 6; + const result = fn(testTable, { + include: 'name, foo, bar', + }); + + expect(result.columns[0]).to.have.property('name', 'name'); + expect(result.rows[arbitraryRowIndex]) + .to.have.property('name', testTable.rows[arbitraryRowIndex].name) + .and.to.not.have.property('foo') + .and.to.not.have.property('bar'); + }); + + it('returns an empty datable if include only has invalid columns', () => { + expect(fn(testTable, { include: 'foo, bar' })).to.eql(emptyTable); + }); + }); + + describe('exclude', () => { + it('returns a datatable without excluded columns', () => { + const arbitraryRowIndex = 5; + const result = fn(testTable, { exclude: 'price, quantity, foo, bar' }); + + expect(result.columns.length).to.equal(testTable.columns.length - 2); + expect(Object.keys(result.rows[0])).to.have.length(testTable.columns.length - 2); + expect(result.rows[arbitraryRowIndex]) + .to.not.have.property('price') + .and.to.not.have.property('quantity') + .and.to.not.have.property('foo') + .and.to.not.have.property('bar'); + }); + + it('ignores invalid columns', () => { + const arbitraryRowIndex = 1; + const result = fn(testTable, { exclude: 'time, foo, bar' }); + + expect(result.columns.length).to.equal(testTable.columns.length - 1); + expect(result.rows[arbitraryRowIndex]) + .to.not.have.property('time') + .and.to.not.have.property('foo') + .and.to.not.have.property('bar'); + }); + + it('returns original context if exclude only references invalid column name(s)', () => { + expect(fn(testTable, { exclude: 'foo, bar, fizz, buzz' })).to.eql(testTable); + }); + }); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/compare.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/compare.js new file mode 100644 index 0000000000000..c7b61be4e4b25 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/compare.js @@ -0,0 +1,125 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { compare } from '../compare'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; + +describe('compare', () => { + const fn = functionWrapper(compare); + + describe('args', () => { + describe('op', () => { + it('sets the operator', () => { + expect(fn(0, { op: 'lt', to: 1 })).to.be(true); + }); + + it("defaults to 'eq'", () => { + expect(fn(0, { to: 1 })).to.be(false); + expect(fn(0, { to: 0 })).to.be(true); + }); + + it('throws when invalid op is provided', () => { + expect(() => fn(1, { op: 'boo', to: 2 })).to.throwException(e => { + expect(e.message).to.be('Invalid compare operator. Use eq, ne, lt, gt, lte, or gte.'); + }); + expect(() => fn(1, { op: 'boo' })).to.throwException(e => { + expect(e.message).to.be('Invalid compare operator. Use eq, ne, lt, gt, lte, or gte.'); + }); + }); + }); + + describe('to', () => { + it('sets the value that context is compared to', () => { + expect(fn(0, { to: 1 })).to.be(false); + }); + + it('if not provided, ne returns true while every other operator returns false', () => { + expect(fn(null, { op: 'ne' })).to.be(true); + expect(fn(0, { op: 'ne' })).to.be(true); + expect(fn(true, { op: 'lte' })).to.be(false); + expect(fn(1, { op: 'gte' })).to.be(false); + expect(fn('foo', { op: 'lt' })).to.be(false); + expect(fn(null, { op: 'gt' })).to.be(false); + expect(fn(null, { op: 'eq' })).to.be(false); + }); + }); + }); + + describe('same type comparisons', () => { + describe('null', () => { + it('returns true', () => { + expect(fn(null, { op: 'eq', to: null })).to.be(true); + expect(fn(null, { op: 'lte', to: null })).to.be(true); + expect(fn(null, { op: 'gte', to: null })).to.be(true); + }); + + it('returns false', () => { + expect(fn(null, { op: 'ne', to: null })).to.be(false); + expect(fn(null, { op: 'lt', to: null })).to.be(false); + expect(fn(null, { op: 'gt', to: null })).to.be(false); + }); + }); + + describe('number', () => { + it('returns true', () => { + expect(fn(-2.34, { op: 'lt', to: 10 })).to.be(true); + expect(fn(2, { op: 'gte', to: 2 })).to.be(true); + }); + + it('returns false', () => { + expect(fn(2, { op: 'eq', to: 10 })).to.be(false); + expect(fn(10, { op: 'ne', to: 10 })).to.be(false); + expect(fn(1, { op: 'lte', to: -3 })).to.be(false); + expect(fn(2, { op: 'gt', to: 2 })).to.be(false); + }); + }); + + describe('string', () => { + it('returns true', () => { + expect(fn('foo', { op: 'gte', to: 'foo' })).to.be(true); + expect(fn('foo', { op: 'lte', to: 'foo' })).to.be(true); + expect(fn('bar', { op: 'lt', to: 'foo' })).to.be(true); + }); + + it('returns false', () => { + expect(fn('foo', { op: 'eq', to: 'bar' })).to.be(false); + expect(fn('foo', { op: 'ne', to: 'foo' })).to.be(false); + expect(fn('foo', { op: 'gt', to: 'foo' })).to.be(false); + }); + }); + + describe('boolean', () => { + it('returns true', () => { + expect(fn(true, { op: 'eq', to: true })).to.be(true); + expect(fn(false, { op: 'eq', to: false })).to.be(true); + expect(fn(true, { op: 'ne', to: false })).to.be(true); + expect(fn(false, { op: 'ne', to: true })).to.be(true); + }); + it('returns false', () => { + expect(fn(true, { op: 'eq', to: false })).to.be(false); + expect(fn(false, { op: 'eq', to: true })).to.be(false); + expect(fn(true, { op: 'ne', to: true })).to.be(false); + expect(fn(false, { op: 'ne', to: false })).to.be(false); + }); + }); + }); + + describe('different type comparisons', () => { + it("returns true for 'ne' only", () => { + expect(fn(0, { op: 'ne', to: '0' })).to.be(true); + }); + + it('otherwise always returns false', () => { + expect(fn(0, { op: 'eq', to: '0' })).to.be(false); + expect(fn('foo', { op: 'lt', to: 10 })).to.be(false); + expect(fn('foo', { op: 'lte', to: true })).to.be(false); + expect(fn(0, { op: 'gte', to: null })).to.be(false); + expect(fn(0, { op: 'eq', to: false })).to.be(false); + expect(fn(true, { op: 'gte', to: null })).to.be(false); + }); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/container_style.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/container_style.js new file mode 100644 index 0000000000000..7daef98b0929c --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/container_style.js @@ -0,0 +1,135 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { containerStyle } from '../containerStyle'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; +import { elasticLogo } from '../../../lib/elastic_logo'; + +describe('containerStyle', () => { + const fn = functionWrapper(containerStyle); + + describe('default output', () => { + const result = fn(null); + + it('returns a containerStyle', () => { + expect(result).to.have.property('type', 'containerStyle'); + }); + + it('all style properties are omitted if args not provided', () => { + expect(result).to.only.have.key('type'); + }); + }); + + describe('args', () => { + describe('border', () => { + it('sets border', () => { + const result = fn(null, { border: '1px solid black' }); + expect(result).to.have.property('border', '1px solid black'); + }); + }); + + describe('borderRadius', () => { + it('sets border-radius', () => { + const result = fn(null, { borderRadius: '20px' }); + expect(result).to.have.property('borderRadius', '20px'); + }); + }); + + describe('padding', () => { + it('sets padding', () => { + const result = fn(null, { padding: '10px' }); + expect(result).to.have.property('padding', '10px'); + }); + }); + + describe('backgroundColor', () => { + it('sets backgroundColor', () => { + const result = fn(null, { backgroundColor: '#3f9939' }); + expect(result).to.have.property('backgroundColor', '#3f9939'); + }); + }); + + describe('backgroundImage', () => { + it('sets backgroundImage', () => { + let result = fn(null, { backgroundImage: elasticLogo }); + expect(result).to.have.property('backgroundImage', `url(${elasticLogo})`); + + const imageURL = 'https://www.elastic.co/assets/blt45b0886c90beceee/logo-elastic.svg'; + result = fn(null, { + backgroundImage: imageURL, + }); + expect(result).to.have.property('backgroundImage', `url(${imageURL})`); + }); + + it('omitted when provided a null value', () => { + let result = fn(null, { backgroundImage: '' }); + expect(result).to.not.have.property('backgroundImage'); + + result = fn(null, { backgroundImage: null }); + expect(result).to.not.have.property('backgroundImage'); + }); + + it('throws when provided an invalid dataurl/url', () => { + expect(fn) + .withArgs(null, { backgroundImage: 'foo' }) + .to.throwException(e => { + expect(e.message).to.be('Invalid backgroundImage. Please provide an asset or a URL.'); + }); + }); + }); + + describe('backgroundSize', () => { + it('sets backgroundSize when backgroundImage is provided', () => { + const result = fn(null, { backgroundImage: elasticLogo, backgroundSize: 'cover' }); + expect(result).to.have.property('backgroundSize', 'cover'); + }); + + it("defaults to 'contain' when backgroundImage is provided", () => { + const result = fn(null, { backgroundImage: elasticLogo }); + expect(result).to.have.property('backgroundSize', 'contain'); + }); + + it('omitted when backgroundImage is not provided', () => { + const result = fn(null, { backgroundSize: 'cover' }); + expect(result).to.not.have.property('backgroundSize'); + }); + }); + + describe('backgroundRepeat', () => { + it('sets backgroundRepeat when backgroundImage is provided', () => { + const result = fn(null, { backgroundImage: elasticLogo, backgroundRepeat: 'repeat' }); + expect(result).to.have.property('backgroundRepeat', 'repeat'); + }); + + it("defaults to 'no-repeat'", () => { + const result = fn(null, { backgroundImage: elasticLogo }); + expect(result).to.have.property('backgroundRepeat', 'no-repeat'); + }); + + it('omitted when backgroundImage is not provided', () => { + const result = fn(null, { backgroundRepeat: 'repeat' }); + expect(result).to.not.have.property('backgroundRepeat'); + }); + }); + + describe('opacity', () => { + it('sets opacity', () => { + const result = fn(null, { opacity: 0.5 }); + expect(result).to.have.property('opacity', 0.5); + }); + }); + + describe('overflow', () => { + it('sets overflow', () => { + let result = fn(null, { overflow: 'visible' }); + expect(result).to.have.property('overflow', 'visible'); + result = fn(null, { overflow: 'hidden' }); + expect(result).to.have.property('overflow', 'hidden'); + }); + }); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/context.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/context.js new file mode 100644 index 0000000000000..da162dc9150b1 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/context.js @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { context } from '../context'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; +import { testTable, emptyTable } from './fixtures/test_tables'; + +describe('context', () => { + const fn = functionWrapper(context); + + it('returns whatever context you pass into', () => { + expect(fn(null)).to.be(null); + expect(fn(true)).to.be(true); + expect(fn(1)).to.be(1); + expect(fn('foo')).to.be('foo'); + expect(fn({})).to.eql({}); + expect(fn(emptyTable)).to.eql(emptyTable); + expect(fn(testTable)).to.eql(testTable); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/csv.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/csv.js new file mode 100644 index 0000000000000..8f79cab5577af --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/csv.js @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { csv } from '../csv'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; + +describe('csv', () => { + const fn = functionWrapper(csv); + const expected = { + type: 'datatable', + columns: [{ name: 'name', type: 'string' }, { name: 'number', type: 'string' }], + rows: [ + { name: 'one', number: 1 }, + { name: 'two', number: 2 }, + { name: 'fourty two', number: 42 }, + ], + }; + + it('should return a datatable', () => { + expect( + fn(null, { + data: `name,number +one,1 +two,2 +fourty two,42`, + }) + ).to.eql(expected); + }); + + it('should allow custom delimiter', () => { + expect( + fn(null, { + data: `name\tnumber +one\t1 +two\t2 +fourty two\t42`, + delimiter: '\t', + }) + ).to.eql(expected); + + expect( + fn(null, { + data: `name%SPLIT%number +one%SPLIT%1 +two%SPLIT%2 +fourty two%SPLIT%42`, + delimiter: '%SPLIT%', + }) + ).to.eql(expected); + }); + + it('should allow custom newline', () => { + expect( + fn(null, { + data: `name,number\rone,1\rtwo,2\rfourty two,42`, + newline: '\r', + }) + ).to.eql(expected); + }); + + it('should trim column names', () => { + expect( + fn(null, { + data: `foo," bar ", baz, " buz " +1,2,3,4`, + }) + ).to.eql({ + type: 'datatable', + columns: [ + { name: 'foo', type: 'string' }, + { name: 'bar', type: 'string' }, + { name: 'baz', type: 'string' }, + { name: 'buz', type: 'string' }, + ], + rows: [{ foo: '1', bar: '2', baz: '3', buz: '4' }], + }); + }); + + it('should handle odd spaces correctly', () => { + expect( + fn(null, { + data: `foo," bar ", baz, " buz " +1," best ",3, " ok" +" good", bad, better , " worst " `, + }) + ).to.eql({ + type: 'datatable', + columns: [ + { name: 'foo', type: 'string' }, + { name: 'bar', type: 'string' }, + { name: 'baz', type: 'string' }, + { name: 'buz', type: 'string' }, + ], + rows: [ + { foo: '1', bar: ' best ', baz: '3', buz: ' ok' }, + { foo: ' good', bar: ' bad', baz: ' better ', buz: ' worst ' }, + ], + }); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/date.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/date.js new file mode 100644 index 0000000000000..94b6e7eb4f674 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/date.js @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import sinon from 'sinon'; +import { date } from '../date'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; + +describe('date', () => { + const fn = functionWrapper(date); + + let clock; + // stubbed date constructor to check current dates match when no args are passed in + beforeEach(() => { + clock = sinon.useFakeTimers(); + }); + + afterEach(() => { + clock.restore(); + }); + + it('returns a date in ms from a date string with the provided format', () => { + expect(fn(null, { value: '20111031', format: 'YYYYMMDD' })).to.be(1320019200000); + }); + + describe('args', () => { + describe('value', () => { + it('sets the date string to convert into ms', () => { + expect(fn(null, { value: '2011-10-05T14:48:00.000Z' })).to.be(1317826080000); + }); + + it('defaults to current date (ms)', () => { + expect(fn(null)).to.be(new Date().valueOf()); + }); + }); + + describe('format', () => { + it('sets the format to parse the date string', () => { + expect(fn(null, { value: '20111031', format: 'YYYYMMDD' })).to.be(1320019200000); + }); + + it('defaults to ISO 8601 format', () => { + expect(fn(null, { value: '2011-10-05T14:48:00.000Z' })).to.be(1317826080000); + }); + + it('throws when passing an invalid date string and format is not specified', () => { + expect(() => fn(null, { value: '23/25/2014' })).to.throwException(e => { + expect(e.message).to.be('Invalid date input: 23/25/2014'); + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/do.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/do.js new file mode 100644 index 0000000000000..000e614a35c98 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/do.js @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { doFn } from '../do'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; + +describe('do', () => { + const fn = functionWrapper(doFn); + + it('should only pass context', () => { + expect(fn(1, { fn: '1' })).to.equal(1); + expect(fn(true, {})).to.equal(true); + expect(fn(null, {})).to.equal(null); + expect(fn(null, { fn: 'not null' })).to.equal(null); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/dropdown_control.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/dropdown_control.js new file mode 100644 index 0000000000000..437e55a197c40 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/dropdown_control.js @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { dropdownControl } from '../dropdownControl'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; +import { testTable } from './fixtures/test_tables'; + +describe('dropdownControl', () => { + const fn = functionWrapper(dropdownControl); + const uniqueNames = testTable.rows.reduce( + (unique, { name }) => (unique.includes(name) ? unique : unique.concat([name])), + [] + ); + + it('returns a render as dropdown_filter', () => { + expect(fn(testTable, { filterColumn: 'name', valueColumn: 'name' })) + .to.have.property('type', 'render') + .and.to.have.property('as', 'dropdown_filter'); + }); + + describe('args', () => { + describe('valueColumn', () => { + it('populates dropdown choices with unique values in valueColumn', () => { + expect(fn(testTable, { valueColumn: 'name' }).value.choices).to.eql(uniqueNames); + }); + + it('returns an empty array when provided an invalid column', () => { + expect(fn(testTable, { valueColumn: 'foo' }).value.choices).to.be.empty(); + expect(fn(testTable, { valueColumn: '' }).value.choices).to.be.empty(); + }); + }); + }); + + describe('filterColumn', () => { + it('sets which column the filter is applied to', () => { + expect(fn(testTable, { filterColumn: 'name' }).value).to.have.property('column', 'name'); + expect(fn(testTable, { filterColumn: 'name', valueColumn: 'price' }).value).to.have.property( + 'column', + 'name' + ); + }); + + it('defaults to valueColumn if not provided', () => { + expect(fn(testTable, { valueColumn: 'price' }).value).to.have.property('column', 'price'); + }); + + it('sets column to undefined if no args are provided', () => { + expect(fn(testTable).value).to.have.property('column', undefined); + }); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/eq.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/eq.js new file mode 100644 index 0000000000000..a419dd98f3ffa --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/eq.js @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { eq } from '../eq'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; + +describe('eq', () => { + const fn = functionWrapper(eq); + + it('should return false when the types are different', () => { + expect(fn(1, { value: '1' })).to.be(false); + expect(fn(true, { value: 'true' })).to.be(false); + expect(fn(null, { value: 'null' })).to.be(false); + }); + + it('should return false when the values are different', () => { + expect(fn(1, { value: 2 })).to.be(false); + expect(fn('foo', { value: 'bar' })).to.be(false); + expect(fn(true, { value: false })).to.be(false); + }); + + it('should return true when the values are the same', () => { + expect(fn(1, { value: 1 })).to.be(true); + expect(fn('foo', { value: 'foo' })).to.be(true); + expect(fn(true, { value: true })).to.be(true); + expect(fn(null, { value: null })).to.be(true); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/exactly.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/exactly.js new file mode 100644 index 0000000000000..e9f37efd15c26 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/exactly.js @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { exactly } from '../exactly'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; +import { emptyFilter } from './fixtures/test_filters'; + +describe('exactly', () => { + const fn = functionWrapper(exactly); + + it('returns a filter', () => { + const args = { column: 'name', value: 'product2' }; + expect(fn(emptyFilter, args)).to.have.property('type', 'filter'); + }); + + it("adds an exactly object to 'and'", () => { + const result = fn(emptyFilter, { column: 'name', value: 'product2' }); + expect(result.and[0]).to.have.property('type', 'exactly'); + }); + + describe('args', () => { + describe('column', () => { + it('sets the column to apply the filter to', () => { + const result = fn(emptyFilter, { column: 'name' }); + expect(result.and[0]).to.have.property('column', 'name'); + }); + }); + + describe('value', () => { + it('sets the exact value to filter on in a column', () => { + const result = fn(emptyFilter, { value: 'product2' }); + expect(result.and[0]).to.have.property('value', 'product2'); + }); + }); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/filterrows.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/filterrows.js new file mode 100644 index 0000000000000..baa7096cb6324 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/filterrows.js @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { filterrows } from '../filterrows'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; +import { testTable } from './fixtures/test_tables'; + +const inStock = datatable => datatable.rows[0].in_stock; +const returnFalse = () => false; + +describe('filterrows', () => { + const fn = functionWrapper(filterrows); + + it('returns a datable', () => { + return fn(testTable, { fn: inStock }).then(result => { + expect(result).to.have.property('type', 'datatable'); + }); + }); + + it('keeps rows that evaluate to true and removes rows that evaluate to false', () => { + const inStockRows = testTable.rows.filter(row => row.in_stock); + + return fn(testTable, { fn: inStock }).then(result => { + expect(result.columns).to.eql(testTable.columns); + expect(result.rows).to.eql(inStockRows); + }); + }); + + it('returns datatable with no rows when no rows meet function condition', () => { + return fn(testTable, { fn: returnFalse }).then(result => { + expect(result.rows).to.be.empty(); + }); + }); + + it('throws when no function is provided', () => { + expect(() => fn(testTable)).to.throwException(e => { + expect(e.message).to.be('fn is not a function'); + }); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/fixtures/test_filters.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/fixtures/test_filters.js new file mode 100644 index 0000000000000..2aa8082dbc774 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/fixtures/test_filters.js @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const emptyFilter = { + type: 'filter', + meta: {}, + and: [], +}; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/fixtures/test_pointseries.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/fixtures/test_pointseries.js new file mode 100644 index 0000000000000..61dc84fa6bfb6 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/fixtures/test_pointseries.js @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const testPlot = { + type: 'pointseries', + columns: { + x: { type: 'date', role: 'dimension', expression: 'time' }, + y: { + type: 'number', + role: 'dimension', + expression: 'price', + }, + size: { + type: 'number', + role: 'dimension', + expression: 'quantity', + }, + color: { + type: 'string', + role: 'dimension', + expression: 'name', + }, + text: { + type: 'number', + role: 'dimension', + expression: 'price', + }, + }, + rows: [ + { + x: 1517842800950, + y: 67, + size: 240, + color: 'product3', + text: 67, + }, + { + x: 1517842800950, + y: 605, + size: 100, + color: 'product1', + text: 605, + }, + { + x: 1517842800950, + y: 216, + size: 350, + color: 'product2', + text: 216, + }, + { + x: 1517929200950, + y: 583, + size: 200, + color: 'product1', + text: 583, + }, + { + x: 1517929200950, + y: 200, + size: 256, + color: 'product2', + text: 200, + }, + { + x: 1517842800950, + y: 311, + size: 447, + color: 'product4', + text: 311, + }, + ], +}; + +export const testPie = { + type: 'pointseries', + columns: { + color: { + type: 'string', + role: 'dimension', + expression: 'name', + }, + size: { + type: 'number', + role: 'measure', + expression: 'mean(price)', + }, + }, + rows: [ + { color: 'product2', size: 202 }, + { color: 'product3', size: 67 }, + { color: 'product4', size: 311 }, + { color: 'product1', size: 536 }, + { color: 'product5', size: 288 }, + ], +}; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/fixtures/test_styles.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/fixtures/test_styles.js new file mode 100644 index 0000000000000..82d911e72772d --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/fixtures/test_styles.js @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { elasticLogo } from '../../../../lib/elastic_logo'; + +export const fontStyle = { + type: 'style', + spec: { + fontFamily: 'Chalkboard, serif', + fontWeight: 'bolder', + fontStyle: 'normal', + textDecoration: 'underline', + color: 'pink', + textAlign: 'center', + fontSize: '14px', + lineHeight: '21px', + }, + css: + 'font-family:Chalkboard, serif;font-weight:bolder;font-style:normal;text-decoration:underline;color:pink;text-align:center;font-size:14px;line-height:21px', +}; + +export const containerStyle = { + type: 'containerStyle', + border: '3px dotted blue', + borderRadius: '5px', + padding: '10px', + backgroundColor: 'red', + backgroundImage: `url(${elasticLogo})`, + opacity: 0.5, + backgroundSize: 'contain', + backgroundRepeat: 'no-repeat', +}; + +export const defaultStyle = { + type: 'seriesStyle', + label: null, + color: null, + lines: 0, + bars: 0, + points: 3, + fill: false, + stack: undefined, + horizontalBars: true, +}; + +export const seriesStyle = { + type: 'seriesStyle', + label: 'product1', + color: 'blue', + lines: 0, + bars: 0, + points: 5, + fill: true, + stack: 1, + horizontalBars: true, +}; + +export const grayscalePalette = { + type: 'palette', + colors: ['#FFFFFF', '#888888', '#000000'], + gradient: false, +}; + +export const gradientPalette = { + type: 'palette', + colors: ['#FFFFFF', '#000000'], + gradient: true, +}; + +export const xAxisConfig = { + type: 'axisConfig', + show: true, + position: 'top', +}; + +export const yAxisConfig = { + type: 'axisConfig', + show: true, + position: 'right', +}; + +export const hideAxis = { + type: 'axisConfig', + show: false, + position: 'right', +}; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/fixtures/test_tables.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/fixtures/test_tables.js new file mode 100644 index 0000000000000..7c58bb53bc367 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/fixtures/test_tables.js @@ -0,0 +1,195 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +const emptyTable = { + type: 'datatable', + columns: [], + rows: [], +}; + +const testTable = { + type: 'datatable', + columns: [ + { + name: 'name', + type: 'string', + }, + { + name: 'time', + type: 'date', + }, + { + name: 'price', + type: 'number', + }, + { + name: 'quantity', + type: 'number', + }, + { + name: 'in_stock', + type: 'boolean', + }, + ], + rows: [ + { + name: 'product1', + time: 1517842800950, //05 Feb 2018 15:00:00 GMT + price: 605, + quantity: 100, + in_stock: true, + }, + { + name: 'product1', + time: 1517929200950, //06 Feb 2018 15:00:00 GMT + price: 583, + quantity: 200, + in_stock: true, + }, + { + name: 'product1', + time: 1518015600950, //07 Feb 2018 15:00:00 GMT + price: 420, + quantity: 300, + in_stock: true, + }, + { + name: 'product2', + time: 1517842800950, //05 Feb 2018 15:00:00 GMT + price: 216, + quantity: 350, + in_stock: false, + }, + { + name: 'product2', + time: 1517929200950, //06 Feb 2018 15:00:00 GMT + price: 200, + quantity: 256, + in_stock: false, + }, + { + name: 'product2', + time: 1518015600950, //07 Feb 2018 15:00:00 GMT + price: 190, + quantity: 231, + in_stock: false, + }, + { + name: 'product3', + time: 1517842800950, //05 Feb 2018 15:00:00 GMT + price: 67, + quantity: 240, + in_stock: true, + }, + { + name: 'product4', + time: 1517842800950, //05 Feb 2018 15:00:00 GMT + price: 311, + quantity: 447, + in_stock: false, + }, + { + name: 'product5', + time: 1517842800950, //05 Feb 2018 15:00:00 GMT + price: 288, + quantity: 384, + in_stock: true, + }, + ], +}; + +const stringTable = { + type: 'datatable', + columns: [ + { + name: 'name', + type: 'string', + }, + { + name: 'time', + type: 'string', + }, + { + name: 'price', + type: 'string', + }, + { + name: 'quantity', + type: 'string', + }, + { + name: 'in_stock', + type: 'string', + }, + ], + rows: [ + { + name: 'product1', + time: '2018-02-05T15:00:00.950Z', + price: '605', + quantity: '100', + in_stock: 'true', + }, + { + name: 'product1', + time: '2018-02-06T15:00:00.950Z', + price: '583', + quantity: '200', + in_stock: 'true', + }, + { + name: 'product1', + time: '2018-02-07T15:00:00.950Z', + price: '420', + quantity: '300', + in_stock: 'true', + }, + { + name: 'product2', + time: '2018-02-05T15:00:00.950Z', + price: '216', + quantity: '350', + in_stock: 'false', + }, + { + name: 'product2', + time: '2018-02-06T15:00:00.950Z', + price: '200', + quantity: '256', + in_stock: 'false', + }, + { + name: 'product2', + time: '2018-02-07T15:00:00.950Z', + price: '190', + quantity: '231', + in_stock: 'false', + }, + { + name: 'product3', + time: '2018-02-05T15:00:00.950Z', + price: '67', + quantity: '240', + in_stock: 'true', + }, + { + name: 'product4', + time: '2018-02-05T15:00:00.950Z', + price: '311', + quantity: '447', + in_stock: 'false', + }, + { + name: 'product5', + time: '2018-02-05T15:00:00.950Z', + price: '288', + quantity: '384', + in_stock: 'true', + }, + ], +}; + +export { emptyTable, testTable, stringTable }; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/font.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/font.js new file mode 100644 index 0000000000000..7816283c370b3 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/font.js @@ -0,0 +1,183 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { openSans } from '../../../../common/lib/fonts'; +import { font } from '../font'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; + +describe('font', () => { + const fn = functionWrapper(font); + + describe('default output', () => { + const result = fn(null); + + it('returns a style', () => { + expect(result) + .to.have.property('type', 'style') + .and.to.have.property('spec') + .and.to.have.property('css'); + }); + }); + + describe('args', () => { + describe('size', () => { + it('sets font size', () => { + const result = fn(null, { size: 20 }); + expect(result.spec).to.have.property('fontSize', '20px'); + expect(result.css).to.contain('font-size:20px'); + }); + + it('defaults to 14px', () => { + const result = fn(null); + expect(result.spec).to.have.property('fontSize', '14px'); + expect(result.css).to.contain('font-size:14px'); + }); + }); + + describe('lHeight', () => { + it('sets line height', () => { + const result = fn(null, { lHeight: 30 }); + expect(result.spec).to.have.property('lineHeight', '30px'); + expect(result.css).to.contain('line-height:30px'); + }); + + it('defaults to 1', () => { + const result = fn(null); + expect(result.spec).to.have.property('lineHeight', 1); + expect(result.css).to.contain('line-height:1'); + }); + }); + + describe('family', () => { + it('sets font family', () => { + const result = fn(null, { family: 'Optima, serif' }); + expect(result.spec).to.have.property('fontFamily', 'Optima, serif'); + expect(result.css).to.contain('font-family:Optima, serif'); + }); + + it(`defaults to "${openSans.value}"`, () => { + const result = fn(null); + expect(result.spec).to.have.property('fontFamily', `"${openSans.value}"`); + expect(result.css).to.contain(`font-family:"${openSans.value}"`); + }); + }); + + describe('color', () => { + it('sets font color', () => { + const result = fn(null, { color: 'blue' }); + expect(result.spec).to.have.property('color', 'blue'); + expect(result.css).to.contain('color:blue'); + }); + }); + + describe('weight', () => { + it('sets font weight', () => { + let result = fn(null, { weight: 'normal' }); + expect(result.spec).to.have.property('fontWeight', 'normal'); + expect(result.css).to.contain('font-weight:normal'); + + result = fn(null, { weight: 'bold' }); + expect(result.spec).to.have.property('fontWeight', 'bold'); + expect(result.css).to.contain('font-weight:bold'); + + result = fn(null, { weight: 'bolder' }); + expect(result.spec).to.have.property('fontWeight', 'bolder'); + expect(result.css).to.contain('font-weight:bolder'); + + result = fn(null, { weight: 'lighter' }); + expect(result.spec).to.have.property('fontWeight', 'lighter'); + expect(result.css).to.contain('font-weight:lighter'); + + result = fn(null, { weight: '400' }); + expect(result.spec).to.have.property('fontWeight', '400'); + expect(result.css).to.contain('font-weight:400'); + }); + + it("defaults to 'normal'", () => { + const result = fn(null); + expect(result.spec).to.have.property('fontWeight', 'normal'); + expect(result.css).to.contain('font-weight:normal'); + }); + + it('throws when provided an invalid weight', () => { + expect(() => fn(null, { weight: 'foo' })).to.throwException(e => { + expect(e.message).to.be('Invalid font weight: foo'); + }); + }); + }); + + describe('underline', () => { + it('sets text underline', () => { + let result = fn(null, { underline: true }); + expect(result.spec).to.have.property('textDecoration', 'underline'); + expect(result.css).to.contain('text-decoration:underline'); + + result = fn(null, { underline: false }); + expect(result.spec).to.have.property('textDecoration', 'none'); + expect(result.css).to.contain('text-decoration:none'); + }); + + it('defaults to false', () => { + const result = fn(null); + expect(result.spec).to.have.property('textDecoration', 'none'); + expect(result.css).to.contain('text-decoration:none'); + }); + }); + + describe('italic', () => { + it('sets italic', () => { + let result = fn(null, { italic: true }); + expect(result.spec).to.have.property('fontStyle', 'italic'); + expect(result.css).to.contain('font-style:italic'); + + result = fn(null, { italic: false }); + expect(result.spec).to.have.property('fontStyle', 'normal'); + expect(result.css).to.contain('font-style:normal'); + }); + + it('defaults to false', () => { + const result = fn(null); + expect(result.spec).to.have.property('fontStyle', 'normal'); + expect(result.css).to.contain('font-style:normal'); + }); + }); + + describe('align', () => { + it('sets text alignment', () => { + let result = fn(null, { align: 'left' }); + expect(result.spec).to.have.property('textAlign', 'left'); + expect(result.css).to.contain('text-align:left'); + + result = fn(null, { align: 'center' }); + expect(result.spec).to.have.property('textAlign', 'center'); + expect(result.css).to.contain('text-align:center'); + + result = fn(null, { align: 'right' }); + expect(result.spec).to.have.property('textAlign', 'right'); + expect(result.css).to.contain('text-align:right'); + + result = fn(null, { align: 'justified' }); + expect(result.spec).to.have.property('textAlign', 'justified'); + expect(result.css).to.contain('text-align:justified'); + }); + + it(`defaults to 'left'`, () => { + const result = fn(null); + expect(result.spec).to.have.property('textAlign', 'left'); + expect(result.css).to.contain('text-align:left'); + }); + + it('throws when provided an invalid alignment', () => { + expect(fn) + .withArgs(null, { align: 'foo' }) + .to.throwException(e => { + expect(e.message).to.be('Invalid text alignment: foo'); + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/formatdate.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/formatdate.js new file mode 100644 index 0000000000000..d9f9169079044 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/formatdate.js @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { formatdate } from '../formatdate'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; + +describe('formatdate', () => { + const fn = functionWrapper(formatdate); + + it('returns formatted date string from ms or ISO8601 string using the given format', () => { + const testDate = new Date('2011-10-31T12:30:45Z').valueOf(); + expect(fn(testDate, { format: 'MM/DD/YYYY' })).to.be('10/31/2011'); + }); + + describe('args', () => { + describe('format', () => { + it('sets the format of the returned date string', () => { + const testDate = new Date('2013-03-12T08:03:27Z').valueOf(); + expect(fn(testDate, { format: 'MMMM Do YYYY, h:mm:ss a' })).to.be( + 'March 12th 2013, 8:03:27 am' + ); + expect(fn(testDate, { format: 'MMM Do YY' })).to.be('Mar 12th 13'); + }); + + it('defaults to ISO 8601 format', () => { + const testDate = new Date('2018-01-08T20:15:59Z').valueOf(); + expect(fn(testDate)).to.be('2018-01-08T20:15:59.000Z'); + }); + }); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/formatnumber.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/formatnumber.js new file mode 100644 index 0000000000000..b0d8a7df62f1f --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/formatnumber.js @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { formatnumber } from '../formatnumber'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; + +describe('formatnumber', () => { + const fn = functionWrapper(formatnumber); + + it('returns number as formatted string with given format', () => { + expect(fn(140000, { format: '$0,0.00' })).to.be('$140,000.00'); + }); + + describe('args', () => { + describe('format', () => { + it('sets the format of the resulting number string', () => { + expect(fn(0.68, { format: '0.000%' })).to.be('68.000%'); + }); + + it('casts number to a string if format is not specified', () => { + expect(fn(140000.999999)).to.be('140000.999999'); + }); + }); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/getCell.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/getCell.js new file mode 100644 index 0000000000000..9509c05f9229d --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/getCell.js @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { getCell } from '../getCell'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; +import { emptyTable, testTable } from './fixtures/test_tables'; + +describe('getCell', () => { + const fn = functionWrapper(getCell); + + it('returns the value from the specified row and column', () => { + const arbitraryRowIndex = 3; + + expect(fn(testTable, { column: 'quantity', row: arbitraryRowIndex })).to.eql( + testTable.rows[arbitraryRowIndex].quantity + ); + }); + + describe('args', () => { + const firstColumn = testTable.columns[0].name; + + it('defaults to first column in first row if no args are provided', () => { + expect(fn(testTable)).to.be(testTable.rows[0][firstColumn]); + }); + + describe('column', () => { + const arbitraryRowIndex = 1; + + it('sets which column to get the value from', () => { + expect(fn(testTable, { column: 'price', row: arbitraryRowIndex })).to.be( + testTable.rows[arbitraryRowIndex].price + ); + }); + + it('defaults to first column if not provided', () => { + expect(fn(testTable, { row: arbitraryRowIndex })).to.be( + testTable.rows[arbitraryRowIndex][firstColumn] + ); + }); + + it('throws when invalid column is provided', () => { + expect(() => fn(testTable, { column: 'foo' })).to.throwException(e => { + expect(e.message).to.be('Column not found: foo'); + }); + }); + }); + + describe('row', () => { + it('sets which row to get the value from', () => { + const arbitraryRowIndex = 8; + + expect(fn(testTable, { column: 'in_stock', row: arbitraryRowIndex })).to.eql( + testTable.rows[arbitraryRowIndex].in_stock + ); + }); + + it('defaults to first row if not specified', () => { + expect(fn(testTable, { column: 'name' })).to.eql(testTable.rows[0].name); + }); + + it('throws when row does not exist', () => { + const invalidRow = testTable.rows.length; + + expect(() => fn(testTable, { column: 'name', row: invalidRow })).to.throwException(e => { + expect(e.message).to.be(`Row not found: ${invalidRow}`); + }); + + expect(() => fn(emptyTable, { column: 'foo' })).to.throwException(e => { + expect(e.message).to.be('Row not found: 0'); + }); + + expect(() => fn(emptyTable)).to.throwException(e => { + expect(e.message).to.be('Row not found: 0'); + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/get_flot_axis_config.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/get_flot_axis_config.js new file mode 100644 index 0000000000000..2a1fca38f2685 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/get_flot_axis_config.js @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { getFlotAxisConfig } from '../plot/get_flot_axis_config'; +import { xAxisConfig, yAxisConfig, hideAxis } from './fixtures/test_styles'; + +describe('getFlotAxisConfig', () => { + const columns = { + x: { type: 'string', role: 'dimension', expression: 'project' }, + y: { type: 'date', role: 'dimension', expression: 'location' }, + }; + + const ticks = { + x: { hash: { product1: 2, product2: 1 }, counter: 3 }, + y: { hash: {}, counter: 0 }, + }; + + describe('show', () => { + it('hides the axis', () => { + expect(getFlotAxisConfig('x', false, { columns, ticks })) + .to.only.have.key('show') + .and.to.have.property('show', false); + expect(getFlotAxisConfig('y', false, { columns, ticks })) + .to.only.have.key('show') + .and.to.have.property('show', false); + }); + + it('shows the axis', () => { + expect(getFlotAxisConfig('x', true, { columns, ticks })).to.have.property('show', true); + expect(getFlotAxisConfig('y', true, { columns, ticks })).to.have.property('show', true); + }); + + it('sets show using an AxisConfig', () => { + let result = getFlotAxisConfig('x', xAxisConfig, { columns, ticks }); + expect(result).to.have.property('show', xAxisConfig.show); + + result = getFlotAxisConfig('y', yAxisConfig, { columns, ticks }); + expect(result).to.have.property('show', yAxisConfig.show); + + result = getFlotAxisConfig('x', hideAxis, { columns, ticks }); + expect(result).to.have.property('show', hideAxis.show); + + result = getFlotAxisConfig('y', hideAxis, { columns, ticks }); + expect(result).to.have.property('show', hideAxis.show); + }); + }); + + describe('position', () => { + it('sets the position of the axis when given an AxisConfig', () => { + let result = getFlotAxisConfig('x', xAxisConfig, { columns, ticks }); + expect(result).to.have.property('position', xAxisConfig.position); + + result = getFlotAxisConfig('y', yAxisConfig, { columns, ticks }); + expect(result).to.have.property('position', yAxisConfig.position); + }); + + it("defaults position to 'bottom' for the x-axis", () => { + const invalidXPosition = { + type: 'axisConfig', + show: true, + position: 'left', + }; + + const result = getFlotAxisConfig('x', invalidXPosition, { columns, ticks }); + expect(result).to.have.property('position', 'bottom'); + }); + + it("defaults position to 'left' for the y-axis", () => { + const invalidYPosition = { + type: 'axisConfig', + show: true, + position: 'bottom', + }; + + const result = getFlotAxisConfig('y', invalidYPosition, { columns, ticks }); + expect(result).to.have.property('position', 'left'); + }); + }); + + describe('ticks', () => { + it('adds a tick mark mapping for string columns', () => { + let result = getFlotAxisConfig('x', true, { columns, ticks }); + expect(result.ticks).to.eql([[2, 'product1'], [1, 'product2']]); + + result = getFlotAxisConfig('x', xAxisConfig, { columns, ticks }); + expect(result.ticks).to.eql([[2, 'product1'], [1, 'product2']]); + }); + }); + + describe('mode', () => { + it('sets the mode to time for date columns', () => { + let result = getFlotAxisConfig('y', true, { columns, ticks }); + expect(result).to.have.property('mode', 'time'); + + result = getFlotAxisConfig('y', yAxisConfig, { columns, ticks }); + expect(result).to.have.property('mode', 'time'); + }); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/get_font_spec.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/get_font_spec.js new file mode 100644 index 0000000000000..4f2fb67cd938f --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/get_font_spec.js @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { defaultSpec, getFontSpec } from '../plot/get_font_spec'; +import { fontStyle } from './fixtures/test_styles'; + +describe('getFontSpec', () => { + describe('default output', () => { + it('returns the default spec object', () => { + expect(getFontSpec()).to.eql(defaultSpec); + }); + }); + + describe('convert from fontStyle object', () => { + it('returns plot font spec', () => { + expect(getFontSpec(fontStyle)).to.eql({ + size: 14, + lHeight: 21, + style: 'normal', + weight: 'bolder', + family: 'Chalkboard, serif', + color: 'pink', + }); + }); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/get_tick_hash.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/get_tick_hash.js new file mode 100644 index 0000000000000..02412862d9512 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/get_tick_hash.js @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { getTickHash } from '../plot/get_tick_hash'; + +describe('getTickHash', () => { + it('creates a hash for tick marks for string columns only', () => { + const columns = { + x: { type: 'string', role: 'dimension', expression: 'project' }, + y: { type: 'string', role: 'dimension', expression: 'location' }, + }; + const rows = [ + { x: 'product1', y: 'AZ' }, + { x: 'product2', y: 'AZ' }, + { x: 'product1', y: 'CA' }, + { x: 'product2', y: 'CA' }, + ]; + + expect(getTickHash(columns, rows)).to.eql({ + x: { hash: { product1: 2, product2: 1 }, counter: 3 }, + y: { hash: { CA: 1, AZ: 2 }, counter: 3 }, + }); + }); + + it('ignores columns of any other type', () => { + const columns = { + x: { type: 'number', role: 'dimension', expression: 'id' }, + y: { type: 'boolean', role: 'dimension', expression: 'running' }, + }; + const rows = [{ x: 1, y: true }, { x: 2, y: true }, { x: 1, y: false }, { x: 2, y: false }]; + + expect(getTickHash(columns, rows)).to.eql({ + x: { hash: {}, counter: 0 }, + y: { hash: {}, counter: 0 }, + }); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/gt.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/gt.js new file mode 100644 index 0000000000000..ca490aa88fec7 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/gt.js @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { gt } from '../gt'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; + +describe('gt', () => { + const fn = functionWrapper(gt); + + it('should return false when the types are different', () => { + expect(fn(1, { value: '1' })).to.be(false); + expect(fn(true, { value: 'true' })).to.be(false); + expect(fn(null, { value: 'null' })).to.be(false); + }); + + it('should return true when greater than', () => { + expect(fn(2, { value: 1 })).to.be(true); + expect(fn('foo', { value: 'bar' })).to.be(true); + expect(fn(true, { value: false })).to.be(true); + }); + + it('should return false when less than or equal to', () => { + expect(fn(1, { value: 2 })).to.be(false); + expect(fn(2, { value: 2 })).to.be(false); + expect(fn('bar', { value: 'foo' })).to.be(false); + expect(fn('foo', { value: 'foo' })).to.be(false); + expect(fn(false, { value: true })).to.be(false); + expect(fn(true, { value: true })).to.be(false); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/gte.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/gte.js new file mode 100644 index 0000000000000..ba4f0f7a3f9b1 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/gte.js @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { gte } from '../gte'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; + +describe('gte', () => { + const fn = functionWrapper(gte); + + it('should return false when the types are different', () => { + expect(fn(1, { value: '1' })).to.be(false); + expect(fn(true, { value: 'true' })).to.be(false); + expect(fn(null, { value: 'null' })).to.be(false); + }); + + it('should return true when greater than or equal to', () => { + expect(fn(2, { value: 1 })).to.be(true); + expect(fn(2, { value: 2 })).to.be(true); + expect(fn('foo', { value: 'bar' })).to.be(true); + expect(fn('foo', { value: 'foo' })).to.be(true); + expect(fn(true, { value: false })).to.be(true); + expect(fn(true, { value: true })).to.be(true); + }); + + it('should return false when less than', () => { + expect(fn(1, { value: 2 })).to.be(false); + expect(fn('bar', { value: 'foo' })).to.be(false); + expect(fn(false, { value: true })).to.be(false); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/head.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/head.js new file mode 100644 index 0000000000000..d9cd5a0743764 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/head.js @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { head } from '../head'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; +import { emptyTable, testTable } from './fixtures/test_tables'; + +describe('head', () => { + const fn = functionWrapper(head); + + it('returns a datatable with the first N rows of the context', () => { + const result = fn(testTable, { count: 2 }); + + expect(result.type).to.be('datatable'); + expect(result.columns).to.eql(testTable.columns); + expect(result.rows).to.have.length(2); + expect(result.rows[0]).to.eql(testTable.rows[0]); + expect(result.rows[1]).to.eql(testTable.rows[1]); + }); + + it('returns the original context if N >= context.rows.length', () => { + expect(fn(testTable, { count: testTable.rows.length + 5 })).to.eql(testTable); + expect(fn(testTable, { count: testTable.rows.length })).to.eql(testTable); + expect(fn(emptyTable)).to.eql(emptyTable); + }); + + it('returns the first row if N is not specified', () => { + const result = fn(testTable); + + expect(result.rows).to.have.length(1); + expect(result.rows[0]).to.eql(testTable.rows[0]); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/if.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/if.js new file mode 100644 index 0000000000000..aee9efc5203c2 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/if.js @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { ifFn } from '../if'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; + +describe('if', () => { + const fn = functionWrapper(ifFn); + + describe('spec', () => { + it('is a function', () => { + expect(fn).to.be.a('function'); + }); + }); + + describe('function', () => { + describe('condition passed', () => { + it('with then', async () => { + expect(await fn(null, { condition: true, then: () => 'foo' })).to.be('foo'); + expect(await fn(null, { condition: true, then: () => 'foo', else: () => 'bar' })).to.be( + 'foo' + ); + }); + + it('without then', async () => { + expect(await fn(null, { condition: true })).to.be(null); + expect(await fn('some context', { condition: true })).to.be('some context'); + }); + }); + + describe('condition failed', () => { + it('with else', async () => + expect( + await fn('some context', { condition: false, then: () => 'foo', else: () => 'bar' }) + ).to.be('bar')); + + it('without else', async () => + expect(await fn('some context', { condition: false, then: () => 'foo' })).to.be( + 'some context' + )); + }); + + describe('falsy values', () => { + describe('for then', () => { + it('with null', async () => + expect(await fn('some context', { condition: true, then: () => null })).to.be(null)); + + it('with false', async () => + expect(await fn('some context', { condition: true, then: () => false })).to.be(false)); + + it('with 0', async () => + expect(await fn('some context', { condition: true, then: () => 0 })).to.be(0)); + }); + + describe('for else', () => { + it('with null', async () => + expect(await fn('some context', { condition: false, else: () => null })).to.be(null)); + + it('with false', async () => + expect(await fn('some context', { condition: false, else: () => false })).to.be(false)); + + it('with 0', async () => + expect(await fn('some context', { condition: false, else: () => 0 })).to.be(0)); + }); + }); + }); + + // TODO: Passing through context +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/image.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/image.js new file mode 100644 index 0000000000000..304d970882bf4 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/image.js @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { image } from '../image'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; +import { elasticLogo } from '../../../lib/elastic_logo'; +import { elasticOutline } from '../../../lib/elastic_outline'; + +describe('image', () => { + const fn = functionWrapper(image); + + it('returns an image object using a dataUrl', () => { + const result = fn(null, { dataurl: elasticOutline, mode: 'cover' }); + expect(result).to.have.property('type', 'image'); + }); + + describe('args', () => { + describe('dataurl', () => { + it('sets the source of the image using dataurl', () => { + const result = fn(null, { dataurl: elasticOutline }); + expect(result).to.have.property('dataurl', elasticOutline); + }); + + it.skip('sets the source of the image using url', () => { + // This is skipped because functionWrapper doesn't use the actual + // interpreter and doesn't resolve aliases + const result = fn(null, { url: elasticOutline }); + expect(result).to.have.property('dataurl', elasticOutline); + }); + + it('defaults to the elasticLogo if not provided', () => { + const result = fn(null); + expect(result).to.have.property('dataurl', elasticLogo); + }); + }); + + describe('mode', () => { + it('sets the mode', () => { + it('to contain', () => { + const result = fn(null, { mode: 'contain' }); + expect(result).to.have.property('mode', 'contain'); + }); + + it('to cover', () => { + const result = fn(null, { mode: 'cover' }); + expect(result).to.have.property('mode', 'cover'); + }); + + it('to stretch', () => { + const result = fn(null, { mode: 'stretch' }); + expect(result).to.have.property('mode', 'stretch'); + }); + + it("defaults to 'contain' if not provided", () => { + const result = fn(null); + expect(result).to.have.property('mode', 'contain'); + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/lt.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/lt.js new file mode 100644 index 0000000000000..3597bd3805d6e --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/lt.js @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { lt } from '../lt'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; + +describe('lt', () => { + const fn = functionWrapper(lt); + + it('should return false when the types are different', () => { + expect(fn(1, { value: '1' })).to.be(false); + expect(fn(true, { value: 'true' })).to.be(false); + expect(fn(null, { value: 'null' })).to.be(false); + }); + + it('should return false when greater than or equal to', () => { + expect(fn(2, { value: 1 })).to.be(false); + expect(fn(2, { value: 2 })).to.be(false); + expect(fn('foo', { value: 'bar' })).to.be(false); + expect(fn('foo', { value: 'foo' })).to.be(false); + expect(fn(true, { value: false })).to.be(false); + expect(fn(true, { value: true })).to.be(false); + }); + + it('should return true when less than', () => { + expect(fn(1, { value: 2 })).to.be(true); + expect(fn('bar', { value: 'foo' })).to.be(true); + expect(fn(false, { value: true })).to.be(true); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/lte.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/lte.js new file mode 100644 index 0000000000000..e800b72c13ff5 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/lte.js @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { lte } from '../lte'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; + +describe('lte', () => { + const fn = functionWrapper(lte); + + it('should return false when the types are different', () => { + expect(fn(1, { value: '1' })).to.be(false); + expect(fn(true, { value: 'true' })).to.be(false); + expect(fn(null, { value: 'null' })).to.be(false); + }); + + it('should return false when greater than', () => { + expect(fn(2, { value: 1 })).to.be(false); + expect(fn('foo', { value: 'bar' })).to.be(false); + expect(fn(true, { value: false })).to.be(false); + }); + + it('should return true when less than or equal to', () => { + expect(fn(1, { value: 2 })).to.be(true); + expect(fn(2, { value: 2 })).to.be(true); + expect(fn('bar', { value: 'foo' })).to.be(true); + expect(fn('foo', { value: 'foo' })).to.be(true); + expect(fn(false, { value: true })).to.be(true); + expect(fn(true, { value: true })).to.be(true); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/mapColumn.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/mapColumn.js new file mode 100644 index 0000000000000..60121f9717abd --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/mapColumn.js @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { mapColumn } from '../mapColumn'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; +import { testTable } from './fixtures/test_tables'; + +const pricePlusTwo = datatable => Promise.resolve(datatable.rows[0].price + 2); + +describe('mapColumn', () => { + const fn = functionWrapper(mapColumn); + + it('returns a datatable with a new column with the values from mapping a function over each row in a datatable', () => { + return fn(testTable, { name: 'pricePlusTwo', expression: pricePlusTwo }).then(result => { + const arbitraryRowIndex = 2; + + expect(result.type).to.be('datatable'); + expect(result.columns).to.eql([ + ...testTable.columns, + { name: 'pricePlusTwo', type: 'number' }, + ]); + expect(result.columns[result.columns.length - 1]).to.have.property('name', 'pricePlusTwo'); + expect(result.rows[arbitraryRowIndex]).to.have.property('pricePlusTwo'); + }); + }); + + it('overwrites existing column with the new column if an existing column name is provided', () => { + return fn(testTable, { name: 'name', expression: pricePlusTwo }).then(result => { + const nameColumnIndex = result.columns.findIndex(({ name }) => name === 'name'); + const arbitraryRowIndex = 4; + + expect(result.type).to.be('datatable'); + expect(result.columns).to.have.length(testTable.columns.length); + expect(result.columns[nameColumnIndex]) + .to.have.property('name', 'name') + .and.to.have.property('type', 'number'); + expect(result.rows[arbitraryRowIndex]).to.have.property('name', 202); + }); + }); + + describe('expression', () => { + it('maps null values to the new column', () => { + return fn(testTable, { name: 'empty' }).then(result => { + const emptyColumnIndex = result.columns.findIndex(({ name }) => name === 'empty'); + const arbitraryRowIndex = 8; + + expect(result.columns[emptyColumnIndex]) + .to.have.property('name', 'empty') + .and.to.have.property('type', 'null'); + expect(result.rows[arbitraryRowIndex]).to.have.property('empty', null); + }); + }); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/math.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/math.js new file mode 100644 index 0000000000000..f290cce865153 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/math.js @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { math } from '../math'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; +import { emptyTable, testTable } from './fixtures/test_tables'; + +describe('math', () => { + const fn = functionWrapper(math); + + it('evaluates math expressions without reference to context', () => { + expect(fn(null, { expression: '10.5345' })).to.be(10.5345); + expect(fn(null, { expression: '123 + 456' })).to.be(579); + expect(fn(null, { expression: '100 - 46' })).to.be(54); + expect(fn(1, { expression: '100 / 5' })).to.be(20); + expect(fn('foo', { expression: '100 / 5' })).to.be(20); + expect(fn(true, { expression: '100 / 5' })).to.be(20); + expect(fn(testTable, { expression: '100 * 5' })).to.be(500); + expect(fn(emptyTable, { expression: '100 * 5' })).to.be(500); + }); + + it('evaluates math expressions with reference to the value of the context, must be a number', () => { + expect(fn(-103, { expression: 'abs(value)' })).to.be(103); + }); + + it('evaluates math expressions with references to columns in a datatable', () => { + expect(fn(testTable, { expression: 'unique(in_stock)' })).to.be(2); + expect(fn(testTable, { expression: 'sum(quantity)' })).to.be(2508); + expect(fn(testTable, { expression: 'mean(price)' })).to.be(320); + expect(fn(testTable, { expression: 'min(price)' })).to.be(67); + expect(fn(testTable, { expression: 'median(quantity)' })).to.be(256); + expect(fn(testTable, { expression: 'max(price)' })).to.be(605); + }); + + describe('args', () => { + describe('expression', () => { + it('sets the math expression to be evaluted', () => { + expect(fn(null, { expression: '10' })).to.be(10); + expect(fn(23.23, { expression: 'floor(value)' })).to.be(23); + expect(fn(testTable, { expression: 'count(price)' })).to.be(9); + expect(fn(testTable, { expression: 'count(name)' })).to.be(9); + }); + }); + }); + + describe('invalid expressions', () => { + it('throws when expression evaluates to an array', () => { + expect(fn) + .withArgs(testTable, { expression: 'multiply(price, 2)' }) + .to.throwException(e => { + expect(e.message).to.be( + 'Expressions must return a single number. Try wrapping your expression in mean() or sum()' + ); + }); + }); + + it('throws when using an unknown context variable', () => { + expect(fn) + .withArgs(testTable, { expression: 'sum(foo)' }) + .to.throwException(e => { + expect(e.message).to.be('Unknown variable: foo'); + }); + }); + + it('throws when using non-numeric data', () => { + expect(fn) + .withArgs(testTable, { expression: 'mean(name)' }) + .to.throwException(e => { + expect(e.message).to.be('Failed to execute math expression. Check your column names'); + }); + expect(fn) + .withArgs(testTable, { expression: 'mean(in_stock)' }) + .to.throwException(e => { + expect(e.message).to.be('Failed to execute math expression. Check your column names'); + }); + }); + + it('throws when missing expression', () => { + expect(fn) + .withArgs(testTable) + .to.throwException(e => { + expect(e.message).to.be('Empty expression'); + }); + expect(fn) + .withArgs(testTable, { expression: '' }) + .to.throwException(e => { + expect(e.message).to.be('Empty expression'); + }); + expect(fn) + .withArgs(testTable, { expression: ' ' }) + .to.throwException(e => { + expect(e.message).to.be('Empty expression'); + }); + }); + + it('throws when passing a context variable from an empty datatable', () => { + expect(fn) + .withArgs(emptyTable, { expression: 'mean(foo)' }) + .to.throwException(e => { + expect(e.message).to.be('Empty datatable'); + }); + }); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/metric.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/metric.js new file mode 100644 index 0000000000000..587e7fbf1f8b0 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/metric.js @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { metric } from '../metric'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; +import { fontStyle } from './fixtures/test_styles'; + +describe('metric', () => { + const fn = functionWrapper(metric); + + it('returns a render as metric', () => { + const result = fn(null); + expect(result) + .to.have.property('type', 'render') + .and.to.have.property('as', 'metric'); + }); + + it('sets the metric to context', () => { + const result = fn('2'); + expect(result.value).to.have.property('metric', '2'); + }); + + it(`defaults metric to '?' when context is missing`, () => { + const result = fn(null); + expect(result.value).to.have.property('metric', '?'); + }); + + describe('args', () => { + describe('label', () => { + it('sets the label of the metric', () => { + const result = fn(null, { + label: 'My Label', + }); + + expect(result.value).to.have.property('label', 'My Label'); + }); + }); + + describe('metricStyle', () => { + it('sets the font style for the metric', () => { + const result = fn(null, { + metricFont: fontStyle, + }); + + expect(result.value).to.have.property('metricFont', fontStyle); + }); + + // TODO: write test when using an instance of the interpreter + // it("sets a default style for the metric when not provided, () => {}); + }); + + describe('labelStyle', () => { + it('sets the font style for the label', () => { + const result = fn(null, { + labelFont: fontStyle, + }); + + expect(result.value).to.have.property('labelFont', fontStyle); + }); + + // TODO: write test when using an instance of the interpreter + // it("sets a default style for the label when not provided, () => {}); + }); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/neq.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/neq.js new file mode 100644 index 0000000000000..3d2ca7b90017c --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/neq.js @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { neq } from '../neq'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; + +describe('neq', () => { + const fn = functionWrapper(neq); + + it('should return true when the types are different', () => { + expect(fn(1, { value: '1' })).to.be(true); + expect(fn(true, { value: 'true' })).to.be(true); + expect(fn(null, { value: 'null' })).to.be(true); + }); + + it('should return true when the values are different', () => { + expect(fn(1, { value: 2 })).to.be(true); + expect(fn('foo', { value: 'bar' })).to.be(true); + expect(fn(true, { value: false })).to.be(true); + }); + + it('should return false when the values are the same', () => { + expect(fn(1, { value: 1 })).to.be(false); + expect(fn('foo', { value: 'foo' })).to.be(false); + expect(fn(true, { value: true })).to.be(false); + expect(fn(null, { value: null })).to.be(false); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/palette.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/palette.js new file mode 100644 index 0000000000000..ecb3ed8b2bbd0 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/palette.js @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { palette } from '../palette'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; +import { palettes } from '../../../../common/lib/palettes'; + +describe('palette', () => { + const fn = functionWrapper(palette); + + it('results a palette', () => { + const result = fn(null); + expect(result).to.have.property('type', 'palette'); + }); + + describe('args', () => { + describe('color', () => { + it('sets colors', () => { + const result = fn(null, { color: ['red', 'green', 'blue'] }); + expect(result.colors).to.eql(['red', 'green', 'blue']); + }); + + it('defaults to pault_tor_14 colors', () => { + const result = fn(null); + expect(result.colors).to.eql(palettes.paul_tor_14.colors); + }); + }); + + describe('gradient', () => { + it('sets gradient', () => { + let result = fn(null, { gradient: true }); + expect(result).to.have.property('gradient', true); + + result = fn(null, { gradient: false }); + expect(result).to.have.property('gradient', false); + }); + + it('defaults to false', () => { + const result = fn(null); + expect(result).to.have.property('gradient', false); + }); + }); + + describe('reverse', () => { + it('reverses order of the colors', () => { + const result = fn(null, { reverse: true }); + expect(result.colors).to.eql(palettes.paul_tor_14.colors.reverse()); + }); + + it('keeps the original order of the colors', () => { + const result = fn(null, { reverse: false }); + expect(result.colors).to.eql(palettes.paul_tor_14.colors); + }); + + it(`defaults to 'false`, () => { + const result = fn(null); + expect(result.colors).to.eql(palettes.paul_tor_14.colors); + }); + }); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/pie.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/pie.js new file mode 100644 index 0000000000000..d869227ae2dc8 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/pie.js @@ -0,0 +1,143 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { pie } from '../pie'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; +import { testPie } from './fixtures/test_pointseries'; +import { fontStyle, grayscalePalette, seriesStyle } from './fixtures/test_styles'; + +describe('pie', () => { + const fn = functionWrapper(pie); + + it('returns a render as pie', () => { + const result = fn(testPie); + expect(result) + .to.have.property('type', 'render') + .and.to.have.property('as', 'pie'); + }); + + describe('data', () => { + const result = fn(testPie).value.data; + it('is sorted by the series labels', () => { + expect(result.every((val, i) => (!!i ? val.label >= result[i - 1].label : true))).to.be(true); + }); + + it('has one series per unique label', () => { + const uniqueLabels = testPie.rows + .reduce( + (unique, series) => + !unique.includes(series.color) ? unique.concat([series.color]) : unique, + [] + ) + .sort(); + + expect(result).to.have.length(uniqueLabels.length); + expect(result.every((series, i) => series.label === uniqueLabels[i])).to.be(true); + }); + + it('populates the data of the plot with points from the pointseries', () => { + expect(result[0].data).to.eql([536]); + expect(result[1].data).to.eql([202]); + expect(result[2].data).to.eql([67]); + expect(result[3].data).to.eql([311]); + expect(result[4].data).to.eql([288]); + }); + }); + + describe('args', () => { + describe('palette', () => { + it('sets the color palette', () => { + const result = fn(testPie, { palette: grayscalePalette }).value.options; + expect(result).to.have.property('colors'); + expect(result.colors).to.eql(grayscalePalette.colors); + }); + + // TODO: write test when using an instance of the interpreter + // it("defaults to the expression '{palette}'", () => {}); + }); + + describe('seriesStyle', () => { + it('sets the color for a specific series', () => { + const result = fn(testPie, { seriesStyle: [seriesStyle] }).value; + const seriesIndex = result.data.findIndex(series => series.label === seriesStyle.label); + const resultSeries = result.data[seriesIndex]; + + expect(resultSeries).to.have.property('color', seriesStyle.color); + }); + }); + + describe('hole', () => { + it('sets the innerRadius of the pie chart', () => { + let result = fn(testPie, { hole: 0 }).value.options.series.pie; + expect(result).to.have.property('innerRadius', 0); + + result = fn(testPie, { hole: 50 }).value.options.series.pie; + expect(result).to.have.property('innerRadius', 0.5); + + result = fn(testPie, { hole: 100 }).value.options.series.pie; + expect(result).to.have.property('innerRadius', 1); + }); + + it('defaults to 0 when given an invalid radius', () => { + let result = fn(testPie).value.options.series.pie; + expect(result).to.have.property('innerRadius', 0); + + result = fn(testPie, { hole: -100 }).value.options.series.pie; + expect(result).to.have.property('innerRadius', 0); + }); + }); + + describe('labels', () => { + it('shows pie labels', () => { + const result = fn(testPie, { labels: true }).value.options.series.pie.label; + expect(result).to.have.property('show', true); + }); + + it('hides pie labels', () => { + const result = fn(testPie, { labels: false }).value.options.series.pie.label; + expect(result).to.have.property('show', false); + }); + + it('defaults to true', () => { + const result = fn(testPie).value.options.series.pie.label; + expect(result).to.have.property('show', true); + }); + }); + + describe('labelRadius', () => { + it('sets the radius of the label circle', () => { + let result = fn(testPie, { labelRadius: 0 }).value.options.series.pie.label; + expect(result).to.have.property('radius', 0); + + result = fn(testPie, { labelRadius: 50 }).value.options.series.pie.label; + expect(result).to.have.property('radius', 0.5); + + result = fn(testPie, { labelRadius: 100 }).value.options.series.pie.label; + expect(result).to.have.property('radius', 1); + }); + + it('defaults to 100% when given an invalid radius', () => { + let result = fn(testPie).value.options.series.pie.label; + expect(result).to.have.property('radius', 1); + + result = fn(testPie, { labelRadius: -100 }).value.options.series.pie.label; + expect(result).to.have.property('radius', 1); + }); + }); + + describe('font', () => { + it('sets the font style', () => { + const result = fn(testPie, { font: fontStyle }).value; + expect(result).to.have.property('font'); + expect(result.font).to.eql(fontStyle); + }); + + // TODO: write test when using an instance of the interpreter + // it("defaults to the expression '{font}'", () => {}); + }); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/plot.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/plot.js new file mode 100644 index 0000000000000..3d6c62419ef48 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/plot.js @@ -0,0 +1,234 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { plot } from '../plot'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; +import { testPlot } from './fixtures/test_pointseries'; +import { + fontStyle, + grayscalePalette, + gradientPalette, + yAxisConfig, + xAxisConfig, + seriesStyle, + defaultStyle, +} from './fixtures/test_styles'; + +describe('plot', () => { + const fn = functionWrapper(plot); + + it('returns a render as plot', () => { + const result = fn(testPlot); + expect(result, () => {}) + .to.have.property('type', 'render') + .and.to.have.property('as', 'plot'); + }); + + describe('data', () => { + const result = fn(testPlot).value.data; + it('is sorted by the series labels', () => { + expect(result.every((val, i) => (!!i ? val.label >= result[i - 1].label : true))).to.be(true); + }); + + it('has one series per unique label', () => { + const uniqueLabels = testPlot.rows + .reduce( + (unique, series) => + !unique.includes(series.color) ? unique.concat([series.color]) : unique, + [] + ) + .sort(); + + expect(result).to.have.length(uniqueLabels.length); + expect(result.every((series, i) => series.label === uniqueLabels[i])).to.be(true); + }); + + it('populates the data of the plot with points from the pointseries', () => { + expect(result[0].data).to.eql([ + [1517842800950, 605, { size: 100, text: 605 }], + [1517929200950, 583, { size: 200, text: 583 }], + ]); + + expect(result[1].data).to.eql([ + [1517842800950, 216, { size: 350, text: 216 }], + [1517929200950, 200, { size: 256, text: 200 }], + ]); + + expect(result[2].data).to.eql([[1517842800950, 67, { size: 240, text: 67 }]]); + + expect(result[3].data).to.eql([[1517842800950, 311, { size: 447, text: 311 }]]); + }); + }); + + describe('args', () => { + describe('seriesStyle', () => { + it('sets the seriesStyle for a specific series', () => { + const result = fn(testPlot, { seriesStyle: [seriesStyle] }).value; + const seriesIndex = result.data.findIndex(series => series.label === seriesStyle.label); + const resultSeries = result.data[seriesIndex]; + + expect(resultSeries.lines) + .to.have.property('lineWidth', seriesStyle.lines) + .and.to.have.property('show', false) + .and.to.have.property('fillColor', seriesStyle.color) + .and.to.have.property('fill', seriesStyle.fill / 10); + + expect(resultSeries.bars) + .to.have.property('show', false) + .and.to.have.property('barWidth', seriesStyle.bars) + .and.to.have.property('horizontal', seriesStyle.horizontalBars); + + expect(resultSeries.bubbles).to.have.property('fill', seriesStyle.fill); + + expect(resultSeries) + .to.have.property('stack', seriesStyle.stack) + .and.to.have.property('color', seriesStyle.color); + }); + }); + + describe('defaultStyle', () => { + it('sets the default seriesStyle for the entire plot', () => { + const results = fn(testPlot, { defaultStyle: defaultStyle }); + const defaultSeriesConfig = results.value.options.series; + + expect(defaultSeriesConfig.lines) + .to.have.property('lineWidth', defaultStyle.lines) + .and.to.have.property('show', false) + .and.to.have.property('fillColor', defaultStyle.color) + .and.to.have.property('fill', defaultStyle.fill / 10); + + expect(defaultSeriesConfig.bars) + .to.have.property('show', false) + .and.to.have.property('barWidth', defaultStyle.bars) + .and.to.have.property('horizontal', defaultStyle.horizontalBars); + + expect(defaultSeriesConfig.bubbles).to.have.property('fill', defaultStyle.fill); + + expect(defaultSeriesConfig) + .to.not.have.property('stack') + .and.to.not.have.property('color'); + }); + + // TODO: write test when using an instance of the interpreter + // it("defaults to the expression '{seriesStyle points=5}'", () => {}); + }); + + describe('palette', () => { + it('sets the color palette', () => { + const result = fn(testPlot, { palette: grayscalePalette }).value.options; + expect(result).to.have.property('colors'); + expect(result.colors).to.eql(grayscalePalette.colors); + }); + + it('creates a new set of colors from a color scale when gradient is true', () => { + const result = fn(testPlot, { palette: gradientPalette }).value.options; + expect(result).to.have.property('colors'); + expect(result.colors).to.eql(['#ffffff', '#aaaaaa', '#555555', '#000000']); + }); + + // TODO: write test when using an instance of the interpreter + // it("defaults to the expression '{palette}'", () => {}); + }); + + describe('font', () => { + it('sets the font style', () => { + const result = fn(testPlot, { font: fontStyle }).value; + const style = { + size: 14, + lHeight: 21, + style: 'normal', + weight: 'bolder', + family: 'Chalkboard, serif', + color: 'pink', + }; + expect(result.options.xaxis.font).to.eql(style); + expect(result.options.yaxis.font).to.eql(style); + }); + + // TODO: write test when using an instance of the interpreter + // it("defaults to the expression '{font}'", () => {}); + }); + + describe('legend', () => { + it('hides the legend', () => { + const result = fn(testPlot, { legend: false }).value.options; + expect(result.legend) + .to.only.have.key('show') + .and.to.have.property('show', false); + }); + + it('sets the position of the legend', () => { + let result = fn(testPlot, { legend: 'nw' }).value.options; + expect(result.legend).to.have.property('position', 'nw'); + + result = fn(testPlot, { legend: 'ne' }).value.options; + expect(result.legend).to.have.property('position', 'ne'); + + result = fn(testPlot, { legend: 'sw' }).value.options; + expect(result.legend).to.have.property('position', 'sw'); + + result = fn(testPlot, { legend: 'se' }).value.options; + expect(result.legend).to.have.property('position', 'se'); + }); + + it("defaults to 'ne' if invalid position is provided", () => { + let result = fn(testPlot).value.options; + expect(result.legend).to.have.property('position', 'ne'); + + result = fn(testPlot, { legend: true }).value.options; + expect(result.legend).to.have.property('position', 'ne'); + + result = fn(testPlot, { legend: 'foo' }).value.options; + expect(result.legend).to.have.property('position', 'ne'); + }); + }); + + describe('yaxis', () => { + it('sets visibility of the y-axis labels', () => { + let result = fn(testPlot, { yaxis: true }).value.options; + expect(result.yaxis).to.have.property('show', true); + + result = fn(testPlot, { yaxis: false }).value.options; + expect(result.yaxis).to.have.property('show', false); + }); + + it('configures the y-axis with an AxisConfig', () => { + const result = fn(testPlot, { yaxis: yAxisConfig }).value.options; + expect(result.yaxis) + .to.have.property('show', true) + .and.to.have.property('position', 'right'); + }); + + it("defaults to 'true' if not provided", () => { + const result = fn(testPlot).value.options; + expect(result.yaxis).to.have.property('show', true); + }); + }); + + describe('xaxis', () => { + it('sets visibility of the x-axis labels', () => { + let result = fn(testPlot, { xaxis: true }).value.options; + expect(result.xaxis).to.have.property('show', true); + + result = fn(testPlot, { xaxis: false }).value.options; + expect(result.xaxis).to.have.property('show', false); + }); + + it('configures the x-axis with an AxisConfig', () => { + const result = fn(testPlot, { xaxis: xAxisConfig }).value.options; + expect(result.xaxis) + .to.have.property('show', true) + .and.to.have.property('position', 'top'); + }); + + it("defaults to 'true' if not provided", () => { + const result = fn(testPlot).value.options; + expect(result.xaxis).to.have.property('show', true); + }); + }); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/ply.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/ply.js new file mode 100644 index 0000000000000..0e72cb38885b9 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/ply.js @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { ply } from '../ply'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; +import { testTable } from './fixtures/test_tables'; + +const averagePrice = datatable => { + const average = datatable.rows.reduce((sum, row) => sum + row.price, 0) / datatable.rows.length; + + return Promise.resolve({ + type: 'datatable', + columns: [{ name: 'average_price', type: 'number' }], + rows: [{ average_price: average }], + }); +}; + +const doublePrice = datatable => { + const newRows = datatable.rows.map(row => ({ double_price: row.price * 2 })); + + return Promise.resolve({ + type: 'datatable', + columns: [{ name: 'double_price', type: 'number' }], + rows: newRows, + }); +}; + +const rowCount = datatable => { + return Promise.resolve({ + type: 'datatable', + columns: [{ name: 'row_count', type: 'number' }], + rows: [ + { + row_count: datatable.rows.length, + }, + ], + }); +}; + +describe('ply', () => { + const fn = functionWrapper(ply); + + it('maps a function over sub datatables grouped by specified columns and merges results into one datatable', () => { + const arbitaryRowIndex = 0; + + return fn(testTable, { by: ['name', 'in_stock'], expression: [averagePrice, rowCount] }).then( + result => { + expect(result.type).to.be('datatable'); + expect(result.columns).to.eql([ + { name: 'name', type: 'string' }, + { name: 'in_stock', type: 'boolean' }, + { name: 'average_price', type: 'number' }, + { name: 'row_count', type: 'number' }, + ]); + expect(result.rows[arbitaryRowIndex]) + .to.have.property('average_price') + .and.to.have.property('row_count'); + } + ); + }); + + describe('missing args', () => { + it('returns the original datatable if both args are missing', () => { + return fn(testTable).then(result => expect(result).to.eql(testTable)); + }); + + describe('by', () => { + it('passes the entire context into the expression when no columns are provided', () => { + return fn(testTable, { expression: [rowCount] }).then(result => + expect(result).to.eql({ + type: 'datatable', + rows: [{ row_count: testTable.rows.length }], + columns: [{ name: 'row_count', type: 'number' }], + }) + ); + }); + + it('throws when by is an invalid column', () => { + expect(() => fn(testTable, { by: [''], expression: [averagePrice] })).to.throwException( + e => { + expect(e.message).to.be('No such column: '); + } + ); + expect(() => fn(testTable, { by: ['foo'], expression: [averagePrice] })).to.throwException( + e => { + expect(e.message).to.be('No such column: foo'); + } + ); + }); + }); + + describe('expression', () => { + it('returns the original datatable grouped by the specified columns', () => { + const arbitaryRowIndex = 6; + + return fn(testTable, { by: ['price', 'quantity'] }).then(result => { + expect(result.columns[0]).to.have.property('name', 'price'); + expect(result.columns[1]).to.have.property('name', 'quantity'); + expect(result.rows[arbitaryRowIndex]) + .to.have.property('price') + .and.to.have.property('quantity'); + }); + }); + + it('throws when row counts do not match across resulting datatables', () => { + return fn(testTable, { by: ['name'], expression: [doublePrice, rowCount] }).catch(e => + expect(e.message).to.be('All expressions must return the same number of rows') + ); + }); + }); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/render.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/render.js new file mode 100644 index 0000000000000..720242418e0a5 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/render.js @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { render } from '../render'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; +import { testTable } from './fixtures/test_tables'; +import { fontStyle, containerStyle } from './fixtures/test_styles'; + +const renderTable = { + type: 'render', + as: 'table', + value: { + datatable: testTable, + font: fontStyle, + paginate: false, + perPage: 2, + }, +}; + +describe('render', () => { + const fn = functionWrapper(render); + + it('returns a render', () => { + const result = fn(renderTable, { + as: 'debug', + css: '".canvasRenderEl { background-color: red; }"', + containerStyle: containerStyle, + }); + + expect(result).to.have.property('type', 'render'); + }); + + describe('args', () => { + describe('as', () => { + it('sets what the element will render as', () => { + const result = fn(renderTable, { as: 'debug' }); + expect(result).to.have.property('as', 'debug'); + }); + + it('keep the original context.as if not specified', () => { + const result = fn(renderTable); + expect(result).to.have.property('as', renderTable.as); + }); + }); + + describe('css', () => { + it('sets the custom CSS for the render elemnt', () => { + const result = fn(renderTable, { + css: '".canvasRenderEl { background-color: red; }"', + }); + + expect(result).to.have.property('css', '".canvasRenderEl { background-color: red; }"'); + }); + + it("defaults to '* > * {}'", () => { + const result = fn(renderTable); + expect(result).to.have.property('css', '"* > * {}"'); + }); + }); + + describe('containerStyle', () => { + it('sets the containerStyler', () => { + const result = fn(renderTable, { containerStyle: containerStyle }); + + expect(result).to.have.property('containerStyle', containerStyle); + }); + + it('returns a render object with containerStyle as undefined', () => { + const result = fn(renderTable); + expect(result).to.have.property('containerStyle', undefined); + }); + }); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/repeat_image.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/repeat_image.js new file mode 100644 index 0000000000000..0a18b7d25071e --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/repeat_image.js @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { repeatImage } from '../repeatImage'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; +import { elasticOutline } from '../../../lib/elastic_outline'; +import { elasticLogo } from '../../../lib/elastic_logo'; + +describe('repeatImage', () => { + const fn = functionWrapper(repeatImage); + + it('returns a render as repeatImage', () => { + const result = fn(10); + expect(result) + .to.have.property('type', 'render') + .and.to.have.property('as', 'repeatImage'); + }); + + describe('args', () => { + describe('image', () => { + it('sets the source of the repeated image', () => { + const result = fn(10, { image: elasticLogo }).value; + expect(result).to.have.property('image', elasticLogo); + }); + + it('defaults to the Elastic outline logo', () => { + const result = fn(100000).value; + expect(result).to.have.property('image', elasticOutline); + }); + }); + + describe('size', () => { + it('sets the size of the image', () => { + const result = fn(-5, { size: 200 }).value; + expect(result).to.have.property('size', 200); + }); + + it('defaults to 100', () => { + const result = fn(-5).value; + expect(result).to.have.property('size', 100); + }); + }); + + describe('max', () => { + it('sets the maximum number of a times the image is repeated', () => { + const result = fn(100000, { max: 20 }).value; + expect(result).to.have.property('max', 20); + }); + it('defaults to 1000', () => { + const result = fn(100000).value; + expect(result).to.have.property('max', 1000); + }); + }); + + describe('emptyImage', () => { + it('returns repeatImage object with emptyImage as undefined', () => { + const result = fn(100000, { emptyImage: elasticLogo }).value; + expect(result).to.have.property('emptyImage', elasticLogo); + }); + it('sets emptyImage to undefined', () => { + const result = fn(100000).value; + expect(result).to.have.property('emptyImage', undefined); + }); + }); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/replace.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/replace.js new file mode 100644 index 0000000000000..55fcbf10d7fd7 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/replace.js @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { replace } from '../replace'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; + +describe('replace', () => { + const fn = functionWrapper(replace); + + it('replaces text that matches the pattern', () => { + expect( + fn('A string with vowels', { + pattern: '[aeiou]', + flags: 'gi', + replacement: '*', + }) + ).to.be('* str*ng w*th v*w*ls'); + }); + + it('supports capture groups in the pattern', () => { + expect(fn('abcABCabcABC', { pattern: '(a)(b)(c)', flags: 'ig', replacement: '$1-$2 ' })).to.be( + 'a-b A-B a-b A-B ' + ); + + expect( + fn('500.948.0888, 589-786-3621, (887) 486 5577, 123 456 7890', { + pattern: '\\(?([0-9]{3})\\)?[-. ]?([0-9]{3})[-. ]?([0-9]{4})', + flags: 'g', + replacement: '($1)$2-$3', + }) + ).to.be('(500)948-0888, (589)786-3621, (887)486-5577, (123)456-7890'); + }); + + describe('args', () => { + describe('pattern', () => { + it('sets the pattern for RegEx', () => { + expect( + fn('\t\t\t\tfoo\rbar\n\rfizz\n\r\nbuzz\r\n\n', { + pattern: '\\s+', + flag: 'g', + replacement: ',', + }) + ).to.be(',foo,bar,fizz,buzz,'); + }); + + it('adds the replacement between every character if not specified (default behavior of String.replace)', () => { + expect(fn('140000', { flags: 'g', replacement: 'X' })).to.be('X1X4X0X0X0X0X'); + expect(fn('140000', { flags: 'g', replacement: 'foo' })).to.be( + 'foo1foo4foo0foo0foo0foo0foo' + ); + }); + }); + + describe('flags', () => { + it('sets the flags for RegEx', () => { + expect(fn('AaBbAaBb', { pattern: 'a', flags: 'ig', replacement: '_' })).to.be('__Bb__Bb'); + expect(fn('AaBbAaBb', { pattern: 'a', flags: 'i', replacement: '_' })).to.be('_aBbAaBb'); + expect(fn('AaBbAaBb', { pattern: 'a', flags: '', replacement: '_' })).to.be('A_BbAaBb'); + }); + + it("defaults to 'g' if flag is not provided", () => { + expect(fn('This,is,a,test!', { pattern: ',', replacement: ' ' })).to.be('This is a test!'); + }); + }); + + describe('replacement', () => { + it('sets the replacement string for all RegEx matches', () => { + expect(fn('140000', { pattern: '0', replacement: '1' })).to.be('141111'); + }); + // TODO: put test back when using interpreter + // it('removes matches to regex if replacement is not provided', () => { + // expect(fn('140000', { pattern: '0' })).to.be('14'); + // }); + }); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/reveal_image.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/reveal_image.js new file mode 100644 index 0000000000000..3dc00b3c55592 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/reveal_image.js @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { revealImage } from '../revealImage'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; +import { elasticOutline } from '../../../lib/elastic_outline'; +import { elasticLogo } from '../../../lib/elastic_logo'; + +describe('revealImage', () => { + const fn = functionWrapper(revealImage); + + it('returns a render as revealImage', () => { + const result = fn(0.5); + expect(result) + .to.have.property('type', 'render') + .and.to.have.property('as', 'revealImage'); + }); + + describe('context', () => { + it('throws when context is not a number between 0 and 1', () => { + expect(fn) + .withArgs(10, { + image: elasticLogo, + emptyImage: elasticOutline, + origin: 'top', + }) + .to.throwException(e => { + expect(e.message).to.be.equal('input must be between 0 and 1'); + }); + + expect(fn) + .withArgs(-0.1, { + image: elasticLogo, + emptyImage: elasticOutline, + origin: 'top', + }) + .to.throwException(e => { + expect(e.message).to.be.equal('input must be between 0 and 1'); + }); + }); + }); + + describe('args', () => { + describe('image', () => { + it('sets the image', () => { + const result = fn(0.89, { image: elasticLogo }).value; + expect(result).to.have.property('image', elasticLogo); + }); + + it('defaults to the Elastic outline logo', () => { + const result = fn(0.89).value; + expect(result).to.have.property('image', elasticOutline); + }); + }); + + describe('emptyImage', () => { + it('sets the background image', () => { + const result = fn(0, { emptyImage: elasticLogo }).value; + expect(result).to.have.property('emptyImage', elasticLogo); + }); + + it('sets emptyImage to undefined', () => { + const result = fn(0).value; + expect(result).to.have.property('emptyImage', undefined); + }); + }); + + describe('origin', () => { + it('sets which side to start the reveal from', () => { + let result = fn(1, { origin: 'top' }).value; + expect(result).to.have.property('origin', 'top'); + result = fn(1, { origin: 'left' }).value; + expect(result).to.have.property('origin', 'left'); + result = fn(1, { origin: 'bottom' }).value; + expect(result).to.have.property('origin', 'bottom'); + result = fn(1, { origin: 'right' }).value; + expect(result).to.have.property('origin', 'right'); + }); + + it('defaults to bottom', () => { + const result = fn(1).value; + expect(result).to.have.property('origin', 'bottom'); + }); + }); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/rounddate.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/rounddate.js new file mode 100644 index 0000000000000..ab66ef05f4f2b --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/rounddate.js @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { rounddate } from '../rounddate'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; + +describe('rounddate', () => { + const fn = functionWrapper(rounddate); + const date = new Date('2011-10-31T00:00:00.000Z').valueOf(); + + it('returns date in ms from date in ms or ISO8601 string', () => { + expect(fn(date, { format: 'YYYY' })).to.be(1293840000000); + }); + + describe('args', () => { + describe('format', () => { + it('sets the format for the rounded date', () => { + expect(fn(date, { format: 'YYYY-MM' })).to.be(1317427200000); + expect(fn(date, { format: 'YYYY-MM-DD-hh' })).to.be(1320062400000); + }); + + it('returns original date if format is not provided', () => { + expect(fn(date)).to.be(date); + }); + }); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/rowCount.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/rowCount.js new file mode 100644 index 0000000000000..c50f39f7b3084 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/rowCount.js @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { rowCount } from '../rowCount'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; +import { emptyTable, testTable } from './fixtures/test_tables'; + +describe('rowCount', () => { + const fn = functionWrapper(rowCount); + + it('returns the number of rows in the datatable', () => { + expect(fn(testTable)).to.equal(testTable.rows.length); + expect(fn(emptyTable)).to.equal(0); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/series_style.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/series_style.js new file mode 100644 index 0000000000000..a0de476435949 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/series_style.js @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { seriesStyle } from '../seriesStyle'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; + +describe('seriesStyle', () => { + const fn = functionWrapper(seriesStyle); + + it('returns a seriesStyle', () => { + const result = fn(null); + expect(result).to.have.property('type', 'seriesStyle'); + }); + + describe('args', () => { + describe('label', () => { + it('sets label to identify which series to style', () => { + const result = fn(null, { label: 'kibana' }); + expect(result).to.have.property('label', 'kibana'); + }); + }); + + describe('color', () => { + it('sets color', () => { + const result = fn(null, { color: 'purple' }); + expect(result).to.have.property('color', 'purple'); + }); + }); + + describe('lines', () => { + it('sets line width', () => { + const result = fn(null, { lines: 1 }); + expect(result).to.have.property('lines', 1); + }); + }); + + describe('bars', () => { + it('sets bar width', () => { + const result = fn(null, { bars: 3 }); + expect(result).to.have.property('bars', 3); + }); + }); + + describe('points', () => { + it('sets point size', () => { + const result = fn(null, { points: 2 }); + expect(result).to.have.property('points', 2); + }); + }); + + describe('fill', () => { + it('sets if series is filled', () => { + let result = fn(null, { fill: true }); + expect(result).to.have.property('fill', true); + + result = fn(null, { fill: false }); + expect(result).to.have.property('fill', false); + }); + }); + + describe('stack', () => { + it('sets stack id to stack multiple series with a shared id', () => { + const result = fn(null, { stack: 1 }); + expect(result).to.have.property('stack', 1); + }); + }); + + describe('horizontalBars', () => { + it('sets orientation of the series to horizontal', () => { + const result = fn(null, { horizontalBars: true }); + expect(result).to.have.property('horizontalBars', true); + }); + it('sets orientation of the series to vertical', () => { + const result = fn(null, { horizontalBars: false }); + expect(result).to.have.property('horizontalBars', false); + }); + }); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/series_style_to_flot.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/series_style_to_flot.js new file mode 100644 index 0000000000000..adb6b48b9b28b --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/series_style_to_flot.js @@ -0,0 +1,146 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { seriesStyleToFlot } from '../plot/series_style_to_flot'; + +describe('seriesStyleToFlot', () => { + it('returns an empty object if seriesStyle is not provided', () => { + expect(seriesStyleToFlot(null)).to.be.empty(); + }); + + const testSeriesStyle = { + type: 'seriesStyle', + label: null, + color: null, + lines: 0, + bars: 0, + points: 0, + fill: false, + stack: undefined, + horizontalBars: false, + }; + + describe('seriesStyle properties', () => { + describe('color', () => { + const seriesStyle = { ...testSeriesStyle, color: 'blue' }; + const result = seriesStyleToFlot(seriesStyle); + it('sets fillColor for lines', () => { + expect(result.lines).to.have.property('fillColor', 'blue'); + }); + + it('sets color', () => { + expect(result).to.have.property('color', 'blue'); + }); + }); + + describe('lines', () => { + describe('sets show', () => { + it('hides line graphs when line width <= 0', () => { + const seriesStyle = { ...testSeriesStyle }; + expect(seriesStyleToFlot(seriesStyle).lines).to.have.property('show', false); + }); + + it('shows line graphs when line width > 0', () => { + const seriesStyle = { ...testSeriesStyle, lines: 1 }; + expect(seriesStyleToFlot(seriesStyle).lines).to.have.property('show', true); + }); + }); + + describe('sets lineWidth', () => { + it('sets the line width', () => { + const seriesStyle = { ...testSeriesStyle }; + let result = seriesStyleToFlot(seriesStyle); + expect(result.lines).to.have.property('lineWidth', 0); + + seriesStyle.lines = 1; + result = seriesStyleToFlot(seriesStyle); + expect(result.lines).to.have.property('lineWidth', 1); + + seriesStyle.lines = 10; + result = seriesStyleToFlot(seriesStyle); + expect(result.lines).to.have.property('lineWidth', 10); + }); + }); + }); + + describe('bars', () => { + describe('sets show', () => { + it('hides bar graphs when bar width <= 0', () => { + const seriesStyle = { ...testSeriesStyle }; + expect(seriesStyleToFlot(seriesStyle).bars).to.have.property('show', false); + }); + + it('shows bar graphs when bar width > 0', () => { + const seriesStyle = { ...testSeriesStyle, bars: 1 }; + expect(seriesStyleToFlot(seriesStyle).bars).to.have.property('show', true); + }); + }); + + describe('sets barWidth', () => { + it('sets the bar width', () => { + const seriesStyle = { ...testSeriesStyle }; + let result = seriesStyleToFlot(seriesStyle); + expect(result.bars).to.have.property('barWidth', 0); + + seriesStyle.bars = 1; + result = seriesStyleToFlot(seriesStyle); + expect(result.bars).to.have.property('barWidth', 1); + + seriesStyle.bars = 10; + result = seriesStyleToFlot(seriesStyle); + expect(result.bars).to.have.property('barWidth', 10); + }); + }); + }); + + describe('fill', () => { + it('sets opacity of fill for line graphs', () => { + const seriesStyle = { ...testSeriesStyle, fill: 10 }; + let result = seriesStyleToFlot(seriesStyle); + expect(result.lines).to.have.property('fill', 1); + + seriesStyle.fill = 5; + result = seriesStyleToFlot(seriesStyle); + expect(result.lines).to.have.property('fill', 0.5); + }); + + it('sets fill of bubbles', () => { + const seriesStyle = { ...testSeriesStyle, fill: 10 }; + let result = seriesStyleToFlot(seriesStyle); + expect(result.bubbles).to.have.property('fill', 10); + + seriesStyle.fill = 5; + result = seriesStyleToFlot(seriesStyle); + expect(result.bubbles).to.have.property('fill', 5); + }); + }); + + describe('stack', () => { + it('sets stack', () => { + const seriesStyle = { ...testSeriesStyle, stack: 1 }; + let result = seriesStyleToFlot(seriesStyle); + expect(result).to.have.property('stack', 1); + + seriesStyle.stack = 5; + result = seriesStyleToFlot(seriesStyle); + expect(result).to.have.property('stack', 5); + }); + }); + + describe('horizontalBars', () => { + it('sets the orientation of the bar graph', () => { + const seriesStyle = { ...testSeriesStyle }; + let result = seriesStyleToFlot(seriesStyle); + expect(result.bars).to.have.property('horizontal', false); + + seriesStyle.horizontalBars = true; + result = seriesStyleToFlot(seriesStyle); + expect(result.bars).to.have.property('horizontal', true); + }); + }); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/sort.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/sort.js new file mode 100644 index 0000000000000..3cfaf7d46ae18 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/sort.js @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { sort } from '../sort'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; +import { testTable } from './fixtures/test_tables'; + +describe('sort', () => { + const fn = functionWrapper(sort); + + const isSorted = (rows, column, reverse) => { + if (reverse) return !rows.some((row, i) => rows[i + 1] && row[column] < rows[i + 1][column]); + return !rows.some((row, i) => rows[i + 1] && row[column] > rows[i + 1][column]); + }; + + it('returns a datatable sorted by a specified column in asc order', () => { + const result = fn(testTable, { by: 'price', reverse: false }); + + expect(result.type).to.be('datatable'); + expect(result.columns).to.eql(testTable.columns); + expect(isSorted(result.rows, 'price', false)).to.be(true); + }); + + describe('args', () => { + describe('by', () => { + it('sorts on a specified column', () => { + const result = fn(testTable, { by: 'quantity', reverse: true }); + + expect(isSorted(result.rows, 'quantity', true)).to.be(true); + }); + + it('sorts on the first column if not specified', () => { + const result = fn(testTable, { reverse: false }); + + expect(isSorted(result.rows, result.columns[0].name, false)).to.be(true); + }); + + it('returns the original datatable if given an invalid column', () => { + expect(fn(testTable, { by: 'foo' })).to.eql(testTable); + }); + }); + + describe('reverse', () => { + it('sorts in asc order', () => { + const result = fn(testTable, { by: 'in_stock', reverse: false }); + + expect(isSorted(result.rows, 'in_stock', false)).to.be(true); + }); + + it('sorts in desc order', () => { + const result = fn(testTable, { by: 'price', reverse: true }); + + expect(isSorted(result.rows, 'price', true)).to.be(true); + }); + + it('sorts in asc order by default', () => { + const result = fn(testTable, { by: 'time' }); + + expect(isSorted(result.rows, 'time', false)).to.be(true); + }); + }); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/staticColumn.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/staticColumn.js new file mode 100644 index 0000000000000..bce158140d1a0 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/staticColumn.js @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { staticColumn } from '../staticColumn'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; +import { testTable } from './fixtures/test_tables'; + +describe('staticColumn', () => { + const fn = functionWrapper(staticColumn); + + it('adds a column to a datatable with a static value in every row', () => { + const result = fn(testTable, { name: 'foo', value: 'bar' }); + + expect(result.type).to.be('datatable'); + expect(result.columns).to.eql([...testTable.columns, { name: 'foo', type: 'string' }]); + expect(result.rows.every(row => typeof row.foo === 'string')).to.be(true); + expect(result.rows.every(row => row.foo === 'bar')).to.be(true); + }); + + it('overwrites an existing column if provided an existing column name', () => { + const result = fn(testTable, { name: 'name', value: 'John' }); + + expect(result.type).to.be('datatable'); + expect(result.columns).to.eql(testTable.columns); + expect(result.rows.every(row => typeof row.name === 'string')).to.be(true); + expect(result.rows.every(row => row.name === 'John')).to.be(true); + }); + + it('adds a column with null values', () => { + const result = fn(testTable, { name: 'empty' }); + + expect(result.type).to.be('datatable'); + expect(result.columns).to.eql([...testTable.columns, { name: 'empty', type: 'null' }]); + expect(result.rows.every(row => row.empty === null)).to.be(true); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/string.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/string.js new file mode 100644 index 0000000000000..7961d0553644d --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/string.js @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { string } from '../string'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; + +describe('string', () => { + const fn = functionWrapper(string); + + it('casts primitive types to strings', () => { + expect(fn(null, { value: [14000] })).to.be('14000'); + expect(fn(null, { value: ['foo'] })).to.be('foo'); + expect(fn(null, { value: [null] })).to.be(''); + expect(fn(null, { value: [true] })).to.be('true'); + }); + + it('concatenates all args to one string', () => { + expect(fn(null, { value: ['foo', 'bar', 'fizz', 'buzz'] })).to.be('foobarfizzbuzz'); + expect(fn(null, { value: ['foo', 1, true, null] })).to.be('foo1true'); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/switch.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/switch.js new file mode 100644 index 0000000000000..769b0ae8c5579 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/switch.js @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { switchFn } from '../switch'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; + +describe('switch', () => { + const fn = functionWrapper(switchFn); + const getter = value => () => value; + const mockCases = [ + { + type: 'case', + matches: false, + result: 1, + }, + { + type: 'case', + matches: false, + result: 2, + }, + { + type: 'case', + matches: true, + result: 3, + }, + { + type: 'case', + matches: false, + result: 4, + }, + { + type: 'case', + matches: true, + result: 5, + }, + ]; + const nonMatchingCases = mockCases.filter(c => !c.matches); + + describe('spec', () => { + it('is a function', () => { + expect(fn).to.be.a('function'); + }); + }); + + describe('function', () => { + describe('with no cases', () => { + it('should return the context if no default is provided', async () => { + const context = 'foo'; + expect(await fn(context, {})).to.be(context); + }); + + it('should return the default if provided', async () => { + const context = 'foo'; + const args = { default: () => 'bar' }; + expect(await fn(context, args)).to.be(args.default()); + }); + }); + + describe('with no matching cases', () => { + it('should return the context if no default is provided', async () => { + const context = 'foo'; + const args = { case: nonMatchingCases.map(getter) }; + expect(await fn(context, args)).to.be(context); + }); + + it('should return the default if provided', async () => { + const context = 'foo'; + const args = { + case: nonMatchingCases.map(getter), + default: () => 'bar', + }; + expect(await fn(context, args)).to.be(args.default()); + }); + }); + + describe('with matching cases', () => { + it('should return the first match', async () => { + const context = 'foo'; + const args = { case: mockCases.map(getter) }; + const firstMatch = mockCases.find(c => c.matches); + expect(await fn(context, args)).to.be(firstMatch.result); + }); + }); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/table.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/table.js new file mode 100644 index 0000000000000..0980f4b124f49 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/table.js @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { table } from '../table'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; +import { testTable } from './fixtures/test_tables'; +import { fontStyle } from './fixtures/test_styles'; + +describe('table', () => { + const fn = functionWrapper(table); + + it('returns a render as table', () => { + const result = fn(testTable, { + font: fontStyle, + paginate: false, + perPage: 2, + }); + expect(result) + .to.have.property('type', 'render') + .and.to.have.property('as', 'table'); + }); + + describe('context', () => { + it('sets the context as the datatable', () => { + const result = fn(testTable).value; + expect(result).to.have.property('datatable', testTable); + }); + }); + + describe('args', () => { + describe('font', () => { + it('sets the font style of the table', () => { + const result = fn(testTable, { font: fontStyle }).value; + expect(result).to.have.property('font', fontStyle); + }); + + it('defaults to a Canvas expression that calls the font function', () => { + const result = fn(testTable).value; + expect(result).to.have.property('font', '{font}'); // should evaluate to a font object and not a string + }); + }); + + describe('paginate', () => { + it('sets whether or not to paginate the table', () => { + let result = fn(testTable, { paginate: true }).value; + expect(result).to.have.property('paginate', true); + + result = fn(testTable, { paginate: false }).value; + expect(result).to.have.property('paginate', false); + }); + + it('defaults to true', () => { + const result = fn(testTable).value; + expect(result).to.have.property('paginate', true); + }); + }); + + describe('perPage', () => { + it('sets how many rows display per page', () => { + const result = fn(testTable, { perPage: 30 }).value; + expect(result).to.have.property('perPage', 30); + }); + + it('defaults to 10', () => { + const result = fn(testTable).value; + expect(result).to.have.property('perPage', 10); + }); + }); + + describe('showHeader', () => { + it('sets the showHeader property', () => { + const result = fn(testTable, { showHeader: false }).value; + expect(result).to.have.property('showHeader', false); + }); + + it('defaults to true', () => { + const result = fn(testTable).value; + expect(result).to.have.property('showHeader', true); + }); + }); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/tail.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/tail.js new file mode 100644 index 0000000000000..2e2148237df3d --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/tail.js @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { tail } from '../tail'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; +import { emptyTable, testTable } from './fixtures/test_tables'; + +describe('tail', () => { + const fn = functionWrapper(tail); + const lastIndex = testTable.rows.length - 1; + + it('returns a datatable with the last N rows of the context', () => { + const result = fn(testTable, { count: 2 }); + + expect(result.type).to.be('datatable'); + expect(result.columns).to.eql(testTable.columns); + expect(result.rows).to.have.length(2); + expect(result.rows[0]).to.eql(testTable.rows[lastIndex - 1]); + expect(result.rows[1]).to.eql(testTable.rows[lastIndex]); + }); + + it('returns the original context if N >= context.rows.length', () => { + expect(fn(testTable, { count: testTable.rows.length + 5 })).to.eql(testTable); + expect(fn(testTable, { count: testTable.rows.length })).to.eql(testTable); + expect(fn(emptyTable)).to.eql(emptyTable); + }); + + it('returns the last row if N is not specified', () => { + const result = fn(testTable); + + expect(result.rows).to.have.length(1); + expect(result.rows[0]).to.eql(testTable.rows[lastIndex]); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/timefilter.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/timefilter.js new file mode 100644 index 0000000000000..a63adc3d05335 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/timefilter.js @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import sinon from 'sinon'; +import { timefilter } from '../timefilter'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; +import { emptyFilter } from './fixtures/test_filters'; + +let clock = null; + +beforeEach(function() { + clock = sinon.useFakeTimers(); +}); + +afterEach(function() { + clock.restore(); +}); + +describe('timefilter', () => { + const fn = functionWrapper(timefilter); + const fromDate = '2018-02-06T15:00:00.950Z'; + const toDate = '2018-02-07T15:00:00.950Z'; + + it('returns a filter', () => { + expect( + fn(emptyFilter, { + column: 'time', + from: fromDate, + to: toDate, + }) + ).to.have.property('type', 'filter'); + }); + + it("adds a time object to 'and'", () => { + expect( + fn(emptyFilter, { + column: 'time', + from: fromDate, + to: toDate, + }).and[0] + ).to.have.property('type', 'time'); + }); + + describe('args', () => { + it("returns the original context if neither 'from' nor 'to' is provided", () => { + expect(fn(emptyFilter, { column: 'time' })).to.eql(emptyFilter); + }); + + describe('column', () => { + it('sets the column to apply the filter to', () => { + expect(fn(emptyFilter, { column: 'time', from: 'now' }).and[0]).to.have.property( + 'column', + 'time' + ); + }); + + it("defaults column to '@timestamp'", () => { + expect(fn(emptyFilter, { from: 'now' }).and[0]).to.have.property('column', '@timestamp'); + }); + }); + + describe('from', () => { + it('sets the start date', () => { + let result = fn(emptyFilter, { from: fromDate }); + expect(result.and[0]).to.have.property('from', fromDate); + + result = fn(emptyFilter, { from: 'now-5d' }); + const dateOffset = 24 * 60 * 60 * 1000 * 5; //5 days + expect(result.and[0]).to.have.property( + 'from', + new Date(new Date().getTime() - dateOffset).toISOString() + ); + }); + }); + + describe('to', () => { + it('sets the end date', () => { + let result = fn(emptyFilter, { to: toDate }); + expect(result.and[0]).to.have.property('to', toDate); + + result = fn(emptyFilter, { to: 'now' }); + expect(result.and[0]).to.have.property('to', new Date().toISOString()); + }); + + it('throws when provided an invalid date string', () => { + expect(() => fn(emptyFilter, { from: '2018-13-42T15:00:00.950Z' })).to.throwException(e => { + expect(e.message).to.be.equal('Invalid date/time string 2018-13-42T15:00:00.950Z'); + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/timefilter_control.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/timefilter_control.js new file mode 100644 index 0000000000000..1341f961412da --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/timefilter_control.js @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { timefilterControl } from '../timefilterControl'; +import { functionWrapper } from '../../../../__tests__/helpers/function_wrapper'; + +describe('timefilterControl', () => { + const fn = functionWrapper(timefilterControl); + + it('returns a render as time_filter', () => { + expect(fn(null, { column: 'time', compact: false })) + .to.have.property('type', 'render') + .and.to.have.property('as', 'time_filter'); + }); + + describe('args', () => { + describe('column', () => { + it('set the column the filter is applied to', () => { + expect(fn(null, { column: 'time' }).value).to.have.property('column', 'time'); + }); + }); + }); + + it('set if time filter displays in compact mode', () => { + expect(fn(null, { compact: false }).value).to.have.property('compact', false); + expect(fn(null, { compact: true }).value).to.have.property('compact', true); + }); + + it('defaults time filter display to compact mode', () => { + expect(fn(null).value).to.have.property('compact', true); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/all.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/all.js new file mode 100644 index 0000000000000..417803830f451 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/all.js @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const all = () => ({ + name: 'all', + type: 'boolean', + help: 'Return true if all of the conditions are true', + args: { + condition: { + aliases: ['_'], + types: ['boolean', 'null'], + required: true, + multi: true, + help: 'One or more conditions to check', + }, + }, + fn: (context, args) => { + const conditions = args.condition || []; + return conditions.every(Boolean); + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/alterColumn.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/alterColumn.js new file mode 100644 index 0000000000000..b5fd1a1333fc3 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/alterColumn.js @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { omit } from 'lodash'; + +export const alterColumn = () => ({ + name: 'alterColumn', + type: 'datatable', + help: 'Converts between core types, eg string, number, null, boolean, date and rename columns', + context: { + types: ['datatable'], + }, + args: { + column: { + aliases: ['_'], + types: ['string'], + help: 'The name of the column to alter', + }, + type: { + types: ['string'], + help: 'The type to convert the column to. Leave blank to not change type.', + default: null, + }, + name: { + types: ['string'], + help: 'The resultant column name. Leave blank to not rename.', + default: null, + }, + }, + fn: (context, args) => { + if (!args.column || (!args.type && !args.name)) return context; + + const column = context.columns.find(col => col.name === args.column); + if (!column) throw new Error(`Column not found: '${args.column}'`); + + const name = args.name || column.name; + const type = args.type || column.type; + + const columns = context.columns.reduce((all, col) => { + if (col.name !== args.name) { + if (col.name !== column.name) all.push(col); + else all.push({ name, type }); + } + return all; + }, []); + + let handler = val => val; + + if (args.type) { + handler = (function getHandler() { + switch (type) { + case 'string': + if (column.type === 'date') return v => new Date(v).toISOString(); + return String; + case 'number': + return Number; + case 'date': + return v => new Date(v).valueOf(); + case 'boolean': + return Boolean; + case 'null': + return () => null; + default: + throw new Error(`Cannot convert to ${type}`); + } + })(); + } + + const rows = context.rows.map(row => ({ + ...omit(row, column.name), + [name]: handler(row[column.name]), + })); + + return { + type: 'datatable', + columns, + rows, + }; + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/any.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/any.js new file mode 100644 index 0000000000000..9e0b2fd08928e --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/any.js @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const any = () => ({ + name: 'any', + type: 'boolean', + help: 'Return true if any of the conditions are true', + args: { + condition: { + aliases: ['_'], + types: ['boolean', 'null'], + required: true, + multi: true, + help: 'One or more conditions to check', + }, + }, + fn: (context, args) => { + const conditions = args.condition || []; + return conditions.some(Boolean); + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/as.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/as.js new file mode 100644 index 0000000000000..c85cc9e0d5baf --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/as.js @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getType } from '../../../common/lib/get_type'; + +export const asFn = () => ({ + name: 'as', + type: 'datatable', + context: { + types: ['string', 'boolean', 'number', 'null'], + }, + help: 'Creates a datatable with a single value', + args: { + name: { + types: ['string'], + aliases: ['_'], + help: 'A name to give the column', + default: 'value', + }, + }, + fn: (context, args) => { + return { + type: 'datatable', + columns: [ + { + name: args.name, + type: getType(context), + }, + ], + rows: [ + { + [args.name]: context, + }, + ], + }; + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/axisConfig.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/axisConfig.js new file mode 100644 index 0000000000000..d583368266249 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/axisConfig.js @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import moment from 'moment'; +export const axisConfig = () => ({ + name: 'axisConfig', + aliases: [], + type: 'axisConfig', + context: { + types: ['datatable'], + }, + help: 'Configure axis of a visualization', + args: { + show: { + types: ['boolean'], + help: 'Show the axis labels?', + default: true, + }, + position: { + types: ['string'], + help: 'Position of the axis labels. Eg, top, bottom, left, and right.', + default: '', + }, + min: { + types: ['number', 'date', 'string', 'null'], + help: + 'Minimum value displayed in the axis. Must be a number or a date in ms or ISO8601 string', + }, + max: { + types: ['number', 'date', 'string', 'null'], + help: + 'Maximum value displayed in the axis. Must be a number or a date in ms or ISO8601 string', + }, + tickSize: { + types: ['number', 'null'], + help: 'Increment size between each tick. Use for number axes only', + }, + }, + fn: (context, args) => { + const positions = ['top', 'bottom', 'left', 'right', '']; + if (!positions.includes(args.position)) throw new Error(`Invalid position ${args.position}`); + + const min = typeof args.min === 'string' ? moment.utc(args.min).valueOf() : args.min; + const max = typeof args.max === 'string' ? moment.utc(args.max).valueOf() : args.max; + + if (min != null && isNaN(min)) { + throw new Error( + `Invalid date string '${ + args.min + }' found. 'min' must be a number, date in ms, or ISO8601 date string` + ); + } + if (max != null && isNaN(max)) { + throw new Error( + `Invalid date string '${ + args.max + }' found. 'max' must be a number, date in ms, or ISO8601 date string` + ); + } + + return { + type: 'axisConfig', + ...args, + min, + max, + }; + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/case.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/case.js new file mode 100644 index 0000000000000..84a5ba58a3731 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/case.js @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const caseFn = () => ({ + name: 'case', + type: 'case', + help: 'Build a case (including a condition/result) to pass to the switch function.', + args: { + when: { + aliases: ['_'], + resolve: false, + help: + 'This value is compared to the context to see if the condition is met. It is overridden by the "if" argument if both are provided.', + }, + if: { + types: ['boolean'], + help: + 'This value is used as whether or not the condition is met. It overrides the unnamed argument if both are provided.', + }, + then: { + resolve: false, + help: 'The value to return if the condition is met', + }, + }, + fn: async (context, args) => { + const matches = await doesMatch(context, args); + const result = matches ? await getResult(context, args) : null; + return { type: 'case', matches, result }; + }, +}); + +async function doesMatch(context, args) { + if (typeof args.if !== 'undefined') return args.if; + if (typeof args.when !== 'undefined') return (await args.when()) === context; + return true; +} + +async function getResult(context, args) { + if (typeof args.then !== 'undefined') return await args.then(); + return context; +} diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/clog.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/clog.js new file mode 100644 index 0000000000000..db4cc4179762f --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/clog.js @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const clog = () => ({ + name: 'clog', + help: 'Outputs the context to the console', + fn: context => { + console.log(context); //eslint-disable-line no-console + return context; + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/columns.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/columns.js new file mode 100644 index 0000000000000..1dba45a459471 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/columns.js @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { omit, pick, find } from 'lodash'; + +export const columns = () => ({ + name: 'columns', + type: 'datatable', + help: + 'Include or exclude columns from a data table. If you specify both, this will exclude first', + context: { + types: ['datatable'], + }, + args: { + include: { + types: ['string'], + help: 'A comma separated list of column names to keep in the table', + default: null, + }, + exclude: { + types: ['string'], + help: 'A comma separated list of column names to remove from the table', + default: null, + }, + }, + fn: (context, args) => { + const { include, exclude } = args; + + let result = { ...context }; + + if (exclude) { + const fields = exclude.split(',').map(field => field.trim()); + const columns = result.columns.filter(col => !fields.includes(col.name)); + const rows = columns.length > 0 ? result.rows.map(row => omit(row, fields)) : []; + + result = { ...result, rows, columns }; + } + + if (include) { + const fields = include.split(',').map(field => field.trim()); + //const columns = result.columns.filter(col => fields.includes(col.name)); + // Include columns in the order the user specified + const columns = []; + fields.forEach(field => { + const column = find(result.columns, { name: field }); + if (column) columns.push(column); + }); + const rows = columns.length > 0 ? result.rows.map(row => pick(row, fields)) : []; + result = { ...result, rows, columns }; + } + + return result; + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/compare.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/compare.js new file mode 100644 index 0000000000000..c3ff1f7e3f6ea --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/compare.js @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const compare = () => ({ + name: 'compare', + help: + 'Compare the input to something else to determine true or false. Usually used in combination with `{if}`. This only works with primitive types, such as number, string, and boolean.', + aliases: ['condition'], + example: 'math "random()" | compare gt this=0.5', + type: 'boolean', + context: { + types: ['null', 'string', 'number', 'boolean'], + }, + args: { + op: { + aliases: ['_'], + types: ['string'], + default: 'eq', + help: + 'The operator to use in the comparison: ' + + ' eq (equal), ne (not equal), lt (less than), gt (greater than), lte (less than equal), gte (greater than eq)', + }, + to: { + aliases: ['this', 'b'], + help: 'The value to compare the context to, usually returned by a subexpression', + }, + }, + fn: (context, args) => { + const a = context; + const b = args.to; + const op = args.op; + const typesMatch = typeof a === typeof b; + + switch (op) { + case 'eq': + return a === b; + case 'ne': + return a !== b; + case 'lt': + if (typesMatch) return a < b; + return false; + case 'lte': + if (typesMatch) return a <= b; + return false; + case 'gt': + if (typesMatch) return a > b; + return false; + case 'gte': + if (typesMatch) return a >= b; + return false; + default: + throw new Error('Invalid compare operator. Use eq, ne, lt, gt, lte, or gte.'); + } + + return false; + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/containerStyle.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/containerStyle.js new file mode 100644 index 0000000000000..4fe85a72115d1 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/containerStyle.js @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { isValid } from '../../../common/lib/url'; + +export const containerStyle = () => ({ + name: 'containerStyle', + aliases: [], + context: { + types: ['null'], + }, + type: 'containerStyle', + help: + 'Creates an object used for describing the properties of a series on a chart.' + + ' You would usually use this inside of a charting function', + args: { + border: { + types: ['string', 'null'], + help: 'Valid CSS border string', + }, + borderRadius: { + types: ['string', 'null'], + help: 'Number of pixels to use when rounding the border', + }, + padding: { + types: ['string', 'null'], + help: 'Content distance in pixels from border', + }, + backgroundColor: { + types: ['string', 'null'], + help: 'Valid CSS background color string', + }, + backgroundImage: { + types: ['string', 'null'], + help: 'Valid CSS background image string', + }, + backgroundSize: { + types: ['string'], + help: 'Valid CSS background size string', + default: 'contain', + }, + backgroundRepeat: { + types: ['string'], + help: 'Valid CSS background repeat string', + default: 'no-repeat', + }, + opacity: { + types: ['number', 'null'], + help: 'A number between 0 and 1 representing the degree of transparency of the element', + }, + overflow: { + types: ['string'], + help: `Sets overflow of the container`, + }, + }, + fn: (context, args) => { + const { backgroundImage, backgroundSize, backgroundRepeat, ...remainingArgs } = args; + const style = { + type: 'containerStyle', + ...remainingArgs, + }; + + if (backgroundImage) { + if (!isValid(backgroundImage)) + throw new Error('Invalid backgroundImage. Please provide an asset or a URL.'); + style.backgroundImage = `url(${backgroundImage})`; + style.backgroundSize = backgroundSize; + style.backgroundRepeat = backgroundRepeat; + } + + // removes keys with undefined value + return JSON.parse(JSON.stringify(style)); + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/context.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/context.js new file mode 100644 index 0000000000000..20d03c578fd64 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/context.js @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const context = () => ({ + name: 'context', + help: + 'Returns whatever you pass into it. This can be useful when you need to use context as argument to a function as a sub-expression', + args: {}, + fn: context => { + return context; + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/csv.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/csv.js new file mode 100644 index 0000000000000..4a02b901786c8 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/csv.js @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Papa from 'papaparse'; + +export const csv = () => ({ + name: 'csv', + type: 'datatable', + context: { + types: ['null'], + }, + args: { + data: { + aliases: ['_'], + types: ['string'], + help: 'CSV data to use', + }, + delimiter: { + types: ['string'], + help: 'Data separation character', + }, + newline: { + types: ['string'], + help: 'Row separation character', + }, + }, + help: 'Create datatable from csv input', + fn(context, args) { + const { data: csvString, delimiter, newline } = args; + + const config = { + transform: val => { + if (val.indexOf('"') >= 0) { + const trimmed = val.trim(); + return trimmed.replace(/(^\"|\"$)/g, ''); + } + return val; + }, + }; + + if (delimiter != null) config.delimiter = delimiter; + if (newline != null) config.newline = newline; + + // TODO: handle errors, check output.errors + const output = Papa.parse(csvString, config); + + // output.data is an array of arrays, rows and values in each row + return output.data.reduce( + (acc, row, i) => { + if (i === 0) { + // first row, assume header values + row.forEach(colName => acc.columns.push({ name: colName.trim(), type: 'string' })); + } else { + // any other row is a data row + const rowObj = row.reduce((rowAcc, colValue, j) => { + const colName = acc.columns[j].name; + rowAcc[colName] = colValue; + return rowAcc; + }, {}); + + acc.rows.push(rowObj); + } + + return acc; + }, + { + type: 'datatable', + columns: [], + rows: [], + } + ); + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/date.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/date.js new file mode 100644 index 0000000000000..20fc9ca98f649 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/date.js @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import moment from 'moment'; + +const getInputDate = input => { + // return current date if no input + if (!input) return new Date(); + + // return the input + return input; +}; + +export const date = () => ({ + name: 'date', + type: 'number', + context: { + types: ['null'], + }, + help: 'Returns the current time, or a time parsed from a string, as milliseconds since epoch.', + args: { + value: { + aliases: ['_'], + types: ['string', 'null'], + help: + 'An optional date string to parse into milliseconds since epoch. ' + + 'Can be either a valid Javascript Date input or a string to parse using the format argument. Must be an ISO 8601 string or you must provide the format.', + }, + format: { + types: ['string'], + help: + 'The momentJS format for parsing the optional date string (See https://momentjs.com/docs/#/displaying/).', + }, + }, + fn: (context, args) => { + const { value: date, format } = args; + const useMoment = date && format; + const outputDate = useMoment ? moment.utc(date, format).toDate() : new Date(getInputDate(date)); + + if (isNaN(outputDate.getTime())) throw new Error(`Invalid date input: ${date}`); + + return outputDate.valueOf(); + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/do.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/do.js new file mode 100644 index 0000000000000..f67fbbc81434d --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/do.js @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const doFn = () => ({ + name: 'do', + help: + 'Runs multiple sub-expressions. Returns the passed in context. Nice for running actions producing functions.', + args: { + fn: { + aliases: ['_'], + multi: true, + help: + 'One or more sub-expressions. The value of these is not available in the root pipeline as this function simply returns the passed in context', + }, + }, + fn: context => context, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/dropdownControl.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/dropdownControl.js new file mode 100644 index 0000000000000..ebb94cde8fb68 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/dropdownControl.js @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { uniq } from 'lodash'; + +export const dropdownControl = () => ({ + name: 'dropdownControl', + aliases: [], + type: 'render', + context: { + types: ['datatable'], + }, + help: 'Configure a drop down filter control element', + args: { + filterColumn: { + type: ['string'], + help: 'The column or field to attach the filter to', + }, + valueColumn: { + type: ['string'], + help: 'The datatable column from which to extract the unique values for the drop down', + }, + }, + fn: (context, { valueColumn, filterColumn }) => { + let choices = []; + if (context.rows[0][valueColumn]) + choices = uniq(context.rows.map(row => row[valueColumn])).sort(); + + const column = filterColumn || valueColumn; + + return { + type: 'render', + as: 'dropdown_filter', + value: { + column, + choices, + }, + }; + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/eq.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/eq.js new file mode 100644 index 0000000000000..9aaf5d3ad9390 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/eq.js @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const eq = () => ({ + name: 'eq', + type: 'boolean', + help: 'Return if the context is equal to the argument', + args: { + value: { + aliases: ['_'], + types: ['boolean', 'number', 'string', 'null'], + required: true, + help: 'The value to compare the context to', + }, + }, + fn: (context, args) => { + return context === args.value; + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/exactly.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/exactly.js new file mode 100644 index 0000000000000..f4ccbd6623122 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/exactly.js @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const exactly = () => ({ + name: 'exactly', + aliases: [], + type: 'filter', + context: { + types: ['filter'], + }, + help: 'Create a filter that matches a given column for a perfectly exact value', + args: { + column: { + types: ['string'], + aliases: ['field', 'c'], + help: 'The column or field to attach the filter to', + }, + value: { + types: ['string'], + aliases: ['v', 'val'], + help: 'The value to match exactly, including white space and capitalization', + }, + }, + fn: (context, args) => { + const { value, column } = args; + + const filter = { + type: 'exactly', + value, + column, + }; + + return { ...context, and: [...context.and, filter] }; + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/filterrows.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/filterrows.js new file mode 100644 index 0000000000000..cfbf4c8a0fe5e --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/filterrows.js @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const filterrows = () => ({ + name: 'filterrows', + aliases: [], + type: 'datatable', + context: { + types: ['datatable'], + }, + help: 'Filter rows in a datatable based on the return value of a subexpression.', + args: { + fn: { + resolve: false, + aliases: ['_'], + types: ['boolean'], + help: + 'An expression to pass each rows in the datatable into. The expression should return a boolean. ' + + 'A true value will preserve the row, and a false value will remove it.', + }, + }, + fn(context, { fn }) { + const checks = context.rows.map(row => + fn({ + ...context, + rows: [row], + }) + ); + + return Promise.all(checks) + .then(results => context.rows.filter((row, i) => results[i])) + .then(rows => ({ + ...context, + rows, + })); + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/font.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/font.js new file mode 100644 index 0000000000000..ef78b7b9dd847 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/font.js @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import inlineStyle from 'inline-style'; +import { openSans } from '../../../common/lib/fonts'; + +export const font = () => ({ + name: 'font', + aliases: [], + type: 'style', + help: 'Create a font style', + context: { + types: ['null'], + }, + args: { + size: { + types: ['number'], + help: 'Font size (px)', + default: 14, + }, + lHeight: { + types: ['number'], + aliases: ['lineHeight'], + help: 'Line height (px)', + }, + family: { + types: ['string'], + default: `"${openSans.value}"`, + help: 'An acceptable CSS web font string', + }, + color: { + types: ['string', 'null'], + help: 'Text color', + }, + weight: { + types: ['string'], + help: + 'Set the font weight, e.g. normal, bold, bolder, lighter, 100, 200, 300, 400, 500, 600, 700, 800, 900', + default: 'normal', + }, + underline: { + types: ['boolean'], + default: false, + help: 'Underline the text, true or false', + }, + italic: { + types: ['boolean'], + default: false, + help: 'Italicize, true or false', + }, + align: { + types: ['string'], + help: 'Horizontal text alignment', + default: 'left', + }, + }, + fn: (context, args) => { + const weights = [ + 'normal', + 'bold', + 'bolder', + 'lighter', + '100', + '200', + '300', + '400', + '500', + '600', + '700', + '800', + '900', + ]; + const alignments = ['center', 'left', 'right', 'justified']; + + if (!weights.includes(args.weight)) throw new Error(`Invalid font weight: ${args.weight}`); + if (!alignments.includes(args.align)) throw new Error(`Invalid text alignment: ${args.align}`); + + // the line height shouldn't ever be lower than the size + const lineHeight = args.lHeight ? `${args.lHeight}px` : 1; + + const spec = { + fontFamily: args.family, + fontWeight: args.weight, + fontStyle: args.italic ? 'italic' : 'normal', + textDecoration: args.underline ? 'underline' : 'none', + textAlign: args.align, + fontSize: `${args.size}px`, // apply font size as a pixel setting + lineHeight: lineHeight, // apply line height as a pixel setting + }; + + // conditionally apply styles based on input + if (args.color) spec.color = args.color; + + return { + type: 'style', + spec, + css: inlineStyle(spec), + }; + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/formatdate.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/formatdate.js new file mode 100644 index 0000000000000..489317928e035 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/formatdate.js @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import moment from 'moment'; +export const formatdate = () => ({ + name: 'formatdate', + type: 'string', + help: 'Output a ms since epoch number as a formatted string', + context: { + types: ['number'], + }, + args: { + format: { + aliases: ['_'], + types: ['string'], + help: 'MomentJS Format with which to bucket (See https://momentjs.com/docs/#/displaying/)', + }, + }, + fn: (context, args) => { + if (!args.format) return moment.utc(new Date(context)).toISOString(); + return moment.utc(new Date(context)).format(args.format); + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/formatnumber.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/formatnumber.js new file mode 100644 index 0000000000000..ff3cd5f243d46 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/formatnumber.js @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import numeral from '@elastic/numeral'; + +export const formatnumber = () => ({ + name: 'formatnumber', + type: 'string', + help: 'Turn a number into a string using a NumberJS format', + context: { + types: ['number'], + }, + args: { + format: { + aliases: ['_'], + types: ['string'], + help: 'NumeralJS format string http://numeraljs.com/#format', + }, + }, + fn: (context, args) => { + if (!args.format) return String(context); + return numeral(context).format(args.format); + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/getCell.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/getCell.js new file mode 100644 index 0000000000000..625db0a434a4d --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/getCell.js @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const getCell = () => ({ + name: 'getCell', + help: 'Fetch a single cell in a table', + context: { + types: ['datatable'], + }, + args: { + column: { + types: ['string'], + aliases: ['_', 'c'], + help: 'The name of the column value to fetch', + }, + row: { + types: ['number'], + aliases: ['r'], + help: 'The row number, starting at 0', + default: 0, + }, + }, + fn: (context, args) => { + const row = context.rows[args.row]; + if (!row) throw new Error(`Row not found: ${args.row}`); + + const { column = context.columns[0].name } = args; + const value = row[column]; + + if (typeof value === 'undefined') throw new Error(`Column not found: ${column}`); + + return value; + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/gt.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/gt.js new file mode 100644 index 0000000000000..ccde455f20cba --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/gt.js @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const gt = () => ({ + name: 'gt', + type: 'boolean', + help: 'Return if the context is greater than the argument', + args: { + value: { + aliases: ['_'], + types: ['boolean', 'number', 'string', 'null'], + required: true, + help: 'The value to compare the context to', + }, + }, + fn: (context, args) => { + if (typeof context !== typeof args.value) return false; + return context > args.value; + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/gte.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/gte.js new file mode 100644 index 0000000000000..691deae146c05 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/gte.js @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const gte = () => ({ + name: 'gte', + type: 'boolean', + help: 'Return if the context is greater than or equal to the argument', + args: { + value: { + aliases: ['_'], + types: ['boolean', 'number', 'string', 'null'], + required: true, + help: 'The value to compare the context to', + }, + }, + fn: (context, args) => { + if (typeof context !== typeof args.value) return false; + return context >= args.value; + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/head.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/head.js new file mode 100644 index 0000000000000..b301ab3cf1aa2 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/head.js @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { take } from 'lodash'; + +export const head = () => ({ + name: 'head', + aliases: [], + type: 'datatable', + help: 'Get the first N rows from the datatable. Also see `tail`', + context: { + types: ['datatable'], + }, + args: { + count: { + aliases: ['_'], + types: ['number'], + help: 'Return this many rows from the beginning of the datatable', + default: 1, + }, + }, + fn: (context, args) => ({ + ...context, + rows: take(context.rows, args.count), + }), +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/if.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/if.js new file mode 100644 index 0000000000000..fa54a14b0998a --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/if.js @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const ifFn = () => ({ + name: 'if', + help: 'Perform conditional logic', + args: { + condition: { + types: ['boolean', 'null'], + aliases: ['_'], + help: + 'A boolean true or false, usually returned by a subexpression. If this is not supplied then the input context will be used', + }, + then: { + resolve: false, + help: 'The return value if true', + }, + else: { + resolve: false, + help: + 'The return value if false. If else is not specified, and the condition is false' + + 'then the input context to the function will be returned', + }, + }, + fn: async (context, args) => { + if (args.condition) { + if (typeof args.then === 'undefined') return context; + return await args.then(); + } else { + if (typeof args.else === 'undefined') return context; + return await args.else(); + } + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/image.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/image.js new file mode 100644 index 0000000000000..55af5f60ef97d --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/image.js @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { includes } from 'lodash'; +import { resolveWithMissingImage } from '../../../common/lib/resolve_dataurl'; +import { elasticLogo } from '../../lib/elastic_logo'; + +export const image = () => ({ + name: 'image', + aliases: [], + type: 'image', + help: 'Display an image', + context: { + types: ['null'], + }, + args: { + dataurl: { + // This was accepting dataurl, but there was no facility in fn for checking type and handling a dataurl type. + types: ['string', 'null'], + help: 'The HTTP(S) URL or base64 data of an image.', + aliases: ['_', 'url'], + default: elasticLogo, + }, + mode: { + types: ['string', 'null'], + help: + '"contain" will show the entire image, scaled to fit.' + + '"cover" will fill the container with the image, cropping from the sides or bottom as needed.' + + '"stretch" will resize the height and width of the image to 100% of the container', + default: 'contain', + }, + }, + fn: (context, { dataurl, mode }) => { + if (!includes(['contain', 'cover', 'stretch'], mode)) + throw '"mode" must be "contain", "cover", or "stretch"'; + + const modeStyle = mode === 'stretch' ? '100% 100%' : mode; + + return { + type: 'image', + mode: modeStyle, + dataurl: resolveWithMissingImage(dataurl, elasticLogo), + }; + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/index.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/index.js new file mode 100644 index 0000000000000..afc9ba665c09b --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/index.js @@ -0,0 +1,115 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { alterColumn } from './alterColumn'; +import { all } from './all'; +import { any } from './any'; +import { asFn } from './as'; +import { axisConfig } from './axisConfig'; +import { compare } from './compare'; +import { containerStyle } from './containerStyle'; +import { clog } from './clog'; +import { context } from './context'; +import { columns } from './columns'; +import { csv } from './csv'; +import { date } from './date'; +import { doFn } from './do'; +import { dropdownControl } from './dropdownControl'; +import { eq } from './eq'; +import { exactly } from './exactly'; +import { filterrows } from './filterrows'; +import { font } from './font'; +import { formatdate } from './formatdate'; +import { formatnumber } from './formatnumber'; +import { getCell } from './getCell'; +import { gt } from './gt'; +import { gte } from './gte'; +import { head } from './head'; +import { ifFn } from './if'; +import { image } from './image'; +import { lt } from './lt'; +import { lte } from './lte'; +import { mapColumn } from './mapColumn'; +import { math } from './math'; +import { metric } from './metric'; +import { neq } from './neq'; +import { palette } from './palette'; +import { pie } from './pie'; +import { plot } from './plot'; +import { ply } from './ply'; +import { render } from './render'; +import { replace } from './replace'; +import { rounddate } from './rounddate'; +import { rowCount } from './rowCount'; +import { repeatImage } from './repeatImage'; +import { revealImage } from './revealImage'; +import { seriesStyle } from './seriesStyle'; +import { shape } from './shape'; +import { sort } from './sort'; +import { staticColumn } from './staticColumn'; +import { string } from './string'; +import { table } from './table'; +import { tail } from './tail'; +import { timefilter } from './timefilter'; +import { timefilterControl } from './timefilterControl'; +import { switchFn } from './switch'; +import { caseFn } from './case'; + +export const functions = [ + all, + alterColumn, + any, + asFn, + axisConfig, + clog, + columns, + compare, + containerStyle, + context, + csv, + date, + doFn, + dropdownControl, + eq, + exactly, + filterrows, + font, + formatdate, + formatnumber, + getCell, + gt, + gte, + head, + ifFn, + image, + lt, + lte, + mapColumn, + math, + metric, + neq, + palette, + pie, + plot, + ply, + render, + repeatImage, + replace, + revealImage, + rounddate, + rowCount, + seriesStyle, + shape, + sort, + staticColumn, + string, + table, + tail, + timefilter, + timefilterControl, + switchFn, + caseFn, +]; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/lt.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/lt.js new file mode 100644 index 0000000000000..f2d90639fc995 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/lt.js @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const lt = () => ({ + name: 'lt', + type: 'boolean', + help: 'Return if the context is less than the argument', + args: { + value: { + aliases: ['_'], + types: ['boolean', 'number', 'string', 'null'], + required: true, + help: 'The value to compare the context to', + }, + }, + fn: (context, args) => { + if (typeof context !== typeof args.value) return false; + return context < args.value; + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/lte.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/lte.js new file mode 100644 index 0000000000000..ed9a413a71ede --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/lte.js @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const lte = () => ({ + name: 'lte', + type: 'boolean', + help: 'Return if the context is less than or equal to the argument', + args: { + value: { + aliases: ['_'], + types: ['boolean', 'number', 'string', 'null'], + required: true, + help: 'The value to compare the context to', + }, + }, + fn: (context, args) => { + if (typeof context !== typeof args.value) return false; + return context <= args.value; + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/mapColumn.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/mapColumn.js new file mode 100644 index 0000000000000..db55780205296 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/mapColumn.js @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getType } from '../../../common/lib/get_type'; + +export const mapColumn = () => ({ + name: 'mapColumn', + aliases: ['mc'], // midnight commander. So many times I've launched midnight commander instead of moving a file. + type: 'datatable', + help: 'Add a column calculated as the result of other columns, or not', + context: { + types: ['datatable'], + }, + args: { + name: { + types: ['string'], + aliases: ['_', 'column'], + help: 'The name of the resulting column', + required: true, + }, + expression: { + types: ['boolean', 'number', 'string', 'null'], + resolve: false, + aliases: ['exp', 'fn'], + help: 'A canvas expression which will be passed each row as a single row datatable', + }, + }, + fn: (context, args) => { + args.expression = args.expression || (() => Promise.resolve(null)); + + const columns = [...context.columns]; + const rowPromises = context.rows.map(row => { + return args + .expression({ + type: 'datatable', + columns, + rows: [row], + }) + .then(val => ({ + ...row, + [args.name]: val, + })); + }); + + return Promise.all(rowPromises).then(rows => { + const existingColumnIndex = columns.findIndex(({ name }) => name === args.name); + const type = getType(rows[0][args.name]); + const newColumn = { name: args.name, type }; + if (existingColumnIndex === -1) columns.push(newColumn); + else columns[existingColumnIndex] = newColumn; + + return { + type: 'datatable', + columns, + rows, + }; + }); + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/math.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/math.js new file mode 100644 index 0000000000000..190ad482153fa --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/math.js @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { evaluate } from 'tinymath'; +import { pivotObjectArray } from '../../../common/lib/pivot_object_array'; + +export const math = () => ({ + name: 'math', + type: 'number', + help: + 'Interpret a math expression, with a number or datatable as context. Datatable columns are available by their column name. ' + + 'If you pass in a number it is available as "value" (without the quotes)', + context: { + types: ['number', 'datatable'], + }, + args: { + expression: { + aliases: ['_'], + types: ['string'], + help: + 'An evaluated TinyMath expression. (See [TinyMath Functions](http://canvas.elastic.co/reference/tinymath.html))', + }, + }, + fn: (context, args) => { + if (!args.expression || args.expression.trim() === '') throw new Error('Empty expression'); + + const isDatatable = context && context.type === 'datatable'; + const mathContext = isDatatable + ? pivotObjectArray(context.rows, context.columns.map(col => col.name)) + : { value: context }; + try { + const result = evaluate(args.expression, mathContext); + if (Array.isArray(result)) { + if (result.length === 1) return result[0]; + throw new Error( + 'Expressions must return a single number. Try wrapping your expression in mean() or sum()' + ); + } + if (isNaN(result)) + throw new Error('Failed to execute math expression. Check your column names'); + return result; + } catch (e) { + if (context.rows.length === 0) throw new Error('Empty datatable'); + else throw e; + } + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/metric.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/metric.js new file mode 100644 index 0000000000000..c34ea83f1afb5 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/metric.js @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { openSans } from '../../../common/lib/fonts'; +export const metric = () => ({ + name: 'metric', + aliases: [], + type: 'render', + help: 'A number with a label', + context: { + types: ['string', 'null'], + }, + args: { + label: { + types: ['string'], + alias: ['_', 'text', 'description'], + help: 'Text describing the metric', + default: '""', + }, + metricFont: { + types: ['style'], + help: 'Font settings for the metric. Technically you can stick other styles in here too!', + default: `{font size=48 family="${openSans.value}" color="#000000" align=center lHeight=48}`, + }, + labelFont: { + types: ['style'], + help: 'Font settings for the label. Technically you can stick other styles in here too!', + default: `{font size=14 family="${openSans.value}" color="#000000" align=center}`, + }, + }, + fn: (context, { label, metricFont, labelFont }) => { + return { + type: 'render', + as: 'metric', + value: { + metric: context === null ? '?' : context, + label, + metricFont, + labelFont, + }, + }; + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/neq.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/neq.js new file mode 100644 index 0000000000000..85b060ce882aa --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/neq.js @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const neq = () => ({ + name: 'neq', + type: 'boolean', + help: 'Return if the context is not equal to the argument', + args: { + value: { + aliases: ['_'], + types: ['boolean', 'number', 'string', 'null'], + required: true, + help: 'The value to compare the context to', + }, + }, + fn: (context, args) => { + return context !== args.value; + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/palette.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/palette.js new file mode 100644 index 0000000000000..7a601f71b2229 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/palette.js @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { palettes } from '../../../common/lib/palettes'; +export const palette = () => ({ + name: 'palette', + aliases: [], + type: 'palette', + help: 'Create a color palette', + context: { + types: ['null'], + }, + args: { + color: { + aliases: ['_'], + multi: true, + types: ['string'], + help: 'Palette colors, rgba, hex, or HTML color string. Pass this multiple times.', + }, + gradient: { + types: ['boolean'], + default: false, + help: 'Prefer to make a gradient where supported and useful?', + }, + reverse: { + type: ['boolean'], + default: false, + help: 'Reverse the palette', + }, + }, + fn: (context, args) => { + const colors = [].concat(args.color || palettes.paul_tor_14.colors); + return { + type: 'palette', + colors: args.reverse ? colors.reverse() : colors, + gradient: args.gradient, + }; + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/pie.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/pie.js new file mode 100644 index 0000000000000..fbdd2e9dde210 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/pie.js @@ -0,0 +1,121 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import keyBy from 'lodash.keyby'; +import { get, map, groupBy, sortBy } from 'lodash'; +import { getColorsFromPalette } from '../../../common/lib/get_colors_from_palette'; +import { getLegendConfig } from '../../../common/lib/get_legend_config'; + +export const pie = () => ({ + name: 'pie', + aliases: [], + type: 'render', + help: 'Configure a pie chart element', + context: { + types: ['pointseries'], + }, + args: { + palette: { + types: ['palette', 'null'], + help: 'A palette object for describing the colors to use on this pie', + default: '{palette}', + }, + seriesStyle: { + multi: true, + types: ['seriesStyle', 'null'], + help: 'A style of a specific series', + }, + radius: { + type: ['string', 'number'], + help: `Radius of the pie as a percentage (between 0 and 1) of the available space. Set to 'auto' to automatically set radius`, + default: 'auto', + }, + hole: { + types: ['number'], + default: 0, + help: 'Draw a hole in the pie, 0-100, as a percentage of the pie radius', + }, + labels: { + types: ['boolean'], + default: true, + help: 'Show pie labels', + }, + labelRadius: { + types: ['number'], + default: 100, + help: 'Percentage of area of container to use as radius for the label circle', + }, + font: { + types: ['style'], + help: 'Label font', + default: '{font}', + }, + legend: { + types: ['string', 'boolean'], + help: 'Legend position, nw, sw, ne, se or false', + default: false, + }, + tilt: { + types: ['number'], + default: 1, + help: 'Percentage of tilt where 1 is fully vertical and 0 is completely flat', + }, + }, + fn: (context, args) => { + const rows = sortBy(context.rows, ['color', 'size']); + const seriesStyles = keyBy(args.seriesStyle || [], 'label') || {}; + + const data = map(groupBy(rows, 'color'), (series, label) => { + const item = { + label: label, + data: series.map(point => point.size || 1), + }; + + const seriesStyle = seriesStyles[label]; + + // append series style, if there is a match + if (seriesStyle) item.color = get(seriesStyle, 'color'); + + return item; + }); + + return { + type: 'render', + as: 'pie', + value: { + font: args.font, + data: sortBy(data, 'label'), + options: { + canvas: false, + colors: getColorsFromPalette(args.palette, data.length), + legend: getLegendConfig(args.legend, data.length), + grid: { + show: false, + }, + series: { + pie: { + show: true, + innerRadius: Math.max(args.hole, 0) / 100, + stroke: { + width: 0, + }, + label: { + show: args.labels, + radius: (args.labelRadius >= 0 ? args.labelRadius : 100) / 100, + }, + tilt: args.tilt, + radius: args.radius, + }, + bubbles: { + show: false, + }, + shadowSize: 0, + }, + }, + }, + }; + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/plot/get_flot_axis_config.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/plot/get_flot_axis_config.js new file mode 100644 index 0000000000000..1a8ee7daf7370 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/plot/get_flot_axis_config.js @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get, map } from 'lodash'; +import { getType } from '../../../../common/lib/get_type'; + +export const getFlotAxisConfig = (axis, argValue, { columns, ticks, font } = {}) => { + if (!argValue || argValue.show === false) return { show: false }; + + const config = { show: true }; + const axisType = get(columns, `${axis}.type`); + + if (getType(argValue) === 'axisConfig') { + const { position, min, max, tickSize } = argValue; + // first value is used as the default + const acceptedPositions = axis === 'x' ? ['bottom', 'top'] : ['left', 'right']; + + config.position = acceptedPositions.includes(position) ? position : acceptedPositions[0]; + + if (axisType === 'number' || axisType === 'date') { + if (min) config.min = min; + if (max) config.max = max; + } + + if (tickSize && axisType === 'number') config.tickSize = tickSize; + } + + if (axisType === 'string') + config.ticks = map(ticks[axis].hash, (position, name) => [position, name]); + + if (axisType === 'date') config.mode = 'time'; + + if (typeof font === 'object') config.font = font; + + return config; +}; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/plot/get_font_spec.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/plot/get_font_spec.js new file mode 100644 index 0000000000000..1d9242833b646 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/plot/get_font_spec.js @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { openSans } from '../../../../common/lib/fonts'; +// converts the output of the font function to a flot font spec +// for font spec, see https://github.com/flot/flot/blob/master/API.md#customizing-the-axes +export const defaultSpec = { + size: 14, + lHeight: 21, + style: 'normal', + weight: 'normal', + family: openSans.value, + color: '#000', +}; + +export const getFontSpec = argFont => { + if (!argFont || !argFont.spec) return defaultSpec; + + const { fontSize, lineHeight, fontStyle, fontWeight, fontFamily, color } = argFont.spec; + const size = fontSize && Number(fontSize.replace('px', '')); + const lHeight = typeof lineHeight === 'string' && Number(lineHeight.replace('px', '')); + + return { + size: !isNaN(size) ? size : defaultSpec.size, + lHeight: !isNaN(size) ? lHeight : defaultSpec.lHeight, + style: fontStyle || defaultSpec.style, + weight: fontWeight || defaultSpec.weight, + family: fontFamily || defaultSpec.family, + color: color || defaultSpec.color, + }; +}; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/plot/get_tick_hash.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/plot/get_tick_hash.js new file mode 100644 index 0000000000000..6ca670b881303 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/plot/get_tick_hash.js @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get, sortBy } from 'lodash'; + +export const getTickHash = (columns, rows) => { + const ticks = { + x: { + hash: {}, + counter: 0, + }, + y: { + hash: {}, + counter: 0, + }, + }; + + if (get(columns, 'x.type') === 'string') { + sortBy(rows, ['x']).forEach(row => { + if (!ticks.x.hash[row.x]) ticks.x.hash[row.x] = ticks.x.counter++; + }); + } + + if (get(columns, 'y.type') === 'string') { + sortBy(rows, ['y']) + .reverse() + .forEach(row => { + if (!ticks.y.hash[row.y]) ticks.y.hash[row.y] = ticks.y.counter++; + }); + } + + return ticks; +}; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/plot/index.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/plot/index.js new file mode 100644 index 0000000000000..2cf996f23cef0 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/plot/index.js @@ -0,0 +1,145 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import keyBy from 'lodash.keyby'; +import { groupBy, get, set, map, sortBy } from 'lodash'; +import { getColorsFromPalette } from '../../../../common/lib/get_colors_from_palette'; +import { getLegendConfig } from '../../../../common/lib/get_legend_config'; +import { getFlotAxisConfig } from './get_flot_axis_config'; +import { getFontSpec } from './get_font_spec'; +import { seriesStyleToFlot } from './series_style_to_flot'; +import { getTickHash } from './get_tick_hash'; + +export const plot = () => ({ + name: 'plot', + aliases: [], + type: 'render', + help: 'Configure a plot element', + context: { + types: ['pointseries'], + }, + args: { + seriesStyle: { + multi: true, + types: ['seriesStyle', 'null'], + help: 'A style of a specific series', + }, + defaultStyle: { + multi: false, + types: ['seriesStyle'], + help: 'The default style to use for every series', + default: '{seriesStyle points=5}', + }, + palette: { + types: ['palette'], + help: 'A palette object for describing the colors to use on this plot', + default: '{palette}', + }, + font: { + types: ['style'], + help: 'Legend and tick mark fonts', + default: '{font}', + }, + legend: { + types: ['string', 'boolean'], + help: 'Legend position, nw, sw, ne, se or false', + default: 'ne', + }, + yaxis: { + types: ['boolean', 'axisConfig'], + help: 'Axis configuration, or false to disable', + default: true, + }, + xaxis: { + types: ['boolean', 'axisConfig'], + help: 'Axis configuration, or false to disable', + default: true, + }, + }, + fn: (context, args) => { + const seriesStyles = keyBy(args.seriesStyle || [], 'label') || {}; + const sortedRows = sortBy(context.rows, ['x', 'y', 'color', 'size', 'text']); + const ticks = getTickHash(context.columns, sortedRows); + const font = args.font ? getFontSpec(args.font) : {}; + + const data = map(groupBy(sortedRows, 'color'), (series, label) => { + const seriesStyle = { + ...args.defaultStyle, + ...seriesStyles[label], + }; + const flotStyle = seriesStyle ? seriesStyleToFlot(seriesStyle) : {}; + + return { + ...flotStyle, + label: label, + data: series.map(point => { + const attrs = {}; + const x = get(context.columns, 'x.type') === 'string' ? ticks.x.hash[point.x] : point.x; + const y = get(context.columns, 'y.type') === 'string' ? ticks.y.hash[point.y] : point.y; + + if (point.size != null) { + attrs.size = point.size; + } else if (get(seriesStyle, 'points')) { + attrs.size = seriesStyle.points; + set(flotStyle, 'bubbles.size.min', seriesStyle.points); + } + + if (point.text != null) attrs.text = point.text; + + return [x, y, attrs]; + }), + }; + }); + + const gridConfig = { + borderWidth: 0, + borderColor: null, + color: 'rgba(0,0,0,0)', + labelMargin: 30, + margin: { + right: 30, + top: 20, + bottom: 0, + left: 0, + }, + }; + + const result = { + type: 'render', + as: 'plot', + value: { + font: args.font, + data: sortBy(data, 'label'), + options: { + canvas: false, + colors: getColorsFromPalette(args.palette, data.length), + legend: getLegendConfig(args.legend, data.length), + grid: gridConfig, + xaxis: getFlotAxisConfig('x', args.xaxis, { + columns: context.columns, + ticks, + font, + }), + yaxis: getFlotAxisConfig('y', args.yaxis, { + columns: context.columns, + ticks, + font, + }), + series: { + shadowSize: 0, + ...seriesStyleToFlot(args.defaultStyle), + }, + }, + }, + }; + + // fix the issue of plot sometimes re-rendering with an empty chart + // TODO: holy hell, why does this work?! the working theory is that some values become undefined + // and serializing the result here causes them to be dropped off, and this makes flot react differently. + // It's also possible that something else ends up mutating this object, but that seems less likely. + return JSON.parse(JSON.stringify(result)); + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/plot/series_style_to_flot.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/plot/series_style_to_flot.js new file mode 100644 index 0000000000000..644487e4ac71e --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/plot/series_style_to_flot.js @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get } from 'lodash'; + +export const seriesStyleToFlot = seriesStyle => { + if (!seriesStyle) return {}; + + const lines = get(seriesStyle, 'lines'); + const bars = get(seriesStyle, 'bars'); + const fill = get(seriesStyle, 'fill'); + const color = get(seriesStyle, 'color'); + const stack = get(seriesStyle, 'stack'); + const horizontal = get(seriesStyle, 'horizontalBars', false); + + const flotStyle = { + numbers: { + show: true, + }, + lines: { + show: lines > 0, + lineWidth: lines, + fillColor: color, + fill: fill / 10, + }, + bars: { + show: bars > 0, + barWidth: bars, + fill: 1, + align: 'center', + horizontal, + }, + // This is here intentionally even though it is the default. + // We use the `size` plugins for this and if the user says they want points + // we just set the size to be static. + points: { show: false }, + bubbles: { + fill: fill, + }, + }; + + if (stack) flotStyle.stack = stack; + if (color) flotStyle.color = color; + + return flotStyle; +}; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/ply.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/ply.js new file mode 100644 index 0000000000000..f3f167275949f --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/ply.js @@ -0,0 +1,133 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { groupBy, flatten, pick, map } from 'lodash'; + +function combineColumns(arrayOfColumnsArrays) { + return arrayOfColumnsArrays.reduce((resultingColumns, columns) => { + if (columns) { + columns.forEach(column => { + if (resultingColumns.find(resultingColumn => resultingColumn.name === column.name)) return; + else resultingColumns.push(column); + }); + } + + return resultingColumns; + }, []); +} + +// This handles merging the tables produced by multiple expressions run on a single member of the `by` split. +// Thus all tables must be the same length, although their columns do not need to be the same, we will handle combining the columns +function combineAcross(datatableArray) { + const [referenceTable] = datatableArray; + const targetRowLength = referenceTable.rows.length; + + // Sanity check + datatableArray.forEach(datatable => { + if (datatable.rows.length !== targetRowLength) + throw new Error('All expressions must return the same number of rows'); + }); + + // Merge columns and rows. + const arrayOfRowsArrays = map(datatableArray, 'rows'); + const rows = []; + for (let i = 0; i < targetRowLength; i++) { + const rowsAcross = map(arrayOfRowsArrays, i); + + // The reason for the Object.assign is that rowsAcross is an array + // and those rows need to be applied as arguments to Object.assign + rows.push(Object.assign({}, ...rowsAcross)); + } + + const columns = combineColumns(map(datatableArray, 'columns')); + + return { + type: 'datatable', + rows, + columns, + }; +} + +export const ply = () => ({ + name: 'ply', + type: 'datatable', + help: + 'Subdivide a datatable and pass the resulting tables into an expression, then merge the output', + context: { + types: ['datatable'], + }, + args: { + by: { + types: ['string'], + help: 'The column to subdivide on', + multi: true, + }, + expression: { + types: ['datatable'], + resolve: false, + multi: true, + aliases: ['fn', 'function'], + help: + 'An expression to pass each resulting data table into. Tips: \n' + + ' Expressions must return a datatable. Use `as` to turn literals into datatables.\n' + + ' Multiple expressions must return the same number of rows.' + + ' If you need to return a differing row count, pipe into another instance of ply.\n' + + ' If multiple expressions return the same columns, the last one wins.', + }, + // In the future it may make sense to add things like shape, or tooltip values, but I think what we have is good for now + // The way the function below is written you can add as many arbitrary named args as you want. + }, + fn: (context, args) => { + if (!args) return context; + let byColumns; + let originalDatatables; + + if (args.by) { + byColumns = args.by.map(by => { + const column = context.columns.find(column => column.name === by); + if (!column) throw new Error(`No such column: ${by}`); + return column; + }); + const keyedDatatables = groupBy(context.rows, row => JSON.stringify(pick(row, args.by))); + originalDatatables = Object.values(keyedDatatables).map(rows => ({ + ...context, + rows, + })); + } else { + originalDatatables = [context]; + } + + const datatablePromises = originalDatatables.map(originalDatatable => { + let expressionResultPromises = []; + + if (args.expression) + expressionResultPromises = args.expression.map(expression => expression(originalDatatable)); + else expressionResultPromises.push(Promise.resolve(originalDatatable)); + + return Promise.all(expressionResultPromises).then(combineAcross); + }); + + return Promise.all(datatablePromises).then(newDatatables => { + // Here we're just merging each for the by splits, so it doesn't actually matter if the rows are the same length + const columns = combineColumns([byColumns].concat(map(newDatatables, 'columns'))); + const rows = flatten( + newDatatables.map((dt, i) => { + const byColumnValues = pick(originalDatatables[i].rows[0], args.by); + return dt.rows.map(row => ({ + ...byColumnValues, + ...row, + })); + }) + ); + + return { + type: 'datatable', + rows, + columns, + }; + }); + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/register.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/register.js new file mode 100644 index 0000000000000..f4e7fa4b467b5 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/register.js @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { functions } from './index'; + +functions.forEach(canvas.register); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/render.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/render.js new file mode 100644 index 0000000000000..716ca8abc340d --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/render.js @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const render = () => ({ + name: 'render', + aliases: [], + type: 'render', + help: 'Render an input as a specific element and set element level options such as styling', + context: { + types: ['render'], + }, + args: { + as: { + types: ['string', 'null'], + help: + 'The element type to use in rendering. You probably want a specialized function instead, such as plot or grid', + }, + css: { + types: ['string', 'null'], + default: '"* > * {}"', + help: 'Any block of custom CSS to be scoped to this element.', + }, + containerStyle: { + types: ['containerStyle', 'null'], + help: 'Style for the container, including background, border, and opacity', + }, + }, + fn: (context, args) => { + return { + ...context, + as: args.as || context.as, + css: args.css, + containerStyle: args.containerStyle, + }; + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/repeatImage.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/repeatImage.js new file mode 100644 index 0000000000000..403efd4692643 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/repeatImage.js @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { resolveWithMissingImage } from '../../../common/lib/resolve_dataurl'; +import { elasticOutline } from '../../lib/elastic_outline'; + +export const repeatImage = () => ({ + name: 'repeatImage', + aliases: [], + type: 'render', + help: 'Configure a repeating image element', + context: { + types: ['number'], + }, + args: { + image: { + types: ['string', 'null'], + help: 'The image to repeat. Usually a dataURL or an asset', + default: elasticOutline, + }, + size: { + types: ['number'], + default: 100, + help: + 'The maximum height or width of the image, in pixels. Eg, if you images is taller than it is wide, this will limit its height', + }, + max: { + types: ['number', 'null'], + help: 'Maximum number of times the image may repeat', + default: 1000, + }, + emptyImage: { + types: ['string', 'null'], + help: 'Fill the difference between the input and the `max=` parameter with this image', + default: null, + }, + }, + fn: (count, args) => { + return { + type: 'render', + as: 'repeatImage', + value: { + count: Math.floor(count), + ...args, + image: resolveWithMissingImage(args.image, elasticOutline), + emptyImage: resolveWithMissingImage(args.emptyImage), + }, + }; + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/replace.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/replace.js new file mode 100644 index 0000000000000..fb9d3462c8b19 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/replace.js @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const replace = () => ({ + name: 'replace', + type: 'string', + help: 'Use a regular expression to replace parts of a string', + context: { + types: ['string'], + }, + args: { + pattern: { + aliases: ['_', 'regex'], + types: ['string'], + help: + 'The text or pattern of a JavaScript regular expression, eg "[aeiou]". You can use capture groups here.', + }, + flags: { + aliases: ['modifiers'], + types: ['string'], + help: + 'Specify flags. See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp for reference.', + default: 'g', + }, + replacement: { + types: ['string'], + help: + 'The replacement for the matching parts of string. Capture groups can be accessed by their index, eg $1', + default: '""', + }, + }, + fn: (context, args) => context.replace(new RegExp(args.pattern, args.flags), args.replacement), +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/revealImage.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/revealImage.js new file mode 100644 index 0000000000000..8c9e4f2af5914 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/revealImage.js @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { resolveWithMissingImage } from '../../../common/lib/resolve_dataurl'; +import { elasticOutline } from '../../lib/elastic_outline'; + +export const revealImage = () => ({ + name: 'revealImage', + aliases: [], + type: 'render', + help: 'Configure a image reveal element', + context: { + types: ['number'], + }, + args: { + image: { + types: ['string', 'null'], + help: 'The image to reveal', + default: elasticOutline, + }, + emptyImage: { + types: ['string', 'null'], + help: 'An optional background image to reveal over', + default: null, + }, + origin: { + types: ['string'], + help: 'Where to start from. Eg, top, left, bottom or right', + default: 'bottom', + }, + }, + fn: (percent, args) => { + if (percent > 1 || percent < 0) throw new Error('input must be between 0 and 1'); + + return { + type: 'render', + as: 'revealImage', + value: { + percent, + ...args, + image: resolveWithMissingImage(args.image, elasticOutline), + emptyImage: resolveWithMissingImage(args.emptyImage), + }, + }; + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/rounddate.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/rounddate.js new file mode 100644 index 0000000000000..1eeccd1b19432 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/rounddate.js @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import moment from 'moment'; + +export const rounddate = () => ({ + name: 'rounddate', + type: 'number', + help: 'Round ms since epoch using a moment formatting string. Returns ms since epoch', + context: { + types: ['number'], + }, + args: { + format: { + aliases: ['_'], + types: ['string'], + help: + 'MomentJS Format with which to bucket (See https://momentjs.com/docs/#/displaying/). For example "YYYY-MM" would round to the month', + }, + }, + fn: (context, args) => { + if (!args.format) return context; + return moment.utc(moment.utc(context).format(args.format), args.format).valueOf(); + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/rowCount.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/rowCount.js new file mode 100644 index 0000000000000..389c036462834 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/rowCount.js @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const rowCount = () => ({ + name: 'rowCount', + aliases: [], + type: 'number', + context: { + types: ['datatable'], + }, + help: + 'Return the number of rows. Pair with ply to get the count of unique column values, or combinations of unique column values.', + args: {}, + fn: context => context.rows.length, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/seriesStyle.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/seriesStyle.js new file mode 100644 index 0000000000000..5af8d345df4a1 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/seriesStyle.js @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +const name = 'seriesStyle'; + +export const seriesStyle = () => ({ + name, + help: + 'Creates an object used for describing the properties of a series on a chart.' + + ' You would usually use this inside of a charting function', + context: { + types: ['null'], + }, + args: { + label: { + types: ['string'], + displayName: 'Series Label', + help: + 'The label of the line this style applies to, not the name you would like to give the line.', + }, + color: { + types: ['string', 'null'], + displayName: 'Color', + help: 'Color to assign the line', + }, + lines: { + types: ['number'], + displayName: 'Line width', + help: 'Width of the line', + }, + bars: { + types: ['number'], + displayName: 'Bar Width', + help: 'Width of bars', + }, + points: { + types: ['number'], + displayName: 'Show Points', + help: 'Size of points on line', + }, + fill: { + types: ['number', 'boolean'], + displayName: 'Fill points', + help: 'Should we fill points?', + }, + stack: { + types: ['number', 'null'], + displayName: 'Stack Series', + help: + 'Should we stack the series? This is the stack "id". Series with the same stack id will be stacked together', + }, + horizontalBars: { + types: ['boolean'], + displayName: 'Horizontal Bars Orientation', + help: 'Sets the orientation of bars in the chart to horizontal', + }, + }, + fn: (context, args) => ({ type: name, ...args }), +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/shape.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/shape.js new file mode 100644 index 0000000000000..27379a759608d --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/shape.js @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const shape = () => ({ + name: 'shape', + aliases: [], + type: 'shape', + help: 'Create a shape', + context: { + types: ['null'], + }, + args: { + shape: { + types: ['string', 'null'], + help: 'Pick a shape', + aliases: ['_'], + default: 'square', + }, + fill: { + types: ['string', 'null'], + help: 'Valid CSS color string', + default: 'black', + }, + border: { + types: ['string', 'null'], + aliases: ['stroke'], + help: 'Valid CSS color string', + }, + borderWidth: { + types: ['number', 'null'], + aliases: ['strokeWidth'], + help: 'Thickness of the border', + default: '0', + }, + maintainAspect: { + types: ['boolean'], + help: 'Select true to maintain aspect ratio', + default: false, + }, + }, + fn: (context, { shape, fill, border, borderWidth, maintainAspect }) => ({ + type: 'shape', + shape, + fill, + border, + borderWidth, + maintainAspect, + }), +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/sort.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/sort.js new file mode 100644 index 0000000000000..b6b554c032281 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/sort.js @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { sortBy } from 'lodash'; + +export const sort = () => ({ + name: 'sort', + type: 'datatable', + help: 'Sorts a datatable on a column', + context: { + types: ['datatable'], + }, + args: { + by: { + types: ['string'], + aliases: ['_', 'column'], + multi: false, // TODO: No reason you couldn't. + help: + 'The column to sort on. If column is not specified, the datatable will be sorted on the first column.', + }, + reverse: { + types: ['boolean'], + help: + 'Reverse the sort order. If reverse is not specified, the datatable will be sorted in ascending order.', + }, + }, + fn: (context, args) => { + const column = args.by || context.columns[0].name; + + return { + ...context, + rows: args.reverse ? sortBy(context.rows, column).reverse() : sortBy(context.rows, column), + }; + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/staticColumn.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/staticColumn.js new file mode 100644 index 0000000000000..4b9e96bda7354 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/staticColumn.js @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getType } from '../../../common/lib/get_type'; + +export const staticColumn = () => ({ + name: 'staticColumn', + type: 'datatable', + help: 'Add a column with a static value.', + context: { + types: ['datatable'], + }, + args: { + name: { + types: ['string'], + aliases: ['_', 'column'], + help: 'The name of the new column column', + required: true, + }, + value: { + types: ['string', 'number', 'boolean', 'null'], + help: + 'The value to insert in each column. Tip: use a sub-expression to rollup other columns into a static value', + default: null, + }, + }, + fn: (context, args) => { + const rows = context.rows.map(row => ({ ...row, [args.name]: args.value })); + const type = getType(rows[0][args.name]); + const columns = [...context.columns]; + const existingColumnIndex = columns.findIndex(({ name }) => name === args.name); + const newColumn = { name: args.name, type }; + + if (existingColumnIndex > -1) columns[existingColumnIndex] = newColumn; + else columns.push(newColumn); + + return { + type: 'datatable', + columns, + rows, + }; + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/string.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/string.js new file mode 100644 index 0000000000000..9374dc424ce51 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/string.js @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const string = () => ({ + name: 'string', + aliases: [], + type: 'string', + help: + 'Output a string made of other strings. Mostly useful when combined with sub-expressions that output a string, ' + + ' or something castable to a string', + args: { + value: { + aliases: ['_'], + types: ['string'], + multi: true, + help: "One or more strings to join together. Don't forget spaces where needed!", + }, + }, + fn: (context, args) => args.value.join(''), +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/switch.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/switch.js new file mode 100644 index 0000000000000..3cb3577cf74c8 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/switch.js @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const switchFn = () => ({ + name: 'switch', + help: 'Perform conditional logic with multiple conditions', + args: { + case: { + types: ['case'], + aliases: ['_'], + resolve: false, + multi: true, + help: 'The list of conditions to check', + }, + default: { + aliases: ['finally'], + resolve: false, + help: 'The default case if no cases match', + }, + }, + fn: async (context, args) => { + const cases = args.case || []; + for (let i = 0; i < cases.length; i++) { + const { matches, result } = await cases[i](); + if (matches) return result; + } + if (typeof args.default !== 'undefined') return await args.default(); + return context; + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/table.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/table.js new file mode 100644 index 0000000000000..3afd345c65c4a --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/table.js @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const table = () => ({ + name: 'table', + aliases: [], + type: 'render', + help: 'Configure a Data Table element', + context: { + types: ['datatable'], + }, + args: { + font: { + types: ['style'], + default: '{font}', + help: 'Font style', + }, + paginate: { + types: ['boolean'], + default: true, + help: 'Show pagination controls. If set to false only the first page will be displayed.', + }, + perPage: { + types: ['number'], + default: 10, + help: 'Show this many rows per page. You probably want to raise this is disabling pagination', + }, + showHeader: { + types: ['boolean'], + default: true, + help: 'Show or hide the header row with titles for each column.', + }, + }, + fn: (context, args) => { + const { font, paginate, perPage, showHeader } = args; + + return { + type: 'render', + as: 'table', + value: { + datatable: context, + font, + paginate, + perPage, + showHeader, + }, + }; + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/tail.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/tail.js new file mode 100644 index 0000000000000..5ee56d69e7462 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/tail.js @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { takeRight } from 'lodash'; + +export const tail = () => ({ + name: 'tail', + aliases: [], + type: 'datatable', + help: 'Get the last N rows from the end of a datatable. Also see `head`', + context: { + types: ['datatable'], + }, + args: { + count: { + aliases: ['_'], + types: ['number'], + help: 'Return this many rows from the end of the datatable', + }, + }, + fn: (context, args) => ({ + ...context, + rows: takeRight(context.rows, args.count), + }), +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/timefilter.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/timefilter.js new file mode 100644 index 0000000000000..42463bf374e65 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/timefilter.js @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import dateMath from '@elastic/datemath'; + +export const timefilter = () => ({ + name: 'timefilter', + aliases: [], + type: 'filter', + context: { + types: ['filter'], + }, + help: 'Create a timefilter for querying a source', + args: { + column: { + type: ['string'], + aliases: ['field', 'c'], + default: '@timestamp', + help: 'The column or field to attach the filter to', + }, + from: { + types: ['string', 'null'], + aliases: ['f', 'start'], + help: 'Beginning of the range, in ISO8601 or Elasticsearch datemath format', + }, + to: { + types: ['string', 'null'], + aliases: ['t', 'end'], + help: 'End of the range, in ISO8601 or Elasticsearch datemath format', + }, + }, + fn: (context, args) => { + if (!args.from && !args.to) return context; + + const { from, to, column } = args; + const filter = { + type: 'time', + column, + }; + + function parseAndValidate(str) { + if (!str) return; + + const moment = dateMath.parse(str); + if (!moment || !moment.isValid()) throw new Error(`Invalid date/time string ${str}`); + return moment.toISOString(); + } + + if (to != null) filter.to = parseAndValidate(to); + + if (from != null) filter.from = parseAndValidate(from); + + return { ...context, and: [...context.and, filter] }; + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/timefilterControl.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/timefilterControl.js new file mode 100644 index 0000000000000..ef7466622c08b --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/timefilterControl.js @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const timefilterControl = () => ({ + name: 'timefilterControl', + aliases: [], + type: 'render', + context: { + types: ['null'], + }, + help: 'Configure a time filter control element', + args: { + column: { + type: ['string'], + aliases: ['field', 'c'], + help: 'The column or field to attach the filter to', + }, + compact: { + type: ['boolean'], + help: 'Show the time filter as a button that triggers a popover', + default: true, + }, + }, + fn: (context, args) => { + return { + type: 'render', + as: 'time_filter', + value: args, + }; + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/__tests__/demodata.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/__tests__/demodata.js new file mode 100644 index 0000000000000..86ca52ebbc4b0 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/__tests__/demodata.js @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { demodata } from '../demodata'; + +const nullFilter = { + type: 'filter', + meta: {}, + size: null, + sort: [], + and: [], +}; + +const fn = demodata().fn; + +describe('demodata', () => { + it('ci, different object references', () => { + const ci1 = fn(nullFilter, { type: 'ci' }); + const ci2 = fn(nullFilter, { type: 'ci' }); + expect(ci1).not.to.equal(ci2); + expect(ci1.rows).not.to.equal(ci2.rows); + expect(ci1.rows[0]).not.to.equal(ci2.rows[0]); + }); + it('shirts, different object references', () => { + const shirts1 = fn(nullFilter, { type: 'shirts' }); + const shirts2 = fn(nullFilter, { type: 'shirts' }); + expect(shirts1).not.to.be.equal(shirts2); + expect(shirts1.rows).not.to.be.equal(shirts2.rows); + expect(shirts1.rows[0]).not.to.be.equal(shirts2.rows[0]); + }); + it('invalid set', () => { + expect(fn) + .withArgs(null, { type: 'foo' }) + .to.throwException(e => { + expect(e.message).to.be("Invalid data set: foo, use 'ci' or 'shirts'."); + }); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/__tests__/get_expression_type.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/__tests__/get_expression_type.js new file mode 100644 index 0000000000000..f22daa0a8cf51 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/__tests__/get_expression_type.js @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { getExpressionType } from '../pointseries/lib/get_expression_type'; +import { emptyTable, testTable } from '../../common/__tests__/fixtures/test_tables'; + +describe('getExpressionType', () => { + it('returns the result type of an evaluated math expression', () => { + expect(getExpressionType(testTable.columns, '2')).to.be.equal('number'); + expect(getExpressionType(testTable.colunns, '2 + 3')).to.be.equal('number'); + expect(getExpressionType(testTable.columns, 'name')).to.be.equal('string'); + expect(getExpressionType(testTable.columns, 'time')).to.be.equal('date'); + expect(getExpressionType(testTable.columns, 'price')).to.be.equal('number'); + expect(getExpressionType(testTable.columns, 'quantity')).to.be.equal('number'); + expect(getExpressionType(testTable.columns, 'in_stock')).to.be.equal('boolean'); + expect(getExpressionType(testTable.columns, 'mean(price)')).to.be.equal('number'); + expect(getExpressionType(testTable.columns, 'count(name)')).to.be.equal('string'); + expect(getExpressionType(testTable.columns, 'random()')).to.be.equal('number'); + expect(getExpressionType(testTable.columns, 'mean(multiply(price,quantity))')).to.be.eql( + 'number' + ); + }); + it('returns date instead of number when referencing date column', () => { + expect(getExpressionType(testTable.columns, 'mean(time)')).to.be.equal('date'); + }); + it(`returns 'null' if referenced field does not exist in datatable`, () => { + expect(getExpressionType(testTable.columns, 'foo')).to.be.equal('null'); + expect(getExpressionType(emptyTable.columns, 'foo')).to.be.equal('null'); + expect(getExpressionType(emptyTable.columns, 'mean(foo)')).to.be.equal('string'); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/__tests__/get_field_names.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/__tests__/get_field_names.js new file mode 100644 index 0000000000000..9b3f9990d1122 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/__tests__/get_field_names.js @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { parse } from 'tinymath'; +import { getFieldNames } from '../pointseries/lib/get_field_names'; + +describe('getFieldNames', () => { + it('returns array of field names referenced in a parsed math object', () => { + expect(getFieldNames([], parse('2+3'))).to.be.eql([]); + expect(getFieldNames([], parse('mean(foo)'))).to.be.eql(['foo']); + expect(getFieldNames([], parse('max(foo + bar)'))).to.be.eql(['foo', 'bar']); + expect(getFieldNames([], parse('count(foo) + count(bar)'))).to.be.eql(['foo', 'bar']); + expect( + getFieldNames([], parse('sum(count(foo),count(bar),count(fizz),count(buzz),2,3,4)')) + ).to.be.eql(['foo', 'bar', 'fizz', 'buzz']); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/__tests__/is_column_reference.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/__tests__/is_column_reference.js new file mode 100644 index 0000000000000..10951e73b301d --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/__tests__/is_column_reference.js @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { isColumnReference } from '../pointseries/lib/is_column_reference'; + +describe('isColumnReference', () => { + it('get a string result after parsing math expression', () => { + expect(isColumnReference('field')).to.be(true); + }); + it('non-string', () => { + expect(isColumnReference('2')).to.be(false); + expect(isColumnReference('mean(field)')).to.be(false); + expect(isColumnReference('field * 3')).to.be(false); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/__tests__/pointseries.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/__tests__/pointseries.js new file mode 100644 index 0000000000000..c2c7fb8f4a477 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/__tests__/pointseries.js @@ -0,0 +1,283 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { pointseries } from '../pointseries'; +import { emptyTable, testTable } from '../../common/__tests__/fixtures/test_tables'; + +describe('pointseries', () => { + const fn = pointseries().fn; + + describe('function', () => { + it('empty datatable, null args', () => { + expect(fn(emptyTable, { x: null, y: null })).to.be.eql({ + type: 'pointseries', + columns: {}, + rows: [], + }); + }); + it('empty datatable, invalid args', () => { + expect(fn(emptyTable, { x: 'name', y: 'price' })).to.be.eql({ + type: 'pointseries', + columns: {}, + rows: [], + }); + }); + it('args with constants only', () => { + expect(fn(testTable, { x: '1', y: '2' })).to.be.eql({ + type: 'pointseries', + columns: { + x: { type: 'number', role: 'measure', expression: '1' }, + y: { type: 'number', role: 'measure', expression: '2' }, + }, + rows: [{ x: 1, y: 2 }], + }); + }); + it('args with dimensions only', () => { + expect(fn(testTable, { x: 'name', y: 'price', size: 'quantity' })).to.be.eql({ + type: 'pointseries', + columns: { + x: { + type: 'string', + role: 'dimension', + expression: 'name', + }, + y: { + type: 'number', + role: 'dimension', + expression: 'price', + }, + size: { + type: 'number', + role: 'dimension', + expression: 'quantity', + }, + }, + rows: [ + { x: 'product1', y: 605, size: 100 }, + { x: 'product1', y: 583, size: 200 }, + { x: 'product1', y: 420, size: 300 }, + { x: 'product2', y: 216, size: 350 }, + { x: 'product2', y: 200, size: 256 }, + { x: 'product2', y: 190, size: 231 }, + { x: 'product3', y: 67, size: 240 }, + { x: 'product4', y: 311, size: 447 }, + { x: 'product5', y: 288, size: 384 }, + ], + }); + }); + it('args including measures', () => { + expect(fn(testTable, { x: 'name', y: 'mean(price)', size: 'mean(quantity)' })).to.be.eql({ + type: 'pointseries', + columns: { + x: { + type: 'string', + role: 'dimension', + expression: 'name', + }, + y: { + type: 'number', + role: 'measure', + expression: 'mean(price)', + }, + size: { + type: 'number', + role: 'measure', + expression: 'mean(quantity)', + }, + }, + rows: [ + { x: 'product1', y: 536, size: 200 }, + { x: 'product2', y: 202, size: 279 }, + { x: 'product3', y: 67, size: 240 }, + { x: 'product4', y: 311, size: 447 }, + { x: 'product5', y: 288, size: 384 }, + ], + }); + expect(fn(testTable, { x: 'name', y: 'max(price * quantity + 2)' })).to.be.eql({ + type: 'pointseries', + columns: { + x: { + type: 'string', + role: 'dimension', + expression: 'name', + }, + y: { + type: 'number', + role: 'measure', + expression: 'max(price * quantity + 2)', + }, + }, + rows: [ + { x: 'product1', y: 126002 }, + { x: 'product2', y: 75602 }, + { x: 'product3', y: 16082 }, + { x: 'product4', y: 139019 }, + { x: 'product5', y: 110594 }, + ], + }); + expect(fn(testTable, { x: 'name', y: 'count(price)' })).to.be.eql({ + type: 'pointseries', + columns: { + x: { + type: 'string', + role: 'dimension', + expression: 'name', + }, + y: { + type: 'number', + role: 'measure', + expression: 'count(price)', + }, + }, + rows: [ + { x: 'product1', y: 3 }, + { x: 'product2', y: 3 }, + { x: 'product3', y: 1 }, + { x: 'product4', y: 1 }, + { x: 'product5', y: 1 }, + ], + }); + expect(fn(testTable, { x: 'unique(name)' })).to.be.eql({ + type: 'pointseries', + columns: { + x: { + type: 'number', + role: 'measure', + expression: 'unique(name)', + }, + }, + rows: [{ x: 5 }], + }); + }); + it('args including random()', () => { + const randomPointseries = fn(testTable, { x: 'time', y: 'random()' }); + expect(randomPointseries).to.have.property('type', 'pointseries'); + expect(randomPointseries.columns).to.be.eql({ + x: { type: 'date', role: 'dimension', expression: 'time' }, + y: { + type: 'number', + role: 'measure', + expression: 'random()', + }, + }); + randomPointseries.rows.map((row, i) => { + expect(row.x).to.be(testTable.rows[i].time); + expect(row.y).to.be.within(0, 1); + }); + }); + it('empty string arg', () => { + expect(fn(testTable, { x: 'name', y: 'max(time)', size: ' ', text: '' })).to.be.eql({ + type: 'pointseries', + columns: { + x: { + type: 'string', + role: 'dimension', + expression: 'name', + }, + y: { + type: 'number', + role: 'measure', + expression: 'max(time)', + }, + }, + rows: [ + { x: 'product1', y: 1518015600950 }, + { x: 'product2', y: 1518015600950 }, + { x: 'product3', y: 1517842800950 }, + { x: 'product4', y: 1517842800950 }, + { x: 'product5', y: 1517842800950 }, + ], + }); + }); + it('ignores missing columns', () => { + expect(fn(testTable, { x: 'name', y: 'notInTheTable' })).to.be.eql({ + type: 'pointseries', + columns: { + x: { + type: 'string', + role: 'dimension', + expression: 'name', + }, + }, + rows: [ + { x: 'product1' }, + { x: 'product2' }, + { x: 'product3' }, + { x: 'product4' }, + { x: 'product5' }, + ], + }); + expect(fn(testTable, { y: 'notInTheTable' })).to.be.eql({ + type: 'pointseries', + columns: {}, + rows: [{}], + }); + expect(fn(testTable, { x: 'name', y: 'mean(notInTheTable)' })).to.be.eql({ + type: 'pointseries', + columns: { + x: { + type: 'string', + role: 'dimension', + expression: 'name', + }, + y: { + type: 'number', + role: 'measure', + expression: 'mean(notInTheTable)', + }, + }, + rows: [ + { x: 'product1', y: null }, + { x: 'product2', y: null }, + { x: 'product3', y: null }, + { x: 'product4', y: null }, + { x: 'product5', y: null }, + ], + }); + }); + it('invalid args', () => { + expect(fn(testTable, { x: 'name', y: 'quantity * 3' })).to.be.eql({ + type: 'pointseries', + columns: { + x: { + type: 'string', + role: 'dimension', + expression: 'name', + }, + y: { + type: 'number', + role: 'measure', + expression: 'quantity * 3', + }, + }, + rows: [ + { x: 'product1', y: null }, + { x: 'product2', y: null }, + { x: 'product3', y: null }, + { x: 'product4', y: null }, + { x: 'product5', y: null }, + ], + }); + expect(fn(testTable, { x: 'time', y: 'sum(notInTheTable)' })).to.be.eql({ + type: 'pointseries', + columns: { + x: { type: 'date', role: 'dimension', expression: 'time' }, + y: { + type: 'number', + role: 'measure', + expression: 'sum(notInTheTable)', + }, + }, + rows: [ + { x: 1517842800950, y: null }, + { x: 1517929200950, y: null }, + { x: 1518015600950, y: null }, + ], + }); + }); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/demodata/ci.json b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/demodata/ci.json new file mode 100644 index 0000000000000..62aec98a50a0e --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/demodata/ci.json @@ -0,0 +1,10002 @@ +[ + { + "age": 41, + "cost": 22.09, + "country": "CN", + "price": 52, + "project": "x-pack", + "state": "running", + "time": 1471590000000, + "username": "arobertson0" + }, + { + "age": 62, + "cost": 21.45, + "country": "RU", + "price": 53, + "project": "x-pack", + "state": "start", + "time": 1477292400000, + "username": "smeyer1" + }, + { + "age": 24, + "cost": 21.29, + "country": "FR", + "price": 53, + "project": "elasticsearch", + "state": "start", + "time": 1490425200000, + "username": "rcollins2" + }, + { + "age": 35, + "cost": 23.99, + "country": "PL", + "price": 52, + "project": "logstash", + "state": "start", + "time": 1477033200000, + "username": "lhoward3" + }, + { + "age": 79, + "cost": 22.09, + "country": "EC", + "price": 61, + "project": "kibana", + "state": "running", + "time": 1491116400000, + "username": "kcarroll4" + }, + { + "age": 69, + "cost": 22.9, + "country": "PE", + "price": 52, + "project": "x-pack", + "state": "done", + "time": 1476946800000, + "username": "handerson5" + }, + { + "age": 20, + "cost": 21.2, + "country": "JP", + "price": 65, + "project": "x-pack", + "state": "start", + "time": 1481094000000, + "username": "twashington6" + }, + { + "age": 56, + "cost": 24.19, + "country": "BG", + "price": 38, + "project": "logstash", + "state": "start", + "time": 1476601200000, + "username": "dmendoza7" + }, + { + "age": 48, + "cost": 21.37, + "country": "RU", + "price": 47, + "project": "x-pack", + "state": "start", + "time": 1478070000000, + "username": "staylor8" + }, + { + "age": 75, + "cost": 24.61, + "country": "ES", + "price": 58, + "project": "machine-learning", + "state": "running", + "time": 1483081200000, + "username": "mramirez9" + }, + { + "age": 33, + "cost": 21.82, + "country": "MX", + "price": 59, + "project": "machine-learning", + "state": "running", + "time": 1467529200000, + "username": "dandrewsa" + }, + { + "age": 61, + "cost": 22.57, + "country": "PL", + "price": 52, + "project": "machine-learning", + "state": "done", + "time": 1490252400000, + "username": "bwalkerb" + }, + { + "age": 69, + "cost": 22.28, + "country": "KR", + "price": 58, + "project": "x-pack", + "state": "running", + "time": 1470812400000, + "username": "vfoxc" + }, + { + "age": 75, + "cost": 22.07, + "country": "VN", + "price": 52, + "project": "machine-learning", + "state": "running", + "time": 1474441200000, + "username": "kdixond" + }, + { + "age": 62, + "cost": 23.3, + "country": "ID", + "price": 49, + "project": "x-pack", + "state": "done", + "time": 1488610800000, + "username": "rjacksone" + }, + { + "age": 54, + "cost": 21.96, + "country": "CF", + "price": 48, + "project": "machine-learning", + "state": "running", + "time": 1462086000000, + "username": "ecruzf" + }, + { + "age": 41, + "cost": 22.64, + "country": "JP", + "price": 48, + "project": "x-pack", + "state": "done", + "time": 1480748400000, + "username": "dfreemang" + }, + { + "age": 49, + "cost": 25.63, + "country": "CN", + "price": 56, + "project": "logstash", + "state": "done", + "time": 1478502000000, + "username": "jhernandezh" + }, + { + "age": 36, + "cost": 24.18, + "country": "JM", + "price": 59, + "project": "beats", + "state": "start", + "time": 1465628400000, + "username": "rcolemani" + }, + { + "age": 67, + "cost": 22.04, + "country": "CN", + "price": 66, + "project": "opbeat", + "state": "running", + "time": 1472626800000, + "username": "cbaileyj" + }, + { + "age": 25, + "cost": 24.02, + "country": "AR", + "price": 41, + "project": "opbeat", + "state": "start", + "time": 1488006000000, + "username": "hperryk" + }, + { + "age": 32, + "cost": 23.4, + "country": "CU", + "price": 59, + "project": "opbeat", + "state": "done", + "time": 1479711600000, + "username": "lfoxl" + }, + { + "age": 61, + "cost": 23.9, + "country": "CN", + "price": 56, + "project": "machine-learning", + "state": "start", + "time": 1476255600000, + "username": "cgrantm" + }, + { + "age": 30, + "cost": 23.46, + "country": "CN", + "price": 54, + "project": "kibana", + "state": "done", + "time": 1473231600000, + "username": "ewarrenn" + }, + { + "age": 28, + "cost": 22.8, + "country": "ID", + "price": 48, + "project": "logstash", + "state": "done", + "time": 1474182000000, + "username": "nrileyo" + }, + { + "age": 33, + "cost": 23.61, + "country": "RU", + "price": 53, + "project": "machine-learning", + "state": "start", + "time": 1490338800000, + "username": "swillisp" + }, + { + "age": 19, + "cost": 23.28, + "country": "ID", + "price": 64, + "project": "machine-learning", + "state": "done", + "time": 1461913200000, + "username": "jthompsonq" + }, + { + "age": 27, + "cost": 24.36, + "country": "AZ", + "price": 63, + "project": "x-pack", + "state": "start", + "time": 1483945200000, + "username": "bpricer" + }, + { + "age": 52, + "cost": 23.79, + "country": "AG", + "price": 57, + "project": "elasticsearch", + "state": "start", + "time": 1461222000000, + "username": "rgibsons" + }, + { + "age": 61, + "cost": 23.21, + "country": "CN", + "price": 70, + "project": "machine-learning", + "state": "done", + "time": 1482994800000, + "username": "cwashingtont" + }, + { + "age": 49, + "cost": 23.71, + "country": "US", + "price": 50, + "project": "logstash", + "state": "start", + "time": 1484722800000, + "username": "athompsonu" + }, + { + "age": 64, + "cost": 23.64, + "country": "BR", + "price": 47, + "project": "beats", + "state": "start", + "time": 1475910000000, + "username": "ewarrenv" + }, + { + "age": 36, + "cost": 24.03, + "country": "PE", + "price": 60, + "project": "beats", + "state": "start", + "time": 1472022000000, + "username": "mcruzw" + }, + { + "age": 45, + "cost": 23.13, + "country": "HR", + "price": 50, + "project": "elasticsearch", + "state": "done", + "time": 1463295600000, + "username": "brosex" + }, + { + "age": 30, + "cost": 22.37, + "country": "PK", + "price": 69, + "project": "kibana", + "state": "running", + "time": 1465455600000, + "username": "gberryy" + }, + { + "age": 49, + "cost": 23.96, + "country": "ID", + "price": 49, + "project": "logstash", + "state": "start", + "time": 1468220400000, + "username": "mortizz" + }, + { + "age": 73, + "cost": 22.36, + "country": "YE", + "price": 44, + "project": "elasticsearch", + "state": "running", + "time": 1477897200000, + "username": "pelliott10" + }, + { + "age": 43, + "cost": 20.38, + "country": "PT", + "price": 54, + "project": "beats", + "state": "done", + "time": 1475737200000, + "username": "rperkins11" + }, + { + "age": 30, + "cost": 22.96, + "country": "TZ", + "price": 42, + "project": "kibana", + "state": "done", + "time": 1482822000000, + "username": "rjohnson12" + }, + { + "age": 37, + "cost": 23.27, + "country": "AM", + "price": 57, + "project": "kibana", + "state": "done", + "time": 1473490800000, + "username": "chall13" + }, + { + "age": 79, + "cost": 22.06, + "country": "PH", + "price": 52, + "project": "kibana", + "state": "running", + "time": 1464850800000, + "username": "mwells14" + }, + { + "age": 34, + "cost": 21.81, + "country": "CZ", + "price": 54, + "project": "x-pack", + "state": "running", + "time": 1468306800000, + "username": "bbrown15" + }, + { + "age": 61, + "cost": 22.16, + "country": "ID", + "price": 56, + "project": "machine-learning", + "state": "running", + "time": 1470294000000, + "username": "bwood16" + }, + { + "age": 19, + "cost": 23.26, + "country": "ID", + "price": 56, + "project": "machine-learning", + "state": "done", + "time": 1474873200000, + "username": "sanderson17" + }, + { + "age": 21, + "cost": 22.56, + "country": "ID", + "price": 56, + "project": "logstash", + "state": "done", + "time": 1465801200000, + "username": "rbishop18" + }, + { + "age": 49, + "cost": 23.8, + "country": "PE", + "price": 52, + "project": "logstash", + "state": "start", + "time": 1487574000000, + "username": "kgomez19" + }, + { + "age": 60, + "cost": 22.33, + "country": "MG", + "price": 62, + "project": "opbeat", + "state": "running", + "time": 1485846000000, + "username": "jclark1a" + }, + { + "age": 78, + "cost": 21.46, + "country": "PT", + "price": 61, + "project": "beats", + "state": "start", + "time": 1474527600000, + "username": "cpeterson1b" + }, + { + "age": 78, + "cost": 22.74, + "country": "CN", + "price": 57, + "project": "beats", + "state": "done", + "time": 1461567600000, + "username": "ttaylor1c" + }, + { + "age": 58, + "cost": 23.41, + "country": "PS", + "price": 63, + "project": "kibana", + "state": "done", + "time": 1491634800000, + "username": "sdean1d" + }, + { + "age": 69, + "cost": 21.14, + "country": "RU", + "price": 47, + "project": "x-pack", + "state": "start", + "time": 1481698800000, + "username": "sfreeman1e" + }, + { + "age": 54, + "cost": 23.1, + "country": "CY", + "price": 49, + "project": "machine-learning", + "state": "done", + "time": 1462431600000, + "username": "rmccoy1f" + }, + { + "age": 43, + "cost": 23.55, + "country": "MX", + "price": 62, + "project": "beats", + "state": "start", + "time": 1461308400000, + "username": "kwallace1g" + }, + { + "age": 35, + "cost": 22.39, + "country": "ID", + "price": 59, + "project": "logstash", + "state": "running", + "time": 1468652400000, + "username": "jhunt1h" + }, + { + "age": 51, + "cost": 24.76, + "country": "LU", + "price": 64, + "project": "kibana", + "state": "running", + "time": 1472194800000, + "username": "ageorge1i" + }, + { + "age": 60, + "cost": 22.97, + "country": "HN", + "price": 71, + "project": "opbeat", + "state": "done", + "time": 1482735600000, + "username": "jsims1j" + }, + { + "age": 55, + "cost": 22.75, + "country": "ID", + "price": 54, + "project": "x-pack", + "state": "done", + "time": 1486537200000, + "username": "jwatkins1k" + }, + { + "age": 26, + "cost": 24.65, + "country": "PL", + "price": 61, + "project": "machine-learning", + "state": "running", + "time": 1465110000000, + "username": "jstanley1l" + }, + { + "age": 60, + "cost": 22.96, + "country": "NO", + "price": 56, + "project": "opbeat", + "state": "done", + "time": 1472367600000, + "username": "rking1m" + }, + { + "age": 56, + "cost": 23.93, + "country": "CN", + "price": 57, + "project": "logstash", + "state": "start", + "time": 1489561200000, + "username": "tmcdonald1n" + }, + { + "age": 19, + "cost": 21.46, + "country": "SA", + "price": 52, + "project": "machine-learning", + "state": "start", + "time": 1485586800000, + "username": "dharper1o" + }, + { + "age": 59, + "cost": 24.91, + "country": "SE", + "price": 52, + "project": "elasticsearch", + "state": "running", + "time": 1484031600000, + "username": "kwilson1p" + }, + { + "age": 49, + "cost": 22.91, + "country": "UG", + "price": 51, + "project": "logstash", + "state": "done", + "time": 1490166000000, + "username": "phansen1q" + }, + { + "age": 66, + "cost": 22.68, + "country": "ID", + "price": 52, + "project": "elasticsearch", + "state": "done", + "time": 1484377200000, + "username": "wrodriguez1r" + }, + { + "age": 80, + "cost": 21.29, + "country": "NG", + "price": 53, + "project": "elasticsearch", + "state": "start", + "time": 1474268400000, + "username": "hbowman1s" + }, + { + "age": 38, + "cost": 21.79, + "country": "ID", + "price": 55, + "project": "elasticsearch", + "state": "running", + "time": 1463122800000, + "username": "agonzales1t" + }, + { + "age": 61, + "cost": 24.5, + "country": "AF", + "price": 62, + "project": "machine-learning", + "state": "running", + "time": 1474268400000, + "username": "jmarshall1u" + }, + { + "age": 40, + "cost": 23.17, + "country": "AZ", + "price": 62, + "project": "machine-learning", + "state": "done", + "time": 1472626800000, + "username": "sadams1v" + }, + { + "age": 19, + "cost": 22.79, + "country": "FR", + "price": 60, + "project": "machine-learning", + "state": "done", + "time": 1479279600000, + "username": "athomas1w" + }, + { + "age": 48, + "cost": 22.15, + "country": "CN", + "price": 47, + "project": "x-pack", + "state": "running", + "time": 1483513200000, + "username": "jhanson1x" + }, + { + "age": 51, + "cost": 23.68, + "country": "BR", + "price": 56, + "project": "kibana", + "state": "start", + "time": 1478070000000, + "username": "smurphy1y" + }, + { + "age": 74, + "cost": 22.77, + "country": "ID", + "price": 59, + "project": "opbeat", + "state": "done", + "time": 1480921200000, + "username": "jaustin1z" + }, + { + "age": 63, + "cost": 23.42, + "country": "PL", + "price": 44, + "project": "logstash", + "state": "done", + "time": 1469948400000, + "username": "aburns20" + }, + { + "age": 58, + "cost": 22.63, + "country": "PH", + "price": 51, + "project": "kibana", + "state": "done", + "time": 1490770800000, + "username": "jmills21" + }, + { + "age": 49, + "cost": 22.47, + "country": "KP", + "price": 65, + "project": "logstash", + "state": "running", + "time": 1488438000000, + "username": "wmontgomery22" + }, + { + "age": 34, + "cost": 24.12, + "country": "YE", + "price": 67, + "project": "x-pack", + "state": "start", + "time": 1463468400000, + "username": "kbrooks23" + }, + { + "age": 55, + "cost": 22.93, + "country": "ID", + "price": 53, + "project": "x-pack", + "state": "done", + "time": 1473836400000, + "username": "dmarshall24" + }, + { + "age": 75, + "cost": 22.34, + "country": "PE", + "price": 41, + "project": "machine-learning", + "state": "running", + "time": 1471330800000, + "username": "rsmith25" + }, + { + "age": 62, + "cost": 23.06, + "country": "PT", + "price": 60, + "project": "x-pack", + "state": "done", + "time": 1490338800000, + "username": "amartinez26" + }, + { + "age": 69, + "cost": 24.24, + "country": "PL", + "price": 57, + "project": "x-pack", + "state": "start", + "time": 1466492400000, + "username": "bfranklin27" + }, + { + "age": 34, + "cost": 21.01, + "country": "GR", + "price": 45, + "project": "x-pack", + "state": "start", + "time": 1461222000000, + "username": "dhicks28" + }, + { + "age": 42, + "cost": 24.88, + "country": "GR", + "price": 43, + "project": "logstash", + "state": "running", + "time": 1476514800000, + "username": "hperez29" + }, + { + "age": 18, + "cost": 23.44, + "country": "CN", + "price": 58, + "project": "opbeat", + "state": "done", + "time": 1484118000000, + "username": "jchavez2a" + }, + { + "age": 80, + "cost": 22.87, + "country": "ID", + "price": 39, + "project": "elasticsearch", + "state": "done", + "time": 1471330800000, + "username": "krobinson2b" + }, + { + "age": 47, + "cost": 23.26, + "country": "PH", + "price": 49, + "project": "machine-learning", + "state": "done", + "time": 1468566000000, + "username": "aking2c" + }, + { + "age": 22, + "cost": 23.7, + "country": "ID", + "price": 61, + "project": "beats", + "state": "start", + "time": 1473404400000, + "username": "ediaz2d" + }, + { + "age": 61, + "cost": 23.28, + "country": "ID", + "price": 60, + "project": "machine-learning", + "state": "done", + "time": 1481785200000, + "username": "aevans2e" + }, + { + "age": 41, + "cost": 22.72, + "country": "ID", + "price": 44, + "project": "x-pack", + "state": "done", + "time": 1463554800000, + "username": "lperez2f" + }, + { + "age": 41, + "cost": 22.15, + "country": "RU", + "price": 45, + "project": "x-pack", + "state": "running", + "time": 1487833200000, + "username": "rmartinez2g" + }, + { + "age": 44, + "cost": 24.44, + "country": "GR", + "price": 71, + "project": "kibana", + "state": "start", + "time": 1470294000000, + "username": "hcrawford2h" + }, + { + "age": 72, + "cost": 21.46, + "country": "NG", + "price": 47, + "project": "kibana", + "state": "start", + "time": 1477119600000, + "username": "dramirez2i" + }, + { + "age": 31, + "cost": 22.45, + "country": "FR", + "price": 68, + "project": "elasticsearch", + "state": "running", + "time": 1487401200000, + "username": "greynolds2j" + }, + { + "age": 39, + "cost": 24.56, + "country": "KR", + "price": 58, + "project": "opbeat", + "state": "running", + "time": 1477810800000, + "username": "sjordan2k" + }, + { + "age": 54, + "cost": 22.52, + "country": "ZA", + "price": 80, + "project": "machine-learning", + "state": "done", + "time": 1487574000000, + "username": "pjohnston2l" + }, + { + "age": 39, + "cost": 21.86, + "country": "GR", + "price": 50, + "project": "opbeat", + "state": "running", + "time": 1485414000000, + "username": "ccarpenter2m" + }, + { + "age": 79, + "cost": 23.52, + "country": "SE", + "price": 54, + "project": "kibana", + "state": "start", + "time": 1488351600000, + "username": "bmorris2n" + }, + { + "age": 23, + "cost": 23.44, + "country": "BR", + "price": 39, + "project": "kibana", + "state": "done", + "time": 1472367600000, + "username": "mmoore2o" + }, + { + "age": 50, + "cost": 23.03, + "country": "CZ", + "price": 51, + "project": "beats", + "state": "done", + "time": 1478588400000, + "username": "jlawson2p" + }, + { + "age": 74, + "cost": 22.69, + "country": "CN", + "price": 79, + "project": "opbeat", + "state": "done", + "time": 1460530800000, + "username": "rjackson2q" + }, + { + "age": 39, + "cost": 23.73, + "country": "CN", + "price": 60, + "project": "opbeat", + "state": "start", + "time": 1489388400000, + "username": "tcole2r" + }, + { + "age": 47, + "cost": 20.51, + "country": "RU", + "price": 59, + "project": "machine-learning", + "state": "start", + "time": 1479625200000, + "username": "garnold2s" + }, + { + "age": 42, + "cost": 20.26, + "country": "JP", + "price": 53, + "project": "logstash", + "state": "done", + "time": 1482303600000, + "username": "khenry2t" + }, + { + "age": 21, + "cost": 23.71, + "country": "CU", + "price": 61, + "project": "logstash", + "state": "start", + "time": 1482562800000, + "username": "aalvarez2u" + }, + { + "age": 72, + "cost": 23.38, + "country": "CN", + "price": 62, + "project": "kibana", + "state": "done", + "time": 1465887600000, + "username": "cbrown2v" + }, + { + "age": 64, + "cost": 22.52, + "country": "NG", + "price": 43, + "project": "beats", + "state": "done", + "time": 1460617200000, + "username": "tblack2w" + }, + { + "age": 53, + "cost": 24.24, + "country": "MX", + "price": 71, + "project": "opbeat", + "state": "start", + "time": 1481698800000, + "username": "bdiaz2x" + }, + { + "age": 52, + "cost": 22.42, + "country": "PT", + "price": 56, + "project": "elasticsearch", + "state": "running", + "time": 1481958000000, + "username": "drobinson2y" + }, + { + "age": 32, + "cost": 23.84, + "country": "BR", + "price": 66, + "project": "opbeat", + "state": "start", + "time": 1476860400000, + "username": "jsnyder2z" + }, + { + "age": 50, + "cost": 23.18, + "country": "ID", + "price": 54, + "project": "beats", + "state": "done", + "time": 1476169200000, + "username": "rking30" + }, + { + "age": 25, + "cost": 22.43, + "country": "CN", + "price": 46, + "project": "opbeat", + "state": "running", + "time": 1485327600000, + "username": "tjohnston31" + }, + { + "age": 33, + "cost": 22.44, + "country": "MY", + "price": 55, + "project": "machine-learning", + "state": "running", + "time": 1472713200000, + "username": "bdaniels32" + }, + { + "age": 71, + "cost": 23.84, + "country": "BY", + "price": 42, + "project": "beats", + "state": "start", + "time": 1460962800000, + "username": "ggarza33" + }, + { + "age": 74, + "cost": 21.73, + "country": "GR", + "price": 58, + "project": "opbeat", + "state": "running", + "time": 1463382000000, + "username": "csmith34" + }, + { + "age": 57, + "cost": 21.62, + "country": "TH", + "price": 60, + "project": "beats", + "state": "running", + "time": 1486969200000, + "username": "bhall35" + }, + { + "age": 28, + "cost": 21.34, + "country": "CO", + "price": 50, + "project": "logstash", + "state": "start", + "time": 1491462000000, + "username": "jpalmer36" + }, + { + "age": 26, + "cost": 23.35, + "country": "ZA", + "price": 57, + "project": "machine-learning", + "state": "done", + "time": 1471330800000, + "username": "akelly37" + }, + { + "age": 20, + "cost": 24.87, + "country": "JP", + "price": 66, + "project": "x-pack", + "state": "running", + "time": 1479366000000, + "username": "hbowman38" + }, + { + "age": 75, + "cost": 23.91, + "country": "MA", + "price": 59, + "project": "machine-learning", + "state": "start", + "time": 1466233200000, + "username": "wpierce39" + }, + { + "age": 62, + "cost": 20.83, + "country": "CN", + "price": 53, + "project": "x-pack", + "state": "start", + "time": 1464418800000, + "username": "eriley3a" + }, + { + "age": 45, + "cost": 22.57, + "country": "PT", + "price": 43, + "project": "elasticsearch", + "state": "done", + "time": 1471676400000, + "username": "emedina3b" + }, + { + "age": 72, + "cost": 22.45, + "country": "ID", + "price": 79, + "project": "kibana", + "state": "running", + "time": 1469516400000, + "username": "rlane3c" + }, + { + "age": 52, + "cost": 22.48, + "country": "ID", + "price": 61, + "project": "elasticsearch", + "state": "running", + "time": 1487228400000, + "username": "dwallace3d" + }, + { + "age": 36, + "cost": 23.89, + "country": "HR", + "price": 66, + "project": "beats", + "state": "start", + "time": 1462777200000, + "username": "jmcdonald3e" + }, + { + "age": 52, + "cost": 21.84, + "country": "RU", + "price": 56, + "project": "elasticsearch", + "state": "running", + "time": 1490166000000, + "username": "cschmidt3f" + }, + { + "age": 45, + "cost": 22.13, + "country": "ID", + "price": 54, + "project": "elasticsearch", + "state": "running", + "time": 1470207600000, + "username": "jfields3g" + }, + { + "age": 51, + "cost": 23.29, + "country": "CM", + "price": 48, + "project": "kibana", + "state": "done", + "time": 1475564400000, + "username": "kduncan3h" + }, + { + "age": 75, + "cost": 21.61, + "country": "ID", + "price": 58, + "project": "machine-learning", + "state": "running", + "time": 1480402800000, + "username": "award3i" + }, + { + "age": 44, + "cost": 21.09, + "country": "KN", + "price": 51, + "project": "kibana", + "state": "start", + "time": 1480575600000, + "username": "jking3j" + }, + { + "age": 53, + "cost": 24.07, + "country": "CU", + "price": 52, + "project": "opbeat", + "state": "start", + "time": 1489820400000, + "username": "ksanders3k" + }, + { + "age": 57, + "cost": 22.86, + "country": "BR", + "price": 40, + "project": "beats", + "state": "done", + "time": 1474700400000, + "username": "colson3l" + }, + { + "age": 77, + "cost": 22.88, + "country": "GT", + "price": 69, + "project": "logstash", + "state": "done", + "time": 1486537200000, + "username": "dpierce3m" + }, + { + "age": 56, + "cost": 22.79, + "country": "CN", + "price": 52, + "project": "logstash", + "state": "done", + "time": 1474959600000, + "username": "afoster3n" + }, + { + "age": 65, + "cost": 23.62, + "country": "VN", + "price": 43, + "project": "kibana", + "state": "start", + "time": 1485068400000, + "username": "phoward3o" + }, + { + "age": 66, + "cost": 22.32, + "country": "SE", + "price": 44, + "project": "elasticsearch", + "state": "running", + "time": 1473750000000, + "username": "rpalmer3p" + }, + { + "age": 34, + "cost": 21.86, + "country": "CN", + "price": 54, + "project": "x-pack", + "state": "running", + "time": 1480230000000, + "username": "bgordon3q" + }, + { + "age": 63, + "cost": 23.54, + "country": "ID", + "price": 59, + "project": "logstash", + "state": "start", + "time": 1484722800000, + "username": "cturner3r" + }, + { + "age": 20, + "cost": 21.73, + "country": "PS", + "price": 43, + "project": "x-pack", + "state": "running", + "time": 1480921200000, + "username": "jowens3s" + }, + { + "age": 65, + "cost": 23.34, + "country": "PL", + "price": 60, + "project": "kibana", + "state": "done", + "time": 1491548400000, + "username": "rdunn3t" + }, + { + "age": 44, + "cost": 22.12, + "country": "ID", + "price": 48, + "project": "kibana", + "state": "running", + "time": 1475391600000, + "username": "jhall3u" + }, + { + "age": 74, + "cost": 22.71, + "country": "TH", + "price": 57, + "project": "opbeat", + "state": "done", + "time": 1484982000000, + "username": "cmorris3v" + }, + { + "age": 64, + "cost": 23.85, + "country": "ID", + "price": 48, + "project": "beats", + "state": "start", + "time": 1475996400000, + "username": "dpierce3w" + }, + { + "age": 48, + "cost": 22.03, + "country": "CN", + "price": 66, + "project": "x-pack", + "state": "running", + "time": 1468825200000, + "username": "htaylor3x" + }, + { + "age": 59, + "cost": 22.88, + "country": "BG", + "price": 67, + "project": "elasticsearch", + "state": "done", + "time": 1470380400000, + "username": "hwagner3y" + }, + { + "age": 25, + "cost": 23.96, + "country": "JM", + "price": 50, + "project": "opbeat", + "state": "start", + "time": 1486796400000, + "username": "sholmes3z" + }, + { + "age": 62, + "cost": 23.1, + "country": "ID", + "price": 61, + "project": "x-pack", + "state": "done", + "time": 1467874800000, + "username": "grogers40" + }, + { + "age": 67, + "cost": 22.36, + "country": "PT", + "price": 50, + "project": "opbeat", + "state": "running", + "time": 1483686000000, + "username": "jphillips41" + }, + { + "age": 69, + "cost": 23.26, + "country": "TN", + "price": 75, + "project": "x-pack", + "state": "done", + "time": 1473836400000, + "username": "pmcdonald42" + }, + { + "age": 64, + "cost": 23.7, + "country": "PL", + "price": 57, + "project": "beats", + "state": "start", + "time": 1465801200000, + "username": "bsims43" + }, + { + "age": 80, + "cost": 24.38, + "country": "US", + "price": 56, + "project": "elasticsearch", + "state": "start", + "time": 1479193200000, + "username": "jmurray44" + }, + { + "age": 41, + "cost": 24.64, + "country": "PH", + "price": 58, + "project": "x-pack", + "state": "running", + "time": 1472022000000, + "username": "phenderson45" + }, + { + "age": 55, + "cost": 24.47, + "country": "ID", + "price": 63, + "project": "x-pack", + "state": "start", + "time": 1465974000000, + "username": "nhunt46" + }, + { + "age": 30, + "cost": 23.45, + "country": "CN", + "price": 54, + "project": "kibana", + "state": "done", + "time": 1469257200000, + "username": "rwalker47" + }, + { + "age": 31, + "cost": 22.09, + "country": "CO", + "price": 60, + "project": "elasticsearch", + "state": "running", + "time": 1482562800000, + "username": "dkim48" + }, + { + "age": 33, + "cost": 22.48, + "country": "NG", + "price": 61, + "project": "machine-learning", + "state": "running", + "time": 1486969200000, + "username": "dward49" + }, + { + "age": 68, + "cost": 23, + "country": "HN", + "price": 58, + "project": "machine-learning", + "state": "done", + "time": 1471762800000, + "username": "ahughes4a" + }, + { + "age": 32, + "cost": 21.74, + "country": "CN", + "price": 64, + "project": "opbeat", + "state": "running", + "time": 1491548400000, + "username": "jdunn4b" + }, + { + "age": 49, + "cost": 21.46, + "country": "DE", + "price": 65, + "project": "logstash", + "state": "start", + "time": 1466060400000, + "username": "psimmons4c" + }, + { + "age": 33, + "cost": 22.14, + "country": "CN", + "price": 52, + "project": "machine-learning", + "state": "running", + "time": 1460790000000, + "username": "tpierce4d" + }, + { + "age": 59, + "cost": 23.4, + "country": "GY", + "price": 59, + "project": "elasticsearch", + "state": "done", + "time": 1475650800000, + "username": "jchavez4e" + }, + { + "age": 60, + "cost": 20.92, + "country": "MK", + "price": 55, + "project": "opbeat", + "state": "start", + "time": 1486882800000, + "username": "awood4f" + }, + { + "age": 28, + "cost": 22.56, + "country": "PT", + "price": 58, + "project": "logstash", + "state": "done", + "time": 1491375600000, + "username": "psanchez4g" + }, + { + "age": 69, + "cost": 22.78, + "country": "TH", + "price": 54, + "project": "x-pack", + "state": "done", + "time": 1478588400000, + "username": "phansen4h" + }, + { + "age": 73, + "cost": 21.52, + "country": "CN", + "price": 54, + "project": "elasticsearch", + "state": "running", + "time": 1461308400000, + "username": "apierce4i" + }, + { + "age": 49, + "cost": 21.94, + "country": "CN", + "price": 55, + "project": "logstash", + "state": "running", + "time": 1467097200000, + "username": "cedwards4j" + }, + { + "age": 73, + "cost": 21.99, + "country": "JP", + "price": 56, + "project": "elasticsearch", + "state": "running", + "time": 1478329200000, + "username": "sford4k" + }, + { + "age": 64, + "cost": 23.14, + "country": "PH", + "price": 51, + "project": "beats", + "state": "done", + "time": 1483340400000, + "username": "kpeterson4l" + }, + { + "age": 53, + "cost": 22.97, + "country": "BR", + "price": 58, + "project": "opbeat", + "state": "done", + "time": 1486710000000, + "username": "bfoster4m" + }, + { + "age": 57, + "cost": 21.37, + "country": "CN", + "price": 67, + "project": "beats", + "state": "start", + "time": 1481526000000, + "username": "eturner4n" + }, + { + "age": 65, + "cost": 23.65, + "country": "CN", + "price": 61, + "project": "kibana", + "state": "start", + "time": 1469948400000, + "username": "kwilliamson4o" + }, + { + "age": 25, + "cost": 23.94, + "country": "ID", + "price": 50, + "project": "opbeat", + "state": "start", + "time": 1465801200000, + "username": "jfranklin4p" + }, + { + "age": 74, + "cost": 23.53, + "country": "CN", + "price": 52, + "project": "opbeat", + "state": "start", + "time": 1461481200000, + "username": "ecoleman4q" + }, + { + "age": 57, + "cost": 24.26, + "country": "CN", + "price": 71, + "project": "beats", + "state": "start", + "time": 1461481200000, + "username": "rray4r" + }, + { + "age": 67, + "cost": 21.4, + "country": "FR", + "price": 72, + "project": "opbeat", + "state": "start", + "time": 1465714800000, + "username": "mprice4s" + }, + { + "age": 39, + "cost": 23.47, + "country": "FR", + "price": 52, + "project": "opbeat", + "state": "done", + "time": 1486537200000, + "username": "krobertson4t" + }, + { + "age": 50, + "cost": 22.96, + "country": "PE", + "price": 59, + "project": "beats", + "state": "done", + "time": 1489388400000, + "username": "rwalker4u" + }, + { + "age": 26, + "cost": 23.99, + "country": "CN", + "price": 52, + "project": "machine-learning", + "state": "start", + "time": 1477033200000, + "username": "cdiaz4v" + }, + { + "age": 34, + "cost": 23.74, + "country": "US", + "price": 55, + "project": "x-pack", + "state": "start", + "time": 1474009200000, + "username": "cparker4w" + }, + { + "age": 38, + "cost": 23.23, + "country": "BR", + "price": 57, + "project": "elasticsearch", + "state": "done", + "time": 1489993200000, + "username": "cthompson4x" + }, + { + "age": 62, + "cost": 23.34, + "country": "PH", + "price": 42, + "project": "x-pack", + "state": "done", + "time": 1479711600000, + "username": "callen4y" + }, + { + "age": 33, + "cost": 22.24, + "country": "BR", + "price": 36, + "project": "machine-learning", + "state": "running", + "time": 1480662000000, + "username": "lbell4z" + }, + { + "age": 37, + "cost": 24.62, + "country": "CM", + "price": 51, + "project": "kibana", + "state": "running", + "time": 1465887600000, + "username": "hmoreno50" + }, + { + "age": 47, + "cost": 22.23, + "country": "PY", + "price": 53, + "project": "machine-learning", + "state": "running", + "time": 1460962800000, + "username": "jspencer51" + }, + { + "age": 38, + "cost": 23.38, + "country": "ID", + "price": 58, + "project": "elasticsearch", + "state": "done", + "time": 1491116400000, + "username": "bhamilton52" + }, + { + "age": 24, + "cost": 24.1, + "country": "CN", + "price": 58, + "project": "elasticsearch", + "state": "start", + "time": 1466492400000, + "username": "amatthews53" + }, + { + "age": 27, + "cost": 23.12, + "country": "ID", + "price": 68, + "project": "x-pack", + "state": "done", + "time": 1471330800000, + "username": "wjohnston54" + }, + { + "age": 28, + "cost": 22.07, + "country": "PL", + "price": 56, + "project": "logstash", + "state": "running", + "time": 1489302000000, + "username": "crice55" + }, + { + "age": 36, + "cost": 23.32, + "country": "BY", + "price": 60, + "project": "beats", + "state": "done", + "time": 1460790000000, + "username": "probinson56" + }, + { + "age": 44, + "cost": 24.17, + "country": "CN", + "price": 56, + "project": "kibana", + "state": "start", + "time": 1467961200000, + "username": "lmatthews57" + }, + { + "age": 80, + "cost": 21.56, + "country": "SE", + "price": 69, + "project": "elasticsearch", + "state": "running", + "time": 1469862000000, + "username": "scole58" + }, + { + "age": 50, + "cost": 23.15, + "country": "ID", + "price": 54, + "project": "beats", + "state": "done", + "time": 1491548400000, + "username": "gramirez59" + }, + { + "age": 18, + "cost": 23.94, + "country": "CN", + "price": 48, + "project": "opbeat", + "state": "start", + "time": 1482822000000, + "username": "jruiz5a" + }, + { + "age": 48, + "cost": 23.14, + "country": "ID", + "price": 54, + "project": "x-pack", + "state": "done", + "time": 1464332400000, + "username": "jramirez5b" + }, + { + "age": 64, + "cost": 20.38, + "country": "CZ", + "price": 63, + "project": "beats", + "state": "done", + "time": 1472281200000, + "username": "ppierce5c" + }, + { + "age": 45, + "cost": 23.47, + "country": "PH", + "price": 60, + "project": "elasticsearch", + "state": "done", + "time": 1465196400000, + "username": "rfranklin5d" + }, + { + "age": 65, + "cost": 22.87, + "country": "PH", + "price": 56, + "project": "kibana", + "state": "done", + "time": 1468306800000, + "username": "aevans5e" + }, + { + "age": 18, + "cost": 23.45, + "country": "BY", + "price": 71, + "project": "opbeat", + "state": "done", + "time": 1463814000000, + "username": "gday5f" + }, + { + "age": 42, + "cost": 23.44, + "country": "PY", + "price": 43, + "project": "logstash", + "state": "done", + "time": 1478156400000, + "username": "pwilliams5g" + }, + { + "age": 19, + "cost": 23.99, + "country": "MX", + "price": 53, + "project": "machine-learning", + "state": "start", + "time": 1470380400000, + "username": "mgray5h" + }, + { + "age": 64, + "cost": 22.94, + "country": "PL", + "price": 47, + "project": "beats", + "state": "done", + "time": 1465801200000, + "username": "mcarroll5i" + }, + { + "age": 49, + "cost": 24.38, + "country": "JP", + "price": 56, + "project": "logstash", + "state": "start", + "time": 1464246000000, + "username": "dmontgomery5j" + }, + { + "age": 71, + "cost": 23.72, + "country": "BY", + "price": 50, + "project": "beats", + "state": "start", + "time": 1484463600000, + "username": "rsims5k" + }, + { + "age": 38, + "cost": 22.02, + "country": "PH", + "price": 58, + "project": "elasticsearch", + "state": "running", + "time": 1469257200000, + "username": "jgreene5l" + }, + { + "age": 43, + "cost": 23.08, + "country": "EC", + "price": 64, + "project": "beats", + "state": "done", + "time": 1480230000000, + "username": "mmills5m" + }, + { + "age": 75, + "cost": 22.96, + "country": "ID", + "price": 54, + "project": "machine-learning", + "state": "done", + "time": 1472022000000, + "username": "rmoreno5n" + }, + { + "age": 28, + "cost": 24.37, + "country": "US", + "price": 57, + "project": "logstash", + "state": "start", + "time": 1460962800000, + "username": "kcole5o" + }, + { + "age": 75, + "cost": 21.92, + "country": "JP", + "price": 52, + "project": "machine-learning", + "state": "running", + "time": 1468393200000, + "username": "nwelch5p" + }, + { + "age": 66, + "cost": 21.96, + "country": "CN", + "price": 64, + "project": "elasticsearch", + "state": "running", + "time": 1464418800000, + "username": "gburton5q" + }, + { + "age": 32, + "cost": 23.14, + "country": "VE", + "price": 54, + "project": "opbeat", + "state": "done", + "time": 1485586800000, + "username": "dfisher5r" + }, + { + "age": 45, + "cost": 21.99, + "country": "US", + "price": 44, + "project": "elasticsearch", + "state": "running", + "time": 1485068400000, + "username": "mknight5s" + }, + { + "age": 23, + "cost": 22.97, + "country": "CN", + "price": 49, + "project": "kibana", + "state": "done", + "time": 1483426800000, + "username": "pgordon5t" + }, + { + "age": 31, + "cost": 21.85, + "country": "RU", + "price": 62, + "project": "elasticsearch", + "state": "running", + "time": 1471330800000, + "username": "bpowell5u" + }, + { + "age": 28, + "cost": 21.32, + "country": "CA", + "price": 58, + "project": "logstash", + "state": "start", + "time": 1487314800000, + "username": "creed5v" + }, + { + "age": 55, + "cost": 23.47, + "country": "CN", + "price": 59, + "project": "x-pack", + "state": "done", + "time": 1468998000000, + "username": "dgreene5w" + }, + { + "age": 53, + "cost": 21.72, + "country": "ID", + "price": 57, + "project": "opbeat", + "state": "running", + "time": 1482649200000, + "username": "areynolds5x" + }, + { + "age": 68, + "cost": 22.78, + "country": "AU", + "price": 57, + "project": "machine-learning", + "state": "done", + "time": 1473750000000, + "username": "sharris5y" + }, + { + "age": 58, + "cost": 22.76, + "country": "LA", + "price": 56, + "project": "kibana", + "state": "done", + "time": 1474873200000, + "username": "jwatson5z" + }, + { + "age": 40, + "cost": 23.64, + "country": "CN", + "price": 52, + "project": "machine-learning", + "state": "start", + "time": 1490943600000, + "username": "flewis60" + }, + { + "age": 22, + "cost": 24.27, + "country": "CA", + "price": 44, + "project": "beats", + "state": "start", + "time": 1480402800000, + "username": "jdavis61" + }, + { + "age": 67, + "cost": 21.81, + "country": "ID", + "price": 48, + "project": "opbeat", + "state": "running", + "time": 1479279600000, + "username": "bmorrison62" + }, + { + "age": 66, + "cost": 24.82, + "country": "CN", + "price": 42, + "project": "elasticsearch", + "state": "running", + "time": 1482130800000, + "username": "wgordon63" + }, + { + "age": 33, + "cost": 22.6, + "country": "RU", + "price": 59, + "project": "machine-learning", + "state": "done", + "time": 1484118000000, + "username": "athompson64" + }, + { + "age": 28, + "cost": 22.56, + "country": "KZ", + "price": 52, + "project": "logstash", + "state": "done", + "time": 1467097200000, + "username": "bwright65" + }, + { + "age": 37, + "cost": 22.8, + "country": "CN", + "price": 54, + "project": "kibana", + "state": "done", + "time": 1490684400000, + "username": "jburke66" + }, + { + "age": 55, + "cost": 25.38, + "country": "CN", + "price": 41, + "project": "x-pack", + "state": "running", + "time": 1483081200000, + "username": "lmeyer67" + }, + { + "age": 47, + "cost": 22.35, + "country": "PH", + "price": 72, + "project": "machine-learning", + "state": "running", + "time": 1484031600000, + "username": "plane68" + }, + { + "age": 75, + "cost": 21.21, + "country": "AL", + "price": 64, + "project": "machine-learning", + "state": "start", + "time": 1478847600000, + "username": "nschmidt69" + }, + { + "age": 29, + "cost": 24.41, + "country": "CN", + "price": 60, + "project": "beats", + "state": "start", + "time": 1480662000000, + "username": "jdean6a" + }, + { + "age": 80, + "cost": 19.77, + "country": "CN", + "price": 64, + "project": "elasticsearch", + "state": "done", + "time": 1489993200000, + "username": "vvasquez6b" + }, + { + "age": 47, + "cost": 23.12, + "country": "CU", + "price": 53, + "project": "machine-learning", + "state": "done", + "time": 1460358000000, + "username": "jcarpenter6c" + }, + { + "age": 47, + "cost": 21.79, + "country": "VE", + "price": 52, + "project": "machine-learning", + "state": "running", + "time": 1471762800000, + "username": "ihowell6d" + }, + { + "age": 42, + "cost": 22.67, + "country": "PH", + "price": 55, + "project": "logstash", + "state": "done", + "time": 1488524400000, + "username": "lschmidt6e" + }, + { + "age": 78, + "cost": 22.2, + "country": "DE", + "price": 57, + "project": "beats", + "state": "running", + "time": 1475650800000, + "username": "lkennedy6f" + }, + { + "age": 77, + "cost": 22.19, + "country": "CN", + "price": 63, + "project": "logstash", + "state": "running", + "time": 1483945200000, + "username": "bgarza6g" + }, + { + "age": 38, + "cost": 23.47, + "country": "BR", + "price": 51, + "project": "elasticsearch", + "state": "done", + "time": 1477983600000, + "username": "rlewis6h" + }, + { + "age": 38, + "cost": 22.74, + "country": "PH", + "price": 59, + "project": "elasticsearch", + "state": "done", + "time": 1483513200000, + "username": "gwoods6i" + }, + { + "age": 77, + "cost": 22.8, + "country": "CN", + "price": 57, + "project": "logstash", + "state": "done", + "time": 1484031600000, + "username": "mcruz6j" + }, + { + "age": 23, + "cost": 22.9, + "country": "TH", + "price": 44, + "project": "kibana", + "state": "done", + "time": 1463900400000, + "username": "pthomas6k" + }, + { + "age": 47, + "cost": 23.4, + "country": "CA", + "price": 64, + "project": "machine-learning", + "state": "done", + "time": 1491548400000, + "username": "astanley6l" + }, + { + "age": 43, + "cost": 22.91, + "country": "CN", + "price": 50, + "project": "beats", + "state": "done", + "time": 1472886000000, + "username": "sharris6m" + }, + { + "age": 76, + "cost": 21.74, + "country": "ID", + "price": 55, + "project": "x-pack", + "state": "running", + "time": 1490943600000, + "username": "rreynolds6n" + }, + { + "age": 18, + "cost": 23.15, + "country": "RU", + "price": 56, + "project": "opbeat", + "state": "done", + "time": 1484550000000, + "username": "ewebb6o" + }, + { + "age": 50, + "cost": 23.16, + "country": "ID", + "price": 61, + "project": "beats", + "state": "done", + "time": 1461740400000, + "username": "tmorales6p" + }, + { + "age": 27, + "cost": 22.14, + "country": "SE", + "price": 45, + "project": "x-pack", + "state": "running", + "time": 1463727600000, + "username": "dmontgomery6q" + }, + { + "age": 23, + "cost": 24.03, + "country": "PS", + "price": 51, + "project": "kibana", + "state": "start", + "time": 1484031600000, + "username": "blynch6r" + }, + { + "age": 40, + "cost": 22.63, + "country": "CN", + "price": 70, + "project": "machine-learning", + "state": "done", + "time": 1483686000000, + "username": "fsullivan6s" + }, + { + "age": 80, + "cost": 25.11, + "country": "RU", + "price": 52, + "project": "elasticsearch", + "state": "running", + "time": 1476687600000, + "username": "jrobertson6t" + }, + { + "age": 28, + "cost": 22.92, + "country": "CN", + "price": 53, + "project": "logstash", + "state": "done", + "time": 1484031600000, + "username": "randrews6u" + }, + { + "age": 75, + "cost": 24.69, + "country": "AL", + "price": 46, + "project": "machine-learning", + "state": "running", + "time": 1462258800000, + "username": "pkim6v" + }, + { + "age": 34, + "cost": 21.66, + "country": "TH", + "price": 55, + "project": "x-pack", + "state": "running", + "time": 1490079600000, + "username": "aharris6w" + }, + { + "age": 78, + "cost": 20.89, + "country": "SI", + "price": 63, + "project": "beats", + "state": "start", + "time": 1485154800000, + "username": "dbarnes6x" + }, + { + "age": 75, + "cost": 22.08, + "country": "FR", + "price": 53, + "project": "machine-learning", + "state": "running", + "time": 1476428400000, + "username": "jhamilton6y" + }, + { + "age": 42, + "cost": 23.16, + "country": "CN", + "price": 50, + "project": "logstash", + "state": "done", + "time": 1473318000000, + "username": "lgibson6z" + }, + { + "age": 29, + "cost": 23.82, + "country": "ID", + "price": 55, + "project": "beats", + "state": "start", + "time": 1488870000000, + "username": "agordon70" + }, + { + "age": 27, + "cost": 23.25, + "country": "US", + "price": 40, + "project": "x-pack", + "state": "done", + "time": 1466319600000, + "username": "slewis71" + }, + { + "age": 67, + "cost": 23.92, + "country": "FI", + "price": 61, + "project": "opbeat", + "state": "start", + "time": 1478242800000, + "username": "slane72" + }, + { + "age": 69, + "cost": 24.53, + "country": "PH", + "price": 49, + "project": "x-pack", + "state": "running", + "time": 1484463600000, + "username": "rramos73" + }, + { + "age": 72, + "cost": 24.16, + "country": "ID", + "price": 59, + "project": "kibana", + "state": "start", + "time": 1480057200000, + "username": "dhawkins74" + }, + { + "age": 31, + "cost": 23.11, + "country": "CN", + "price": 64, + "project": "elasticsearch", + "state": "done", + "time": 1490598000000, + "username": "rlane75" + }, + { + "age": 31, + "cost": 23.41, + "country": "CN", + "price": 70, + "project": "elasticsearch", + "state": "done", + "time": 1490684400000, + "username": "pturner76" + }, + { + "age": 68, + "cost": 23.27, + "country": "JP", + "price": 57, + "project": "machine-learning", + "state": "done", + "time": 1473404400000, + "username": "dlawrence77" + }, + { + "age": 19, + "cost": 23.72, + "country": "CN", + "price": 44, + "project": "machine-learning", + "state": "start", + "time": 1469084400000, + "username": "twelch78" + }, + { + "age": 79, + "cost": 23, + "country": "PH", + "price": 69, + "project": "kibana", + "state": "done", + "time": 1468393200000, + "username": "hwilliams79" + }, + { + "age": 26, + "cost": 23.19, + "country": "CN", + "price": 58, + "project": "machine-learning", + "state": "done", + "time": 1471071600000, + "username": "tbowman7a" + }, + { + "age": 33, + "cost": 22.87, + "country": "YE", + "price": 55, + "project": "machine-learning", + "state": "done", + "time": 1474009200000, + "username": "kwheeler7b" + }, + { + "age": 35, + "cost": 21.5, + "country": "KZ", + "price": 73, + "project": "logstash", + "state": "running", + "time": 1464073200000, + "username": "cgrant7c" + }, + { + "age": 30, + "cost": 22.25, + "country": "PS", + "price": 70, + "project": "kibana", + "state": "running", + "time": 1485154800000, + "username": "cbradley7d" + }, + { + "age": 37, + "cost": 25, + "country": "CN", + "price": 63, + "project": "kibana", + "state": "running", + "time": 1460876400000, + "username": "mrichardson7e" + }, + { + "age": 66, + "cost": 22.22, + "country": "GR", + "price": 61, + "project": "elasticsearch", + "state": "running", + "time": 1479798000000, + "username": "janderson7f" + }, + { + "age": 45, + "cost": 24.05, + "country": "ID", + "price": 59, + "project": "elasticsearch", + "state": "start", + "time": 1475823600000, + "username": "lgriffin7g" + }, + { + "age": 25, + "cost": 22.98, + "country": "ID", + "price": 48, + "project": "opbeat", + "state": "done", + "time": 1484290800000, + "username": "salvarez7h" + }, + { + "age": 36, + "cost": 24.01, + "country": "ID", + "price": 59, + "project": "beats", + "state": "start", + "time": 1474354800000, + "username": "pmills7i" + }, + { + "age": 23, + "cost": 23.07, + "country": "MU", + "price": 56, + "project": "kibana", + "state": "done", + "time": 1490598000000, + "username": "jdunn7j" + }, + { + "age": 68, + "cost": 23.31, + "country": "CN", + "price": 66, + "project": "machine-learning", + "state": "done", + "time": 1491462000000, + "username": "dwilson7k" + }, + { + "age": 78, + "cost": 21.12, + "country": "PH", + "price": 54, + "project": "beats", + "state": "start", + "time": 1467874800000, + "username": "relliott7l" + }, + { + "age": 64, + "cost": 22.85, + "country": "CN", + "price": 70, + "project": "beats", + "state": "done", + "time": 1462518000000, + "username": "drichardson7m" + }, + { + "age": 29, + "cost": 23.52, + "country": "ET", + "price": 51, + "project": "beats", + "state": "start", + "time": 1484982000000, + "username": "mrussell7n" + }, + { + "age": 48, + "cost": 22.43, + "country": "KM", + "price": 61, + "project": "x-pack", + "state": "running", + "time": 1482908400000, + "username": "eharris7o" + }, + { + "age": 59, + "cost": 21.09, + "country": "ZA", + "price": 58, + "project": "elasticsearch", + "state": "start", + "time": 1480489200000, + "username": "sbryant7p" + }, + { + "age": 67, + "cost": 23.3, + "country": "KG", + "price": 54, + "project": "opbeat", + "state": "done", + "time": 1466060400000, + "username": "mchavez7q" + }, + { + "age": 67, + "cost": 22.4, + "country": "MZ", + "price": 65, + "project": "opbeat", + "state": "running", + "time": 1462086000000, + "username": "emurray7r" + }, + { + "age": 47, + "cost": 21.95, + "country": "ID", + "price": 56, + "project": "machine-learning", + "state": "running", + "time": 1479798000000, + "username": "pmoore7s" + }, + { + "age": 77, + "cost": 24.34, + "country": "SE", + "price": 45, + "project": "logstash", + "state": "start", + "time": 1463727600000, + "username": "jdavis7t" + }, + { + "age": 30, + "cost": 23.66, + "country": "CN", + "price": 61, + "project": "kibana", + "state": "start", + "time": 1482822000000, + "username": "pstephens7u" + }, + { + "age": 74, + "cost": 22.75, + "country": "BY", + "price": 53, + "project": "opbeat", + "state": "done", + "time": 1485759600000, + "username": "cbell7v" + }, + { + "age": 52, + "cost": 21.84, + "country": "CN", + "price": 48, + "project": "elasticsearch", + "state": "running", + "time": 1489906800000, + "username": "agordon7w" + }, + { + "age": 76, + "cost": 22.54, + "country": "BR", + "price": 47, + "project": "x-pack", + "state": "done", + "time": 1480143600000, + "username": "cray7x" + }, + { + "age": 55, + "cost": 21.27, + "country": "PT", + "price": 46, + "project": "x-pack", + "state": "start", + "time": 1475132400000, + "username": "llong7y" + }, + { + "age": 64, + "cost": 22.86, + "country": "CN", + "price": 69, + "project": "beats", + "state": "done", + "time": 1461826800000, + "username": "lcoleman7z" + }, + { + "age": 20, + "cost": 23.15, + "country": "ID", + "price": 70, + "project": "x-pack", + "state": "done", + "time": 1466233200000, + "username": "wpalmer80" + }, + { + "age": 31, + "cost": 22, + "country": "ZA", + "price": 61, + "project": "elasticsearch", + "state": "running", + "time": 1473750000000, + "username": "dkim81" + }, + { + "age": 38, + "cost": 22.68, + "country": "PT", + "price": 50, + "project": "elasticsearch", + "state": "done", + "time": 1468911600000, + "username": "fsanders82" + }, + { + "age": 54, + "cost": 22.14, + "country": "UA", + "price": 61, + "project": "machine-learning", + "state": "running", + "time": 1484722800000, + "username": "jmiller83" + }, + { + "age": 43, + "cost": 23.1, + "country": "ZA", + "price": 56, + "project": "beats", + "state": "done", + "time": 1461481200000, + "username": "rreyes84" + }, + { + "age": 56, + "cost": 22.14, + "country": "MN", + "price": 48, + "project": "logstash", + "state": "running", + "time": 1466751600000, + "username": "jmills85" + }, + { + "age": 77, + "cost": 22.58, + "country": "CN", + "price": 57, + "project": "logstash", + "state": "done", + "time": 1474527600000, + "username": "slopez86" + }, + { + "age": 78, + "cost": 21.71, + "country": "CN", + "price": 54, + "project": "beats", + "state": "running", + "time": 1489561200000, + "username": "rthomas87" + }, + { + "age": 23, + "cost": 21.94, + "country": "FR", + "price": 73, + "project": "kibana", + "state": "running", + "time": 1475910000000, + "username": "eharris88" + }, + { + "age": 68, + "cost": 23.13, + "country": "PH", + "price": 57, + "project": "machine-learning", + "state": "done", + "time": 1462950000000, + "username": "sjackson89" + }, + { + "age": 52, + "cost": 23.07, + "country": "GR", + "price": 52, + "project": "elasticsearch", + "state": "done", + "time": 1481353200000, + "username": "preynolds8a" + }, + { + "age": 46, + "cost": 24.87, + "country": "SE", + "price": 51, + "project": "opbeat", + "state": "running", + "time": 1465196400000, + "username": "bmorris8b" + }, + { + "age": 58, + "cost": 23.7, + "country": "ID", + "price": 53, + "project": "kibana", + "state": "start", + "time": 1485068400000, + "username": "rburns8c" + }, + { + "age": 64, + "cost": 22.76, + "country": "IR", + "price": 53, + "project": "beats", + "state": "done", + "time": 1481526000000, + "username": "jsimpson8d" + }, + { + "age": 29, + "cost": 24.01, + "country": "PH", + "price": 61, + "project": "beats", + "state": "start", + "time": 1476946800000, + "username": "dcarter8e" + }, + { + "age": 42, + "cost": 23.06, + "country": "CN", + "price": 57, + "project": "logstash", + "state": "done", + "time": 1489561200000, + "username": "jfisher8f" + }, + { + "age": 60, + "cost": 23.29, + "country": "RU", + "price": 49, + "project": "opbeat", + "state": "done", + "time": 1461740400000, + "username": "kramirez8g" + }, + { + "age": 77, + "cost": 23.35, + "country": "BR", + "price": 53, + "project": "logstash", + "state": "done", + "time": 1479279600000, + "username": "jowens8h" + }, + { + "age": 55, + "cost": 22.16, + "country": "PT", + "price": 58, + "project": "x-pack", + "state": "running", + "time": 1461567600000, + "username": "gweaver8i" + }, + { + "age": 70, + "cost": 21.65, + "country": "JM", + "price": 57, + "project": "logstash", + "state": "running", + "time": 1481958000000, + "username": "jharrison8j" + }, + { + "age": 76, + "cost": 22.98, + "country": "HN", + "price": 51, + "project": "x-pack", + "state": "done", + "time": 1466492400000, + "username": "chenderson8k" + }, + { + "age": 63, + "cost": 24.31, + "country": "PH", + "price": 61, + "project": "logstash", + "state": "start", + "time": 1472886000000, + "username": "lreynolds8l" + }, + { + "age": 18, + "cost": 23.62, + "country": "MA", + "price": 82, + "project": "opbeat", + "state": "start", + "time": 1474095600000, + "username": "ljacobs8m" + }, + { + "age": 34, + "cost": 24.18, + "country": "CN", + "price": 57, + "project": "x-pack", + "state": "start", + "time": 1476082800000, + "username": "pwheeler8n" + }, + { + "age": 50, + "cost": 22.96, + "country": "JP", + "price": 61, + "project": "beats", + "state": "done", + "time": 1480834800000, + "username": "lbrown8o" + }, + { + "age": 49, + "cost": 22.47, + "country": "FI", + "price": 49, + "project": "logstash", + "state": "running", + "time": 1463295600000, + "username": "jgibson8p" + }, + { + "age": 36, + "cost": 23.41, + "country": "MN", + "price": 60, + "project": "beats", + "state": "done", + "time": 1472626800000, + "username": "jfernandez8q" + }, + { + "age": 36, + "cost": 23.26, + "country": "GH", + "price": 55, + "project": "beats", + "state": "done", + "time": 1469602800000, + "username": "swatkins8r" + }, + { + "age": 33, + "cost": 22.15, + "country": "CN", + "price": 52, + "project": "machine-learning", + "state": "running", + "time": 1472972400000, + "username": "kalvarez8s" + }, + { + "age": 69, + "cost": 21, + "country": "FR", + "price": 65, + "project": "x-pack", + "state": "start", + "time": 1470639600000, + "username": "jwarren8t" + }, + { + "age": 68, + "cost": 23.57, + "country": "MN", + "price": 51, + "project": "machine-learning", + "state": "start", + "time": 1466838000000, + "username": "mwallace8u" + }, + { + "age": 31, + "cost": 23.94, + "country": "US", + "price": 54, + "project": "elasticsearch", + "state": "start", + "time": 1475996400000, + "username": "jmarshall8v" + }, + { + "age": 73, + "cost": 24.4, + "country": "RU", + "price": 52, + "project": "elasticsearch", + "state": "start", + "time": 1462431600000, + "username": "aellis8w" + }, + { + "age": 25, + "cost": 22.34, + "country": "PH", + "price": 68, + "project": "opbeat", + "state": "running", + "time": 1468047600000, + "username": "clopez8x" + }, + { + "age": 35, + "cost": 21.65, + "country": "CA", + "price": 63, + "project": "logstash", + "state": "running", + "time": 1474268400000, + "username": "astewart8y" + }, + { + "age": 34, + "cost": 22.75, + "country": "PL", + "price": 71, + "project": "x-pack", + "state": "done", + "time": 1469516400000, + "username": "hlewis8z" + }, + { + "age": 56, + "cost": 22.22, + "country": "MN", + "price": 54, + "project": "logstash", + "state": "running", + "time": 1480057200000, + "username": "wwheeler90" + }, + { + "age": 52, + "cost": 22.04, + "country": "AR", + "price": 49, + "project": "elasticsearch", + "state": "running", + "time": 1490338800000, + "username": "aelliott91" + }, + { + "age": 61, + "cost": 21.27, + "country": "PH", + "price": 55, + "project": "machine-learning", + "state": "start", + "time": 1485241200000, + "username": "khunter92" + }, + { + "age": 23, + "cost": 24.3, + "country": "PL", + "price": 59, + "project": "kibana", + "state": "start", + "time": 1477983600000, + "username": "treed93" + }, + { + "age": 54, + "cost": 23.5, + "country": "UA", + "price": 60, + "project": "machine-learning", + "state": "start", + "time": 1471590000000, + "username": "jperez94" + }, + { + "age": 80, + "cost": 22.4, + "country": "CN", + "price": 57, + "project": "elasticsearch", + "state": "running", + "time": 1468825200000, + "username": "tfowler95" + }, + { + "age": 80, + "cost": 20.93, + "country": "VE", + "price": 49, + "project": "elasticsearch", + "state": "start", + "time": 1474614000000, + "username": "brichardson96" + }, + { + "age": 59, + "cost": 23.28, + "country": "PT", + "price": 51, + "project": "elasticsearch", + "state": "done", + "time": 1488956400000, + "username": "kthomas97" + }, + { + "age": 49, + "cost": 23.26, + "country": "CA", + "price": 54, + "project": "logstash", + "state": "done", + "time": 1487487600000, + "username": "smason98" + }, + { + "age": 75, + "cost": 21.54, + "country": "ID", + "price": 59, + "project": "machine-learning", + "state": "running", + "time": 1472886000000, + "username": "aphillips99" + }, + { + "age": 40, + "cost": 23.23, + "country": "ID", + "price": 54, + "project": "machine-learning", + "state": "done", + "time": 1488006000000, + "username": "jlawrence9a" + }, + { + "age": 58, + "cost": 22.96, + "country": "TZ", + "price": 56, + "project": "kibana", + "state": "done", + "time": 1463036400000, + "username": "rcunningham9b" + }, + { + "age": 61, + "cost": 22.6, + "country": "PL", + "price": 64, + "project": "machine-learning", + "state": "done", + "time": 1476169200000, + "username": "aaustin9c" + }, + { + "age": 68, + "cost": 22.62, + "country": "GT", + "price": 59, + "project": "machine-learning", + "state": "done", + "time": 1464159600000, + "username": "pthompson9d" + }, + { + "age": 59, + "cost": 23.35, + "country": "GR", + "price": 52, + "project": "elasticsearch", + "state": "done", + "time": 1490511600000, + "username": "lscott9e" + }, + { + "age": 40, + "cost": 24.08, + "country": "LA", + "price": 50, + "project": "machine-learning", + "state": "start", + "time": 1471935600000, + "username": "emartinez9f" + }, + { + "age": 42, + "cost": 22.75, + "country": "HR", + "price": 70, + "project": "logstash", + "state": "done", + "time": 1462431600000, + "username": "kbanks9g" + }, + { + "age": 36, + "cost": 21.85, + "country": "BR", + "price": 45, + "project": "beats", + "state": "running", + "time": 1483599600000, + "username": "njames9h" + }, + { + "age": 31, + "cost": 23.26, + "country": "MX", + "price": 51, + "project": "elasticsearch", + "state": "done", + "time": 1463468400000, + "username": "cjordan9i" + }, + { + "age": 74, + "cost": 22.49, + "country": "BR", + "price": 53, + "project": "opbeat", + "state": "running", + "time": 1472626800000, + "username": "thowell9j" + }, + { + "age": 71, + "cost": 23.16, + "country": "ID", + "price": 47, + "project": "beats", + "state": "done", + "time": 1465714800000, + "username": "aramirez9k" + }, + { + "age": 19, + "cost": 21.01, + "country": "VE", + "price": 63, + "project": "machine-learning", + "state": "start", + "time": 1489129200000, + "username": "cgreen9l" + }, + { + "age": 41, + "cost": 23.07, + "country": "FR", + "price": 56, + "project": "x-pack", + "state": "done", + "time": 1463209200000, + "username": "fgardner9m" + }, + { + "age": 70, + "cost": 21.69, + "country": "UA", + "price": 65, + "project": "logstash", + "state": "running", + "time": 1474614000000, + "username": "bbennett9n" + }, + { + "age": 27, + "cost": 21.84, + "country": "SR", + "price": 65, + "project": "x-pack", + "state": "running", + "time": 1464591600000, + "username": "nferguson9o" + }, + { + "age": 27, + "cost": 22.63, + "country": "MX", + "price": 53, + "project": "x-pack", + "state": "done", + "time": 1481958000000, + "username": "rpatterson9p" + }, + { + "age": 71, + "cost": 23.16, + "country": "CN", + "price": 54, + "project": "beats", + "state": "done", + "time": 1469084400000, + "username": "twright9q" + }, + { + "age": 70, + "cost": 23.34, + "country": "PL", + "price": 49, + "project": "logstash", + "state": "done", + "time": 1482649200000, + "username": "hhughes9r" + }, + { + "age": 60, + "cost": 22.06, + "country": "MX", + "price": 59, + "project": "opbeat", + "state": "running", + "time": 1460617200000, + "username": "rjenkins9s" + }, + { + "age": 31, + "cost": 23.14, + "country": "BR", + "price": 59, + "project": "elasticsearch", + "state": "done", + "time": 1480662000000, + "username": "areynolds9t" + }, + { + "age": 75, + "cost": 23.13, + "country": "RU", + "price": 50, + "project": "machine-learning", + "state": "done", + "time": 1473318000000, + "username": "agordon9u" + }, + { + "age": 43, + "cost": 23.58, + "country": "CN", + "price": 52, + "project": "beats", + "state": "start", + "time": 1480057200000, + "username": "bmorris9v" + }, + { + "age": 55, + "cost": 23.86, + "country": "CN", + "price": 57, + "project": "x-pack", + "state": "start", + "time": 1469602800000, + "username": "hriley9w" + }, + { + "age": 48, + "cost": 23.28, + "country": "PH", + "price": 55, + "project": "x-pack", + "state": "done", + "time": 1465369200000, + "username": "psmith9x" + }, + { + "age": 36, + "cost": 23.87, + "country": "CA", + "price": 70, + "project": "beats", + "state": "start", + "time": 1466751600000, + "username": "dwilson9y" + }, + { + "age": 46, + "cost": 22.45, + "country": "RU", + "price": 53, + "project": "opbeat", + "state": "running", + "time": 1482822000000, + "username": "rsanders9z" + }, + { + "age": 61, + "cost": 23.43, + "country": "CN", + "price": 66, + "project": "machine-learning", + "state": "done", + "time": 1480143600000, + "username": "hharta0" + }, + { + "age": 51, + "cost": 23.73, + "country": "US", + "price": 56, + "project": "kibana", + "state": "start", + "time": 1468738800000, + "username": "jsullivana1" + }, + { + "age": 69, + "cost": 22.33, + "country": "ES", + "price": 55, + "project": "x-pack", + "state": "running", + "time": 1462258800000, + "username": "jrobertsona2" + }, + { + "age": 69, + "cost": 22.83, + "country": "CN", + "price": 57, + "project": "x-pack", + "state": "done", + "time": 1485759600000, + "username": "jkennedya3" + }, + { + "age": 32, + "cost": 21.46, + "country": "AR", + "price": 59, + "project": "opbeat", + "state": "start", + "time": 1466406000000, + "username": "crobertsa4" + }, + { + "age": 32, + "cost": 21.75, + "country": "NA", + "price": 49, + "project": "opbeat", + "state": "running", + "time": 1483945200000, + "username": "jcolea5" + }, + { + "age": 74, + "cost": 22.56, + "country": "PL", + "price": 55, + "project": "opbeat", + "state": "done", + "time": 1468825200000, + "username": "llonga6" + }, + { + "age": 70, + "cost": 23.63, + "country": "ID", + "price": 64, + "project": "logstash", + "state": "start", + "time": 1460530800000, + "username": "sgraya7" + }, + { + "age": 48, + "cost": 22.04, + "country": "AL", + "price": 49, + "project": "x-pack", + "state": "running", + "time": 1468306800000, + "username": "jgeorgea8" + }, + { + "age": 61, + "cost": 24.58, + "country": "SS", + "price": 59, + "project": "machine-learning", + "state": "running", + "time": 1486364400000, + "username": "ablacka9" + }, + { + "age": 27, + "cost": 23.69, + "country": "RU", + "price": 54, + "project": "x-pack", + "state": "start", + "time": 1461999600000, + "username": "jrileyaa" + }, + { + "age": 35, + "cost": 24.23, + "country": "CN", + "price": 62, + "project": "logstash", + "state": "start", + "time": 1466924400000, + "username": "jwalkerab" + }, + { + "age": 26, + "cost": 23.34, + "country": "PL", + "price": 58, + "project": "machine-learning", + "state": "done", + "time": 1486710000000, + "username": "vrussellac" + }, + { + "age": 27, + "cost": 22.1, + "country": "ID", + "price": 56, + "project": "x-pack", + "state": "running", + "time": 1478761200000, + "username": "acooperad" + }, + { + "age": 29, + "cost": 24.27, + "country": "MD", + "price": 55, + "project": "beats", + "state": "start", + "time": 1471244400000, + "username": "rhansonae" + }, + { + "age": 55, + "cost": 23.33, + "country": "SE", + "price": 60, + "project": "x-pack", + "state": "done", + "time": 1461826800000, + "username": "wmooreaf" + }, + { + "age": 33, + "cost": 23.42, + "country": "CO", + "price": 54, + "project": "machine-learning", + "state": "done", + "time": 1463986800000, + "username": "hmccoyag" + }, + { + "age": 75, + "cost": 24.45, + "country": "HR", + "price": 40, + "project": "machine-learning", + "state": "start", + "time": 1491462000000, + "username": "cbarnesah" + }, + { + "age": 60, + "cost": 22.59, + "country": "PT", + "price": 58, + "project": "opbeat", + "state": "done", + "time": 1469430000000, + "username": "jharrisai" + }, + { + "age": 58, + "cost": 21.94, + "country": "CN", + "price": 64, + "project": "kibana", + "state": "running", + "time": 1474441200000, + "username": "swardaj" + }, + { + "age": 51, + "cost": 20.57, + "country": "CN", + "price": 65, + "project": "kibana", + "state": "start", + "time": 1482217200000, + "username": "jhuntak" + }, + { + "age": 36, + "cost": 21.7, + "country": "CN", + "price": 52, + "project": "beats", + "state": "running", + "time": 1461481200000, + "username": "gmartinezal" + }, + { + "age": 19, + "cost": 22.56, + "country": "PA", + "price": 46, + "project": "machine-learning", + "state": "done", + "time": 1484463600000, + "username": "sturneram" + }, + { + "age": 40, + "cost": 23.12, + "country": "RU", + "price": 65, + "project": "machine-learning", + "state": "done", + "time": 1486537200000, + "username": "jortizan" + }, + { + "age": 73, + "cost": 22.32, + "country": "SI", + "price": 49, + "project": "elasticsearch", + "state": "running", + "time": 1467356400000, + "username": "gwatsonao" + }, + { + "age": 38, + "cost": 23.42, + "country": "UA", + "price": 56, + "project": "elasticsearch", + "state": "done", + "time": 1482476400000, + "username": "ckingap" + }, + { + "age": 32, + "cost": 23.07, + "country": "NO", + "price": 66, + "project": "opbeat", + "state": "done", + "time": 1488956400000, + "username": "nfreemanaq" + }, + { + "age": 21, + "cost": 21.13, + "country": "UY", + "price": 66, + "project": "logstash", + "state": "start", + "time": 1473058800000, + "username": "vandrewsar" + }, + { + "age": 46, + "cost": 24.48, + "country": "PH", + "price": 68, + "project": "opbeat", + "state": "start", + "time": 1472540400000, + "username": "jgonzalezas" + }, + { + "age": 72, + "cost": 23.11, + "country": "CI", + "price": 61, + "project": "kibana", + "state": "done", + "time": 1481180400000, + "username": "vkingat" + }, + { + "age": 75, + "cost": 22.27, + "country": "GH", + "price": 43, + "project": "machine-learning", + "state": "running", + "time": 1468047600000, + "username": "rdeanau" + }, + { + "age": 29, + "cost": 22.41, + "country": "ID", + "price": 63, + "project": "beats", + "state": "running", + "time": 1481526000000, + "username": "hfosterav" + }, + { + "age": 75, + "cost": 21.44, + "country": "SE", + "price": 63, + "project": "machine-learning", + "state": "start", + "time": 1475564400000, + "username": "fgarciaaw" + }, + { + "age": 44, + "cost": 23.87, + "country": "PH", + "price": 62, + "project": "kibana", + "state": "start", + "time": 1464937200000, + "username": "pwhiteax" + }, + { + "age": 53, + "cost": 22.79, + "country": "CN", + "price": 47, + "project": "opbeat", + "state": "done", + "time": 1491116400000, + "username": "chuntay" + }, + { + "age": 47, + "cost": 22.86, + "country": "CN", + "price": 60, + "project": "machine-learning", + "state": "done", + "time": 1478588400000, + "username": "dfranklinaz" + }, + { + "age": 71, + "cost": 24, + "country": "CZ", + "price": 64, + "project": "beats", + "state": "start", + "time": 1480230000000, + "username": "djacksonb0" + }, + { + "age": 44, + "cost": 21.93, + "country": "RU", + "price": 67, + "project": "kibana", + "state": "running", + "time": 1460358000000, + "username": "sbutlerb1" + }, + { + "age": 32, + "cost": 23.24, + "country": "GR", + "price": 41, + "project": "opbeat", + "state": "done", + "time": 1460962800000, + "username": "nporterb2" + }, + { + "age": 55, + "cost": 22.31, + "country": "RU", + "price": 65, + "project": "x-pack", + "state": "running", + "time": 1483167600000, + "username": "sburnsb3" + }, + { + "age": 59, + "cost": 23.16, + "country": "JP", + "price": 56, + "project": "elasticsearch", + "state": "done", + "time": 1468911600000, + "username": "jhendersonb4" + }, + { + "age": 73, + "cost": 22.79, + "country": "FR", + "price": 63, + "project": "elasticsearch", + "state": "done", + "time": 1479020400000, + "username": "dgonzalesb5" + }, + { + "age": 41, + "cost": 24.15, + "country": "ID", + "price": 64, + "project": "x-pack", + "state": "start", + "time": 1478674800000, + "username": "cbarnesb6" + }, + { + "age": 25, + "cost": 20.11, + "country": "ID", + "price": 58, + "project": "opbeat", + "state": "done", + "time": 1468652400000, + "username": "mcoxb7" + }, + { + "age": 74, + "cost": 24.27, + "country": "CN", + "price": 52, + "project": "opbeat", + "state": "start", + "time": 1478329200000, + "username": "rbowmanb8" + }, + { + "age": 43, + "cost": 23.9, + "country": "PE", + "price": 51, + "project": "beats", + "state": "start", + "time": 1472972400000, + "username": "dkingb9" + }, + { + "age": 33, + "cost": 21.86, + "country": "CN", + "price": 56, + "project": "machine-learning", + "state": "running", + "time": 1470898800000, + "username": "dwilliamsonba" + }, + { + "age": 32, + "cost": 21.96, + "country": "BR", + "price": 51, + "project": "opbeat", + "state": "running", + "time": 1479452400000, + "username": "jmorrisonbb" + }, + { + "age": 39, + "cost": 22.6, + "country": "PE", + "price": 58, + "project": "opbeat", + "state": "done", + "time": 1467529200000, + "username": "dcastillobc" + }, + { + "age": 30, + "cost": 22.08, + "country": "CN", + "price": 47, + "project": "kibana", + "state": "running", + "time": 1482822000000, + "username": "rgriffinbd" + }, + { + "age": 79, + "cost": 21.6, + "country": "VN", + "price": 49, + "project": "kibana", + "state": "running", + "time": 1479193200000, + "username": "ascottbe" + }, + { + "age": 71, + "cost": 21.06, + "country": "TH", + "price": 57, + "project": "beats", + "state": "start", + "time": 1475132400000, + "username": "dlynchbf" + }, + { + "age": 49, + "cost": 22.18, + "country": "ID", + "price": 54, + "project": "logstash", + "state": "running", + "time": 1471158000000, + "username": "epetersbg" + }, + { + "age": 53, + "cost": 22.57, + "country": "LU", + "price": 57, + "project": "opbeat", + "state": "done", + "time": 1477897200000, + "username": "agonzalezbh" + }, + { + "age": 23, + "cost": 24.78, + "country": "CN", + "price": 64, + "project": "kibana", + "state": "running", + "time": 1463814000000, + "username": "kcookbi" + }, + { + "age": 74, + "cost": 21.34, + "country": "WS", + "price": 47, + "project": "opbeat", + "state": "start", + "time": 1491548400000, + "username": "rjacksonbj" + }, + { + "age": 35, + "cost": 23.38, + "country": "RU", + "price": 50, + "project": "logstash", + "state": "done", + "time": 1475391600000, + "username": "cwellsbk" + }, + { + "age": 22, + "cost": 25.07, + "country": "PH", + "price": 59, + "project": "beats", + "state": "running", + "time": 1480748400000, + "username": "rgarzabl" + }, + { + "age": 37, + "cost": 23.48, + "country": "DK", + "price": 53, + "project": "kibana", + "state": "done", + "time": 1462431600000, + "username": "rramirezbm" + }, + { + "age": 66, + "cost": 24.11, + "country": "ID", + "price": 60, + "project": "elasticsearch", + "state": "start", + "time": 1489388400000, + "username": "jperezbn" + }, + { + "age": 33, + "cost": 22.4, + "country": "PL", + "price": 54, + "project": "machine-learning", + "state": "running", + "time": 1468998000000, + "username": "kricebo" + }, + { + "age": 23, + "cost": 24.47, + "country": "ID", + "price": 73, + "project": "kibana", + "state": "start", + "time": 1487833200000, + "username": "smoorebp" + }, + { + "age": 60, + "cost": 25.35, + "country": "PL", + "price": 57, + "project": "opbeat", + "state": "running", + "time": 1478242800000, + "username": "pwhitebq" + }, + { + "age": 75, + "cost": 22.58, + "country": "CN", + "price": 42, + "project": "machine-learning", + "state": "done", + "time": 1483340400000, + "username": "tleebr" + }, + { + "age": 18, + "cost": 23.18, + "country": "UG", + "price": 53, + "project": "opbeat", + "state": "done", + "time": 1467356400000, + "username": "bfrazierbs" + }, + { + "age": 57, + "cost": 23.39, + "country": "RU", + "price": 51, + "project": "beats", + "state": "done", + "time": 1482217200000, + "username": "salvarezbt" + }, + { + "age": 23, + "cost": 23.31, + "country": "CN", + "price": 45, + "project": "kibana", + "state": "done", + "time": 1490770800000, + "username": "bgonzalezbu" + }, + { + "age": 65, + "cost": 22.43, + "country": "CZ", + "price": 52, + "project": "kibana", + "state": "running", + "time": 1479711600000, + "username": "kdavisbv" + }, + { + "age": 31, + "cost": 23.73, + "country": "NI", + "price": 51, + "project": "elasticsearch", + "state": "start", + "time": 1485241200000, + "username": "jburtonbw" + }, + { + "age": 50, + "cost": 22.05, + "country": "KM", + "price": 57, + "project": "beats", + "state": "running", + "time": 1482476400000, + "username": "dgutierrezbx" + }, + { + "age": 47, + "cost": 22.76, + "country": "BR", + "price": 39, + "project": "machine-learning", + "state": "done", + "time": 1461567600000, + "username": "akelleyby" + }, + { + "age": 31, + "cost": 22.57, + "country": "DK", + "price": 48, + "project": "elasticsearch", + "state": "done", + "time": 1488438000000, + "username": "grobertsbz" + }, + { + "age": 21, + "cost": 23.62, + "country": "CN", + "price": 65, + "project": "logstash", + "state": "start", + "time": 1487142000000, + "username": "aweaverc0" + }, + { + "age": 50, + "cost": 23.85, + "country": "SE", + "price": 51, + "project": "beats", + "state": "start", + "time": 1491202800000, + "username": "charrisonc1" + }, + { + "age": 38, + "cost": 23.17, + "country": "PL", + "price": 52, + "project": "elasticsearch", + "state": "done", + "time": 1461826800000, + "username": "jlewisc2" + }, + { + "age": 47, + "cost": 22.21, + "country": "JP", + "price": 47, + "project": "machine-learning", + "state": "running", + "time": 1487487600000, + "username": "schavezc3" + }, + { + "age": 42, + "cost": 22.45, + "country": "BR", + "price": 56, + "project": "logstash", + "state": "running", + "time": 1462518000000, + "username": "acoxc4" + }, + { + "age": 52, + "cost": 21.66, + "country": "NG", + "price": 58, + "project": "elasticsearch", + "state": "running", + "time": 1470380400000, + "username": "jsanchezc5" + }, + { + "age": 79, + "cost": 23.36, + "country": "RU", + "price": 59, + "project": "kibana", + "state": "done", + "time": 1469343600000, + "username": "gpricec6" + }, + { + "age": 60, + "cost": 22.5, + "country": "CN", + "price": 51, + "project": "opbeat", + "state": "done", + "time": 1466492400000, + "username": "tgarrettc7" + }, + { + "age": 30, + "cost": 22.33, + "country": "RU", + "price": 53, + "project": "kibana", + "state": "running", + "time": 1464073200000, + "username": "llawrencec8" + }, + { + "age": 60, + "cost": 22.6, + "country": "RU", + "price": 47, + "project": "opbeat", + "state": "done", + "time": 1472540400000, + "username": "mgordonc9" + }, + { + "age": 36, + "cost": 23.62, + "country": "PL", + "price": 65, + "project": "beats", + "state": "start", + "time": 1486710000000, + "username": "jmendozaca" + }, + { + "age": 69, + "cost": 23.58, + "country": "CN", + "price": 58, + "project": "x-pack", + "state": "start", + "time": 1490857200000, + "username": "dsnydercb" + }, + { + "age": 20, + "cost": 22.55, + "country": "RU", + "price": 66, + "project": "x-pack", + "state": "done", + "time": 1473663600000, + "username": "pclarkcc" + }, + { + "age": 69, + "cost": 23.92, + "country": "PH", + "price": 60, + "project": "x-pack", + "state": "start", + "time": 1483426800000, + "username": "bkennedycd" + }, + { + "age": 73, + "cost": 23.46, + "country": "PH", + "price": 59, + "project": "elasticsearch", + "state": "done", + "time": 1476255600000, + "username": "gwalkerce" + }, + { + "age": 28, + "cost": 25.75, + "country": "ID", + "price": 50, + "project": "logstash", + "state": "done", + "time": 1475737200000, + "username": "bruizcf" + }, + { + "age": 21, + "cost": 22.5, + "country": "CN", + "price": 44, + "project": "logstash", + "state": "done", + "time": 1465196400000, + "username": "aflorescg" + }, + { + "age": 70, + "cost": 20.85, + "country": "MX", + "price": 47, + "project": "logstash", + "state": "start", + "time": 1472108400000, + "username": "eberrych" + }, + { + "age": 79, + "cost": 22.12, + "country": "SO", + "price": 57, + "project": "kibana", + "state": "running", + "time": 1486364400000, + "username": "ahudsonci" + }, + { + "age": 38, + "cost": 23.02, + "country": "ID", + "price": 50, + "project": "elasticsearch", + "state": "done", + "time": 1487660400000, + "username": "khawkinscj" + }, + { + "age": 38, + "cost": 23.25, + "country": "RU", + "price": 47, + "project": "elasticsearch", + "state": "done", + "time": 1486969200000, + "username": "mwagnerck" + }, + { + "age": 64, + "cost": 22.15, + "country": "CN", + "price": 57, + "project": "beats", + "state": "running", + "time": 1481353200000, + "username": "kbradleycl" + }, + { + "age": 36, + "cost": 24.74, + "country": "CN", + "price": 52, + "project": "beats", + "state": "running", + "time": 1468134000000, + "username": "ejenkinscm" + }, + { + "age": 19, + "cost": 22.69, + "country": "ZM", + "price": 59, + "project": "machine-learning", + "state": "done", + "time": 1476601200000, + "username": "cruizcn" + }, + { + "age": 53, + "cost": 25.07, + "country": "BR", + "price": 43, + "project": "opbeat", + "state": "running", + "time": 1487487600000, + "username": "ljamesco" + }, + { + "age": 26, + "cost": 23.43, + "country": "AE", + "price": 57, + "project": "machine-learning", + "state": "done", + "time": 1470121200000, + "username": "kcarrollcp" + }, + { + "age": 44, + "cost": 21.62, + "country": "US", + "price": 69, + "project": "kibana", + "state": "running", + "time": 1470034800000, + "username": "mbryantcq" + }, + { + "age": 40, + "cost": 24.76, + "country": "DE", + "price": 40, + "project": "machine-learning", + "state": "running", + "time": 1490770800000, + "username": "jcrawfordcr" + }, + { + "age": 23, + "cost": 24.06, + "country": "JP", + "price": 67, + "project": "kibana", + "state": "start", + "time": 1472281200000, + "username": "bsimscs" + }, + { + "age": 23, + "cost": 22.84, + "country": "ID", + "price": 50, + "project": "kibana", + "state": "done", + "time": 1474268400000, + "username": "bphillipsct" + }, + { + "age": 79, + "cost": 22.51, + "country": "ID", + "price": 71, + "project": "kibana", + "state": "done", + "time": 1482562800000, + "username": "jortizcu" + }, + { + "age": 26, + "cost": 22.11, + "country": "ID", + "price": 57, + "project": "machine-learning", + "state": "running", + "time": 1471503600000, + "username": "dmartinezcv" + }, + { + "age": 61, + "cost": 21.3, + "country": "BR", + "price": 61, + "project": "machine-learning", + "state": "start", + "time": 1465455600000, + "username": "mgordoncw" + }, + { + "age": 68, + "cost": 23.92, + "country": "CN", + "price": 49, + "project": "machine-learning", + "state": "start", + "time": 1488092400000, + "username": "amasoncx" + }, + { + "age": 26, + "cost": 24.56, + "country": "UG", + "price": 66, + "project": "machine-learning", + "state": "running", + "time": 1462086000000, + "username": "whowellcy" + }, + { + "age": 67, + "cost": 24.98, + "country": "MY", + "price": 45, + "project": "opbeat", + "state": "running", + "time": 1479452400000, + "username": "bhuntcz" + }, + { + "age": 49, + "cost": 23.13, + "country": "ID", + "price": 60, + "project": "logstash", + "state": "done", + "time": 1460358000000, + "username": "agardnerd0" + }, + { + "age": 18, + "cost": 22.52, + "country": "ET", + "price": 48, + "project": "opbeat", + "state": "done", + "time": 1474700400000, + "username": "nduncand1" + }, + { + "age": 35, + "cost": 23.12, + "country": "MY", + "price": 53, + "project": "logstash", + "state": "done", + "time": 1491289200000, + "username": "mhernandezd2" + }, + { + "age": 54, + "cost": 23.35, + "country": "CN", + "price": 48, + "project": "machine-learning", + "state": "done", + "time": 1461394800000, + "username": "rcoled3" + }, + { + "age": 23, + "cost": 23.54, + "country": "NO", + "price": 48, + "project": "kibana", + "state": "start", + "time": 1474700400000, + "username": "jmarshalld4" + }, + { + "age": 66, + "cost": 22.95, + "country": "JM", + "price": 75, + "project": "elasticsearch", + "state": "done", + "time": 1482044400000, + "username": "phuntd5" + }, + { + "age": 29, + "cost": 21.87, + "country": "PH", + "price": 59, + "project": "beats", + "state": "running", + "time": 1475737200000, + "username": "adixond6" + }, + { + "age": 48, + "cost": 23.39, + "country": "CN", + "price": 58, + "project": "x-pack", + "state": "done", + "time": 1489734000000, + "username": "bgutierrezd7" + }, + { + "age": 65, + "cost": 21.78, + "country": "UA", + "price": 57, + "project": "kibana", + "state": "running", + "time": 1463382000000, + "username": "hnelsond8" + }, + { + "age": 30, + "cost": 22.99, + "country": "GY", + "price": 51, + "project": "kibana", + "state": "done", + "time": 1460271600000, + "username": "acollinsd9" + }, + { + "age": 33, + "cost": 23.3, + "country": "VN", + "price": 57, + "project": "machine-learning", + "state": "done", + "time": 1490770800000, + "username": "tmillerda" + }, + { + "age": 26, + "cost": 22.67, + "country": "JP", + "price": 44, + "project": "machine-learning", + "state": "done", + "time": 1480402800000, + "username": "tmasondb" + }, + { + "age": 69, + "cost": 24.17, + "country": "PL", + "price": 72, + "project": "x-pack", + "state": "start", + "time": 1477551600000, + "username": "baustindc" + }, + { + "age": 53, + "cost": 22.38, + "country": "TH", + "price": 50, + "project": "opbeat", + "state": "running", + "time": 1489129200000, + "username": "dgriffindd" + }, + { + "age": 61, + "cost": 22.69, + "country": "AL", + "price": 48, + "project": "machine-learning", + "state": "done", + "time": 1467874800000, + "username": "dryande" + }, + { + "age": 57, + "cost": 23.94, + "country": "CN", + "price": 46, + "project": "beats", + "state": "start", + "time": 1460876400000, + "username": "kmurraydf" + }, + { + "age": 53, + "cost": 22.52, + "country": "ID", + "price": 42, + "project": "opbeat", + "state": "done", + "time": 1480057200000, + "username": "hdiazdg" + }, + { + "age": 64, + "cost": 22.93, + "country": "LT", + "price": 57, + "project": "beats", + "state": "done", + "time": 1483081200000, + "username": "anicholsdh" + }, + { + "age": 73, + "cost": 21.81, + "country": "US", + "price": 49, + "project": "elasticsearch", + "state": "running", + "time": 1477724400000, + "username": "wgreendi" + }, + { + "age": 47, + "cost": 24.86, + "country": "BR", + "price": 45, + "project": "machine-learning", + "state": "running", + "time": 1483513200000, + "username": "anicholsdj" + }, + { + "age": 57, + "cost": 21.42, + "country": "MN", + "price": 69, + "project": "beats", + "state": "start", + "time": 1483340400000, + "username": "fstevensdk" + }, + { + "age": 78, + "cost": 22.55, + "country": "CN", + "price": 58, + "project": "beats", + "state": "done", + "time": 1472454000000, + "username": "wfrazierdl" + }, + { + "age": 43, + "cost": 22.22, + "country": "ID", + "price": 57, + "project": "beats", + "state": "running", + "time": 1475305200000, + "username": "hbelldm" + }, + { + "age": 72, + "cost": 22.9, + "country": "CN", + "price": 47, + "project": "kibana", + "state": "done", + "time": 1480834800000, + "username": "edixondn" + }, + { + "age": 40, + "cost": 21.5, + "country": "AR", + "price": 57, + "project": "machine-learning", + "state": "running", + "time": 1480662000000, + "username": "kraydo" + }, + { + "age": 36, + "cost": 23.45, + "country": "RU", + "price": 43, + "project": "beats", + "state": "done", + "time": 1485241200000, + "username": "nmurphydp" + }, + { + "age": 56, + "cost": 23.03, + "country": "KR", + "price": 62, + "project": "logstash", + "state": "done", + "time": 1483426800000, + "username": "ssimsdq" + }, + { + "age": 18, + "cost": 22.57, + "country": "ID", + "price": 52, + "project": "opbeat", + "state": "done", + "time": 1485414000000, + "username": "brusselldr" + }, + { + "age": 41, + "cost": 21.47, + "country": "AL", + "price": 56, + "project": "x-pack", + "state": "start", + "time": 1477897200000, + "username": "mmillsds" + }, + { + "age": 46, + "cost": 21.8, + "country": "KE", + "price": 74, + "project": "opbeat", + "state": "running", + "time": 1488092400000, + "username": "jdixondt" + }, + { + "age": 66, + "cost": 24.32, + "country": "TH", + "price": 49, + "project": "elasticsearch", + "state": "start", + "time": 1479452400000, + "username": "bmurphydu" + }, + { + "age": 44, + "cost": 24.61, + "country": "NZ", + "price": 61, + "project": "kibana", + "state": "running", + "time": 1476946800000, + "username": "inicholsdv" + }, + { + "age": 64, + "cost": 21.53, + "country": "NL", + "price": 50, + "project": "beats", + "state": "running", + "time": 1464246000000, + "username": "hstanleydw" + }, + { + "age": 79, + "cost": 21.6, + "country": "CN", + "price": 63, + "project": "kibana", + "state": "running", + "time": 1479538800000, + "username": "nwatkinsdx" + }, + { + "age": 78, + "cost": 23.3, + "country": "PL", + "price": 61, + "project": "beats", + "state": "done", + "time": 1478415600000, + "username": "dmccoydy" + }, + { + "age": 33, + "cost": 24.21, + "country": "PA", + "price": 52, + "project": "machine-learning", + "state": "start", + "time": 1470812400000, + "username": "dchavezdz" + }, + { + "age": 76, + "cost": 23.57, + "country": "PE", + "price": 65, + "project": "x-pack", + "state": "start", + "time": 1467529200000, + "username": "jphillipse0" + }, + { + "age": 41, + "cost": 25.17, + "country": "ID", + "price": 55, + "project": "x-pack", + "state": "running", + "time": 1475046000000, + "username": "ppalmere1" + }, + { + "age": 56, + "cost": 22.74, + "country": "ID", + "price": 56, + "project": "logstash", + "state": "done", + "time": 1482476400000, + "username": "knelsone2" + }, + { + "age": 78, + "cost": 21.78, + "country": "GR", + "price": 57, + "project": "beats", + "state": "running", + "time": 1479020400000, + "username": "gclarke3" + }, + { + "age": 65, + "cost": 21.68, + "country": "VE", + "price": 39, + "project": "kibana", + "state": "running", + "time": 1474182000000, + "username": "rstewarte4" + }, + { + "age": 54, + "cost": 23.5, + "country": "CN", + "price": 65, + "project": "machine-learning", + "state": "start", + "time": 1467529200000, + "username": "vramose5" + }, + { + "age": 69, + "cost": 23.54, + "country": "CN", + "price": 52, + "project": "x-pack", + "state": "start", + "time": 1460617200000, + "username": "kkennedye6" + }, + { + "age": 64, + "cost": 24.08, + "country": "CN", + "price": 54, + "project": "beats", + "state": "start", + "time": 1463036400000, + "username": "sharveye7" + }, + { + "age": 44, + "cost": 24.9, + "country": "NO", + "price": 61, + "project": "kibana", + "state": "running", + "time": 1476514800000, + "username": "jandrewse8" + }, + { + "age": 72, + "cost": 24.49, + "country": "HN", + "price": 53, + "project": "kibana", + "state": "start", + "time": 1482130800000, + "username": "gwashingtone9" + }, + { + "age": 25, + "cost": 23.14, + "country": "CZ", + "price": 55, + "project": "opbeat", + "state": "done", + "time": 1479193200000, + "username": "tgrahamea" + }, + { + "age": 65, + "cost": 23.11, + "country": "GR", + "price": 53, + "project": "kibana", + "state": "done", + "time": 1464332400000, + "username": "awatsoneb" + }, + { + "age": 53, + "cost": 21.38, + "country": "ID", + "price": 46, + "project": "opbeat", + "state": "start", + "time": 1472540400000, + "username": "cnicholsec" + }, + { + "age": 74, + "cost": 24.07, + "country": "CN", + "price": 60, + "project": "opbeat", + "state": "start", + "time": 1473318000000, + "username": "dhamiltoned" + }, + { + "age": 31, + "cost": 22.77, + "country": "ID", + "price": 60, + "project": "elasticsearch", + "state": "done", + "time": 1489820400000, + "username": "pjordanee" + }, + { + "age": 32, + "cost": 22.87, + "country": "FR", + "price": 58, + "project": "opbeat", + "state": "done", + "time": 1464159600000, + "username": "rclarkef" + }, + { + "age": 63, + "cost": 24.53, + "country": "LI", + "price": 62, + "project": "logstash", + "state": "running", + "time": 1491462000000, + "username": "mgonzaleseg" + }, + { + "age": 73, + "cost": 21.39, + "country": "ID", + "price": 57, + "project": "elasticsearch", + "state": "start", + "time": 1473145200000, + "username": "tsnydereh" + }, + { + "age": 24, + "cost": 23.36, + "country": "PH", + "price": 54, + "project": "elasticsearch", + "state": "done", + "time": 1475564400000, + "username": "bedwardsei" + }, + { + "age": 33, + "cost": 22.73, + "country": "CN", + "price": 53, + "project": "machine-learning", + "state": "done", + "time": 1460358000000, + "username": "preyesej" + }, + { + "age": 22, + "cost": 22.35, + "country": "AR", + "price": 44, + "project": "beats", + "state": "running", + "time": 1472713200000, + "username": "aclarkek" + }, + { + "age": 27, + "cost": 22.26, + "country": "GH", + "price": 53, + "project": "x-pack", + "state": "running", + "time": 1487314800000, + "username": "wgeorgeel" + }, + { + "age": 25, + "cost": 23.06, + "country": "AZ", + "price": 53, + "project": "opbeat", + "state": "done", + "time": 1484204400000, + "username": "telliottem" + }, + { + "age": 57, + "cost": 24.79, + "country": "RU", + "price": 49, + "project": "beats", + "state": "running", + "time": 1487314800000, + "username": "lwooden" + }, + { + "age": 32, + "cost": 22.63, + "country": "PH", + "price": 53, + "project": "opbeat", + "state": "done", + "time": 1472886000000, + "username": "emitchelleo" + }, + { + "age": 73, + "cost": 20.87, + "country": "AR", + "price": 45, + "project": "elasticsearch", + "state": "start", + "time": 1471590000000, + "username": "ccarrep" + }, + { + "age": 25, + "cost": 21.37, + "country": "AR", + "price": 70, + "project": "opbeat", + "state": "start", + "time": 1482044400000, + "username": "mfishereq" + }, + { + "age": 40, + "cost": 23.42, + "country": "FR", + "price": 53, + "project": "machine-learning", + "state": "done", + "time": 1490943600000, + "username": "tgrayer" + }, + { + "age": 62, + "cost": 22.33, + "country": "KP", + "price": 64, + "project": "x-pack", + "state": "running", + "time": 1464332400000, + "username": "pstanleyes" + }, + { + "age": 73, + "cost": 23.69, + "country": "CN", + "price": 51, + "project": "elasticsearch", + "state": "start", + "time": 1479366000000, + "username": "nfoxet" + }, + { + "age": 21, + "cost": 21.33, + "country": "PT", + "price": 51, + "project": "logstash", + "state": "start", + "time": 1474441200000, + "username": "rstanleyeu" + }, + { + "age": 65, + "cost": 22.93, + "country": "PE", + "price": 50, + "project": "kibana", + "state": "done", + "time": 1476687600000, + "username": "jrobinsonev" + }, + { + "age": 21, + "cost": 22.89, + "country": "BD", + "price": 54, + "project": "logstash", + "state": "done", + "time": 1475391600000, + "username": "jrichardsew" + }, + { + "age": 24, + "cost": 21.94, + "country": "CN", + "price": 64, + "project": "elasticsearch", + "state": "running", + "time": 1471071600000, + "username": "hwebbex" + }, + { + "age": 46, + "cost": 21.95, + "country": "ID", + "price": 61, + "project": "opbeat", + "state": "running", + "time": 1479538800000, + "username": "awestey" + }, + { + "age": 64, + "cost": 22.09, + "country": "PL", + "price": 45, + "project": "beats", + "state": "running", + "time": 1462431600000, + "username": "ljacobsez" + }, + { + "age": 55, + "cost": 23.6, + "country": "GR", + "price": 54, + "project": "x-pack", + "state": "start", + "time": 1475478000000, + "username": "krussellf0" + }, + { + "age": 55, + "cost": 22.9, + "country": "PL", + "price": 63, + "project": "x-pack", + "state": "done", + "time": 1485154800000, + "username": "amedinaf1" + }, + { + "age": 38, + "cost": 22.83, + "country": "CN", + "price": 63, + "project": "elasticsearch", + "state": "done", + "time": 1473577200000, + "username": "tjenkinsf2" + }, + { + "age": 64, + "cost": 24.1, + "country": "ID", + "price": 44, + "project": "beats", + "state": "start", + "time": 1482822000000, + "username": "lrileyf3" + }, + { + "age": 52, + "cost": 21.41, + "country": "BR", + "price": 50, + "project": "elasticsearch", + "state": "start", + "time": 1480057200000, + "username": "dsimpsonf4" + }, + { + "age": 34, + "cost": 23.48, + "country": "BR", + "price": 66, + "project": "x-pack", + "state": "done", + "time": 1481612400000, + "username": "nwoodsf5" + }, + { + "age": 65, + "cost": 23.04, + "country": "KR", + "price": 75, + "project": "kibana", + "state": "done", + "time": 1464850800000, + "username": "acruzf6" + }, + { + "age": 45, + "cost": 25, + "country": "SE", + "price": 54, + "project": "elasticsearch", + "state": "running", + "time": 1466838000000, + "username": "rmyersf7" + }, + { + "age": 29, + "cost": 21.82, + "country": "TL", + "price": 54, + "project": "beats", + "state": "running", + "time": 1474441200000, + "username": "sfowlerf8" + }, + { + "age": 51, + "cost": 22.2, + "country": "IL", + "price": 54, + "project": "kibana", + "state": "running", + "time": 1463641200000, + "username": "bsimsf9" + }, + { + "age": 23, + "cost": 22.49, + "country": "CN", + "price": 54, + "project": "kibana", + "state": "running", + "time": 1472367600000, + "username": "acampbellfa" + }, + { + "age": 35, + "cost": 23.36, + "country": "RU", + "price": 54, + "project": "logstash", + "state": "done", + "time": 1472799600000, + "username": "llarsonfb" + }, + { + "age": 32, + "cost": 23, + "country": "CN", + "price": 66, + "project": "opbeat", + "state": "done", + "time": 1479625200000, + "username": "kbanksfc" + }, + { + "age": 64, + "cost": 21.07, + "country": "CN", + "price": 56, + "project": "beats", + "state": "start", + "time": 1486882800000, + "username": "jwatkinsfd" + }, + { + "age": 23, + "cost": 23.87, + "country": "PT", + "price": 58, + "project": "kibana", + "state": "start", + "time": 1485846000000, + "username": "kfranklinfe" + }, + { + "age": 22, + "cost": 21.68, + "country": "MX", + "price": 60, + "project": "beats", + "state": "running", + "time": 1479366000000, + "username": "jhuntff" + }, + { + "age": 58, + "cost": 24.67, + "country": "AM", + "price": 57, + "project": "kibana", + "state": "running", + "time": 1488265200000, + "username": "njenkinsfg" + }, + { + "age": 78, + "cost": 24.49, + "country": "CN", + "price": 49, + "project": "beats", + "state": "start", + "time": 1464159600000, + "username": "mjenkinsfh" + }, + { + "age": 46, + "cost": 23.31, + "country": "JP", + "price": 57, + "project": "opbeat", + "state": "done", + "time": 1465974000000, + "username": "adayfi" + }, + { + "age": 68, + "cost": 23.91, + "country": "PF", + "price": 58, + "project": "machine-learning", + "state": "start", + "time": 1479452400000, + "username": "lcoxfj" + }, + { + "age": 79, + "cost": 23.67, + "country": "CA", + "price": 62, + "project": "kibana", + "state": "start", + "time": 1479538800000, + "username": "dhansenfk" + }, + { + "age": 70, + "cost": 23.46, + "country": "VE", + "price": 66, + "project": "logstash", + "state": "done", + "time": 1464073200000, + "username": "rharrisonfl" + }, + { + "age": 71, + "cost": 22.41, + "country": "RS", + "price": 55, + "project": "beats", + "state": "running", + "time": 1472886000000, + "username": "aromerofm" + }, + { + "age": 58, + "cost": 22.99, + "country": "BR", + "price": 66, + "project": "kibana", + "state": "done", + "time": 1484550000000, + "username": "hfoxfn" + }, + { + "age": 21, + "cost": 23.59, + "country": "UA", + "price": 57, + "project": "logstash", + "state": "start", + "time": 1489906800000, + "username": "hrodriguezfo" + }, + { + "age": 34, + "cost": 22.48, + "country": "MK", + "price": 67, + "project": "x-pack", + "state": "running", + "time": 1463382000000, + "username": "kmasonfp" + }, + { + "age": 52, + "cost": 23.43, + "country": "CN", + "price": 58, + "project": "elasticsearch", + "state": "done", + "time": 1475478000000, + "username": "mrileyfq" + }, + { + "age": 24, + "cost": 23.1, + "country": "NZ", + "price": 64, + "project": "elasticsearch", + "state": "done", + "time": 1472022000000, + "username": "tkellyfr" + }, + { + "age": 52, + "cost": 21.59, + "country": "CN", + "price": 72, + "project": "elasticsearch", + "state": "running", + "time": 1490079600000, + "username": "jrileyfs" + }, + { + "age": 40, + "cost": 22.7, + "country": "MX", + "price": 58, + "project": "machine-learning", + "state": "done", + "time": 1484204400000, + "username": "mspencerft" + }, + { + "age": 34, + "cost": 22.01, + "country": "AL", + "price": 58, + "project": "x-pack", + "state": "running", + "time": 1486191600000, + "username": "kburkefu" + }, + { + "age": 66, + "cost": 24.78, + "country": "CN", + "price": 55, + "project": "elasticsearch", + "state": "running", + "time": 1478329200000, + "username": "nturnerfv" + }, + { + "age": 62, + "cost": 22.81, + "country": "ES", + "price": 50, + "project": "x-pack", + "state": "done", + "time": 1460530800000, + "username": "jsmithfw" + }, + { + "age": 56, + "cost": 23.34, + "country": "PH", + "price": 58, + "project": "logstash", + "state": "done", + "time": 1476082800000, + "username": "jrichardsonfx" + }, + { + "age": 32, + "cost": 23.19, + "country": "US", + "price": 67, + "project": "opbeat", + "state": "done", + "time": 1475650800000, + "username": "shernandezfy" + }, + { + "age": 37, + "cost": 23.15, + "country": "CN", + "price": 61, + "project": "kibana", + "state": "done", + "time": 1485500400000, + "username": "hjacobsfz" + }, + { + "age": 29, + "cost": 22.66, + "country": "EC", + "price": 63, + "project": "beats", + "state": "done", + "time": 1483254000000, + "username": "cbennettg0" + }, + { + "age": 48, + "cost": 23.19, + "country": "CM", + "price": 58, + "project": "x-pack", + "state": "done", + "time": 1463900400000, + "username": "aarnoldg1" + }, + { + "age": 63, + "cost": 24.93, + "country": "PT", + "price": 61, + "project": "logstash", + "state": "running", + "time": 1472022000000, + "username": "mgilbertg2" + }, + { + "age": 31, + "cost": 22.81, + "country": "PA", + "price": 54, + "project": "elasticsearch", + "state": "done", + "time": 1482044400000, + "username": "rcoxg3" + }, + { + "age": 72, + "cost": 24.23, + "country": "RU", + "price": 60, + "project": "kibana", + "state": "start", + "time": 1471676400000, + "username": "twhiteg4" + }, + { + "age": 42, + "cost": 22.72, + "country": "BR", + "price": 53, + "project": "logstash", + "state": "done", + "time": 1486710000000, + "username": "lcarpenterg5" + }, + { + "age": 27, + "cost": 23.16, + "country": "BA", + "price": 70, + "project": "x-pack", + "state": "done", + "time": 1481612400000, + "username": "eharrisong6" + }, + { + "age": 46, + "cost": 24.18, + "country": "NP", + "price": 52, + "project": "opbeat", + "state": "start", + "time": 1461222000000, + "username": "hharrisong7" + }, + { + "age": 24, + "cost": 22.94, + "country": "PT", + "price": 59, + "project": "elasticsearch", + "state": "done", + "time": 1484377200000, + "username": "jgibsong8" + }, + { + "age": 38, + "cost": 21.91, + "country": "BO", + "price": 51, + "project": "elasticsearch", + "state": "running", + "time": 1474527600000, + "username": "rwilliamsg9" + }, + { + "age": 77, + "cost": 24.88, + "country": "CN", + "price": 64, + "project": "logstash", + "state": "running", + "time": 1470466800000, + "username": "htaylorga" + }, + { + "age": 64, + "cost": 23.03, + "country": "PT", + "price": 64, + "project": "beats", + "state": "done", + "time": 1464764400000, + "username": "vwebbgb" + }, + { + "age": 43, + "cost": 21.21, + "country": "JP", + "price": 66, + "project": "beats", + "state": "start", + "time": 1491202800000, + "username": "tbrowngc" + }, + { + "age": 73, + "cost": 23.85, + "country": "CN", + "price": 55, + "project": "elasticsearch", + "state": "start", + "time": 1477638000000, + "username": "bmontgomerygd" + }, + { + "age": 78, + "cost": 24.05, + "country": "VN", + "price": 59, + "project": "beats", + "state": "start", + "time": 1480921200000, + "username": "jrileyge" + }, + { + "age": 44, + "cost": 23.43, + "country": "ID", + "price": 55, + "project": "kibana", + "state": "done", + "time": 1487574000000, + "username": "bpetersgf" + }, + { + "age": 31, + "cost": 21.69, + "country": "PH", + "price": 54, + "project": "elasticsearch", + "state": "running", + "time": 1467615600000, + "username": "awilliamsgg" + }, + { + "age": 79, + "cost": 22.26, + "country": "CO", + "price": 58, + "project": "kibana", + "state": "running", + "time": 1478588400000, + "username": "bcoxgh" + }, + { + "age": 47, + "cost": 24.85, + "country": "PH", + "price": 74, + "project": "machine-learning", + "state": "running", + "time": 1461826800000, + "username": "jchavezgi" + }, + { + "age": 19, + "cost": 23.61, + "country": "AZ", + "price": 63, + "project": "machine-learning", + "state": "start", + "time": 1476946800000, + "username": "bstanleygj" + }, + { + "age": 49, + "cost": 22.66, + "country": "RU", + "price": 55, + "project": "logstash", + "state": "done", + "time": 1475046000000, + "username": "lortizgk" + }, + { + "age": 65, + "cost": 21.86, + "country": "YE", + "price": 33, + "project": "kibana", + "state": "running", + "time": 1462345200000, + "username": "cjohnsongl" + }, + { + "age": 30, + "cost": 23.26, + "country": "NZ", + "price": 55, + "project": "kibana", + "state": "done", + "time": 1484636400000, + "username": "sfernandezgm" + }, + { + "age": 75, + "cost": 22.78, + "country": "GB", + "price": 54, + "project": "machine-learning", + "state": "done", + "time": 1491721200000, + "username": "astevensgn" + }, + { + "age": 57, + "cost": 24.28, + "country": "VE", + "price": 55, + "project": "beats", + "state": "start", + "time": 1480575600000, + "username": "hgreengo" + }, + { + "age": 67, + "cost": 21.68, + "country": "FR", + "price": 45, + "project": "opbeat", + "state": "running", + "time": 1485932400000, + "username": "tgutierrezgp" + }, + { + "age": 59, + "cost": 25.04, + "country": "PL", + "price": 53, + "project": "elasticsearch", + "state": "running", + "time": 1481266800000, + "username": "rmorenogq" + }, + { + "age": 57, + "cost": 23.46, + "country": "PE", + "price": 48, + "project": "beats", + "state": "done", + "time": 1464332400000, + "username": "esandersgr" + }, + { + "age": 21, + "cost": 22.72, + "country": "CN", + "price": 49, + "project": "logstash", + "state": "done", + "time": 1477119600000, + "username": "sleegs" + }, + { + "age": 74, + "cost": 21.44, + "country": "RU", + "price": 64, + "project": "opbeat", + "state": "start", + "time": 1485414000000, + "username": "ktaylorgt" + }, + { + "age": 24, + "cost": 23.26, + "country": "ID", + "price": 44, + "project": "elasticsearch", + "state": "done", + "time": 1482217200000, + "username": "dgeorgegu" + }, + { + "age": 27, + "cost": 22.47, + "country": "ID", + "price": 57, + "project": "x-pack", + "state": "running", + "time": 1468134000000, + "username": "swarrengv" + }, + { + "age": 62, + "cost": 22.32, + "country": "JP", + "price": 52, + "project": "x-pack", + "state": "running", + "time": 1481353200000, + "username": "sdeangw" + }, + { + "age": 36, + "cost": 22.85, + "country": "MO", + "price": 49, + "project": "beats", + "state": "done", + "time": 1483167600000, + "username": "rmyersgx" + }, + { + "age": 31, + "cost": 25.49, + "country": "CN", + "price": 64, + "project": "elasticsearch", + "state": "running", + "time": 1469430000000, + "username": "lwilsongy" + }, + { + "age": 75, + "cost": 23.08, + "country": "CN", + "price": 57, + "project": "machine-learning", + "state": "done", + "time": 1472713200000, + "username": "friveragz" + }, + { + "age": 75, + "cost": 22.93, + "country": "RU", + "price": 66, + "project": "machine-learning", + "state": "done", + "time": 1470553200000, + "username": "awebbh0" + }, + { + "age": 18, + "cost": 23.41, + "country": "CN", + "price": 46, + "project": "opbeat", + "state": "done", + "time": 1486105200000, + "username": "fyoungh1" + }, + { + "age": 76, + "cost": 22.25, + "country": "PH", + "price": 57, + "project": "x-pack", + "state": "running", + "time": 1474354800000, + "username": "jbakerh2" + }, + { + "age": 56, + "cost": 21.42, + "country": "PL", + "price": 66, + "project": "logstash", + "state": "start", + "time": 1461394800000, + "username": "wwalkerh3" + }, + { + "age": 23, + "cost": 21.33, + "country": "MX", + "price": 53, + "project": "kibana", + "state": "start", + "time": 1489042800000, + "username": "nwatsonh4" + }, + { + "age": 22, + "cost": 23.46, + "country": "PH", + "price": 46, + "project": "beats", + "state": "done", + "time": 1487055600000, + "username": "dsullivanh5" + }, + { + "age": 65, + "cost": 21.73, + "country": "GT", + "price": 50, + "project": "kibana", + "state": "running", + "time": 1480489200000, + "username": "jcastilloh6" + }, + { + "age": 20, + "cost": 24.24, + "country": "BR", + "price": 50, + "project": "x-pack", + "state": "start", + "time": 1481266800000, + "username": "rgreeneh7" + }, + { + "age": 40, + "cost": 23.22, + "country": "ZA", + "price": 62, + "project": "machine-learning", + "state": "done", + "time": 1469948400000, + "username": "gsmithh8" + }, + { + "age": 31, + "cost": 20.87, + "country": "VU", + "price": 65, + "project": "elasticsearch", + "state": "start", + "time": 1486623600000, + "username": "rramosh9" + }, + { + "age": 73, + "cost": 21.73, + "country": "DO", + "price": 61, + "project": "elasticsearch", + "state": "running", + "time": 1460876400000, + "username": "chansenha" + }, + { + "age": 23, + "cost": 22.63, + "country": "ID", + "price": 64, + "project": "kibana", + "state": "done", + "time": 1488092400000, + "username": "hblackhb" + }, + { + "age": 52, + "cost": 22.56, + "country": "RU", + "price": 52, + "project": "elasticsearch", + "state": "done", + "time": 1480662000000, + "username": "ebakerhc" + }, + { + "age": 27, + "cost": 22.53, + "country": "VE", + "price": 46, + "project": "x-pack", + "state": "done", + "time": 1480316400000, + "username": "tryanhd" + }, + { + "age": 73, + "cost": 23.69, + "country": "MY", + "price": 64, + "project": "elasticsearch", + "state": "start", + "time": 1474009200000, + "username": "adiazhe" + }, + { + "age": 62, + "cost": 25.48, + "country": "UA", + "price": 61, + "project": "x-pack", + "state": "running", + "time": 1461394800000, + "username": "darnoldhf" + }, + { + "age": 37, + "cost": 24.07, + "country": "PL", + "price": 57, + "project": "kibana", + "state": "start", + "time": 1481785200000, + "username": "cgrayhg" + }, + { + "age": 62, + "cost": 23.32, + "country": "GR", + "price": 72, + "project": "x-pack", + "state": "done", + "time": 1465196400000, + "username": "smedinahh" + }, + { + "age": 71, + "cost": 22.75, + "country": "GE", + "price": 59, + "project": "beats", + "state": "done", + "time": 1463209200000, + "username": "jmccoyhi" + }, + { + "age": 44, + "cost": 22.34, + "country": "CN", + "price": 59, + "project": "kibana", + "state": "running", + "time": 1477551600000, + "username": "ameyerhj" + }, + { + "age": 44, + "cost": 20.24, + "country": "PT", + "price": 51, + "project": "kibana", + "state": "done", + "time": 1486969200000, + "username": "wwrighthk" + }, + { + "age": 19, + "cost": 23.86, + "country": "FI", + "price": 63, + "project": "machine-learning", + "state": "start", + "time": 1476255600000, + "username": "wtuckerhl" + }, + { + "age": 51, + "cost": 24.79, + "country": "NA", + "price": 61, + "project": "kibana", + "state": "running", + "time": 1465023600000, + "username": "greedhm" + }, + { + "age": 23, + "cost": 24.51, + "country": "JP", + "price": 61, + "project": "kibana", + "state": "running", + "time": 1480575600000, + "username": "fpaynehn" + }, + { + "age": 29, + "cost": 22.18, + "country": "CN", + "price": 54, + "project": "beats", + "state": "running", + "time": 1462431600000, + "username": "aperryho" + }, + { + "age": 62, + "cost": 21.38, + "country": "CN", + "price": 51, + "project": "x-pack", + "state": "start", + "time": 1471676400000, + "username": "arobertshp" + }, + { + "age": 67, + "cost": 23.81, + "country": "UY", + "price": 68, + "project": "opbeat", + "state": "start", + "time": 1481353200000, + "username": "mallenhq" + }, + { + "age": 78, + "cost": 21.55, + "country": "PA", + "price": 49, + "project": "beats", + "state": "running", + "time": 1467356400000, + "username": "mcruzhr" + }, + { + "age": 36, + "cost": 23.41, + "country": "VN", + "price": 50, + "project": "beats", + "state": "done", + "time": 1478674800000, + "username": "rwagnerhs" + }, + { + "age": 76, + "cost": 24.18, + "country": "CN", + "price": 57, + "project": "x-pack", + "state": "start", + "time": 1483772400000, + "username": "mevansht" + }, + { + "age": 59, + "cost": 22.6, + "country": "VE", + "price": 64, + "project": "elasticsearch", + "state": "done", + "time": 1465714800000, + "username": "nknighthu" + }, + { + "age": 30, + "cost": 21.37, + "country": "ZM", + "price": 68, + "project": "kibana", + "state": "start", + "time": 1480489200000, + "username": "jharrishv" + }, + { + "age": 70, + "cost": 24.13, + "country": "HU", + "price": 50, + "project": "logstash", + "state": "start", + "time": 1470121200000, + "username": "wkimhw" + }, + { + "age": 55, + "cost": 22.34, + "country": "CN", + "price": 43, + "project": "x-pack", + "state": "running", + "time": 1485932400000, + "username": "ejacksonhx" + }, + { + "age": 54, + "cost": 23.08, + "country": "ID", + "price": 54, + "project": "machine-learning", + "state": "done", + "time": 1484722800000, + "username": "mstewarthy" + }, + { + "age": 29, + "cost": 23.4, + "country": "ID", + "price": 65, + "project": "beats", + "state": "done", + "time": 1471330800000, + "username": "psimpsonhz" + }, + { + "age": 19, + "cost": 21.08, + "country": "UA", + "price": 51, + "project": "machine-learning", + "state": "start", + "time": 1472367600000, + "username": "jkingi0" + }, + { + "age": 49, + "cost": 22.83, + "country": "PL", + "price": 57, + "project": "logstash", + "state": "done", + "time": 1468393200000, + "username": "jrileyi1" + }, + { + "age": 56, + "cost": 21.82, + "country": "RU", + "price": 51, + "project": "logstash", + "state": "running", + "time": 1482303600000, + "username": "tdixoni2" + }, + { + "age": 67, + "cost": 23.07, + "country": "ID", + "price": 41, + "project": "opbeat", + "state": "done", + "time": 1468652400000, + "username": "jmitchelli3" + }, + { + "age": 40, + "cost": 23.65, + "country": "PT", + "price": 53, + "project": "machine-learning", + "state": "start", + "time": 1474614000000, + "username": "dcoxi4" + }, + { + "age": 61, + "cost": 23.72, + "country": "CN", + "price": 57, + "project": "machine-learning", + "state": "start", + "time": 1486018800000, + "username": "tporteri5" + }, + { + "age": 24, + "cost": 24.68, + "country": "SE", + "price": 74, + "project": "elasticsearch", + "state": "running", + "time": 1469084400000, + "username": "rwagneri6" + }, + { + "age": 35, + "cost": 22.38, + "country": "FR", + "price": 51, + "project": "logstash", + "state": "running", + "time": 1490425200000, + "username": "gnelsoni7" + }, + { + "age": 38, + "cost": 23.99, + "country": "LA", + "price": 53, + "project": "elasticsearch", + "state": "start", + "time": 1468652400000, + "username": "cmcdonaldi8" + }, + { + "age": 60, + "cost": 22.86, + "country": "KE", + "price": 65, + "project": "opbeat", + "state": "done", + "time": 1461654000000, + "username": "hjordani9" + }, + { + "age": 79, + "cost": 23.56, + "country": "PH", + "price": 57, + "project": "kibana", + "state": "start", + "time": 1474095600000, + "username": "hwalkeria" + }, + { + "age": 54, + "cost": 23.63, + "country": "CN", + "price": 50, + "project": "machine-learning", + "state": "start", + "time": 1479020400000, + "username": "lstanleyib" + }, + { + "age": 35, + "cost": 23.24, + "country": "TT", + "price": 63, + "project": "logstash", + "state": "done", + "time": 1479538800000, + "username": "jrogersic" + }, + { + "age": 72, + "cost": 21.98, + "country": "JP", + "price": 60, + "project": "kibana", + "state": "running", + "time": 1483254000000, + "username": "aperryid" + }, + { + "age": 50, + "cost": 23.65, + "country": "RU", + "price": 48, + "project": "beats", + "state": "start", + "time": 1486364400000, + "username": "mjordanie" + }, + { + "age": 59, + "cost": 23.36, + "country": "ID", + "price": 59, + "project": "elasticsearch", + "state": "done", + "time": 1478415600000, + "username": "pcookif" + }, + { + "age": 25, + "cost": 22.79, + "country": "UG", + "price": 62, + "project": "opbeat", + "state": "done", + "time": 1489129200000, + "username": "cblackig" + }, + { + "age": 39, + "cost": 22.77, + "country": "AR", + "price": 60, + "project": "opbeat", + "state": "done", + "time": 1461913200000, + "username": "djohnsonih" + }, + { + "age": 23, + "cost": 20.45, + "country": "PH", + "price": 52, + "project": "kibana", + "state": "done", + "time": 1475132400000, + "username": "bturnerii" + }, + { + "age": 46, + "cost": 21.79, + "country": "CN", + "price": 55, + "project": "opbeat", + "state": "running", + "time": 1471849200000, + "username": "colsonij" + }, + { + "age": 45, + "cost": 22.68, + "country": "CZ", + "price": 58, + "project": "elasticsearch", + "state": "done", + "time": 1491116400000, + "username": "tmurphyik" + }, + { + "age": 54, + "cost": 19.57, + "country": "PH", + "price": 68, + "project": "machine-learning", + "state": "done", + "time": 1463122800000, + "username": "tshawil" + }, + { + "age": 76, + "cost": 25.04, + "country": "SE", + "price": 47, + "project": "x-pack", + "state": "running", + "time": 1470726000000, + "username": "lgilbertim" + }, + { + "age": 53, + "cost": 23.85, + "country": "GR", + "price": 44, + "project": "opbeat", + "state": "start", + "time": 1474441200000, + "username": "jbakerin" + }, + { + "age": 44, + "cost": 23.22, + "country": "MY", + "price": 65, + "project": "kibana", + "state": "done", + "time": 1479279600000, + "username": "jmurphyio" + }, + { + "age": 32, + "cost": 23.15, + "country": "SE", + "price": 54, + "project": "opbeat", + "state": "done", + "time": 1481180400000, + "username": "glawrenceip" + }, + { + "age": 36, + "cost": 23.06, + "country": "VN", + "price": 56, + "project": "beats", + "state": "done", + "time": 1464937200000, + "username": "jsancheziq" + }, + { + "age": 70, + "cost": 22.77, + "country": "TH", + "price": 54, + "project": "logstash", + "state": "done", + "time": 1466233200000, + "username": "mchapmanir" + }, + { + "age": 24, + "cost": 23.14, + "country": "BR", + "price": 54, + "project": "elasticsearch", + "state": "done", + "time": 1469775600000, + "username": "sbutleris" + }, + { + "age": 50, + "cost": 23.01, + "country": "PT", + "price": 51, + "project": "beats", + "state": "done", + "time": 1477638000000, + "username": "rowensit" + }, + { + "age": 76, + "cost": 22.91, + "country": "ID", + "price": 58, + "project": "x-pack", + "state": "done", + "time": 1472713200000, + "username": "nfrankliniu" + }, + { + "age": 55, + "cost": 23.46, + "country": "AR", + "price": 63, + "project": "x-pack", + "state": "done", + "time": 1477638000000, + "username": "bwhiteiv" + }, + { + "age": 26, + "cost": 21.02, + "country": "ID", + "price": 46, + "project": "machine-learning", + "state": "start", + "time": 1472454000000, + "username": "mrossiw" + }, + { + "age": 61, + "cost": 20.32, + "country": "VN", + "price": 52, + "project": "machine-learning", + "state": "done", + "time": 1474700400000, + "username": "pyoungix" + }, + { + "age": 30, + "cost": 21.94, + "country": "BR", + "price": 53, + "project": "kibana", + "state": "running", + "time": 1464591600000, + "username": "rkimiy" + }, + { + "age": 36, + "cost": 22.09, + "country": "RU", + "price": 64, + "project": "beats", + "state": "running", + "time": 1462863600000, + "username": "pwallaceiz" + }, + { + "age": 54, + "cost": 21.56, + "country": "ID", + "price": 49, + "project": "machine-learning", + "state": "running", + "time": 1490857200000, + "username": "tadamsj0" + }, + { + "age": 56, + "cost": 20.35, + "country": "CN", + "price": 49, + "project": "logstash", + "state": "done", + "time": 1462777200000, + "username": "kmoorej1" + }, + { + "age": 55, + "cost": 22.67, + "country": "PT", + "price": 51, + "project": "x-pack", + "state": "done", + "time": 1468566000000, + "username": "abradleyj2" + }, + { + "age": 34, + "cost": 23.52, + "country": "SI", + "price": 57, + "project": "x-pack", + "state": "start", + "time": 1481698800000, + "username": "trodriguezj3" + }, + { + "age": 39, + "cost": 22.71, + "country": "CN", + "price": 63, + "project": "opbeat", + "state": "done", + "time": 1461999600000, + "username": "jbrownj4" + }, + { + "age": 46, + "cost": 24.28, + "country": "BA", + "price": 59, + "project": "opbeat", + "state": "start", + "time": 1485586800000, + "username": "dmccoyj5" + }, + { + "age": 19, + "cost": 23.55, + "country": "VN", + "price": 51, + "project": "machine-learning", + "state": "start", + "time": 1490943600000, + "username": "ahansenj6" + }, + { + "age": 58, + "cost": 22.23, + "country": "CN", + "price": 51, + "project": "kibana", + "state": "running", + "time": 1461222000000, + "username": "eedwardsj7" + }, + { + "age": 49, + "cost": 23.17, + "country": "PL", + "price": 51, + "project": "logstash", + "state": "done", + "time": 1473750000000, + "username": "jfordj8" + }, + { + "age": 62, + "cost": 23.59, + "country": "CN", + "price": 65, + "project": "x-pack", + "state": "start", + "time": 1479193200000, + "username": "kharrisj9" + }, + { + "age": 78, + "cost": 21.63, + "country": "PL", + "price": 73, + "project": "beats", + "state": "running", + "time": 1479625200000, + "username": "rbradleyja" + }, + { + "age": 63, + "cost": 22.74, + "country": "HR", + "price": 56, + "project": "logstash", + "state": "done", + "time": 1478674800000, + "username": "nholmesjb" + }, + { + "age": 79, + "cost": 22.79, + "country": "UA", + "price": 51, + "project": "kibana", + "state": "done", + "time": 1470121200000, + "username": "psimsjc" + }, + { + "age": 34, + "cost": 22.44, + "country": "PH", + "price": 40, + "project": "x-pack", + "state": "running", + "time": 1474527600000, + "username": "tbanksjd" + }, + { + "age": 59, + "cost": 22.2, + "country": "CR", + "price": 56, + "project": "elasticsearch", + "state": "running", + "time": 1482303600000, + "username": "dallenje" + }, + { + "age": 40, + "cost": 23.81, + "country": "ID", + "price": 50, + "project": "machine-learning", + "state": "start", + "time": 1460876400000, + "username": "kramosjf" + }, + { + "age": 39, + "cost": 22.07, + "country": "ID", + "price": 59, + "project": "opbeat", + "state": "running", + "time": 1463122800000, + "username": "rshawjg" + }, + { + "age": 60, + "cost": 22.42, + "country": "NL", + "price": 74, + "project": "opbeat", + "state": "running", + "time": 1480402800000, + "username": "vhilljh" + }, + { + "age": 29, + "cost": 23.29, + "country": "ID", + "price": 52, + "project": "beats", + "state": "done", + "time": 1462258800000, + "username": "lholmesji" + }, + { + "age": 22, + "cost": 22.52, + "country": "PL", + "price": 60, + "project": "beats", + "state": "done", + "time": 1477551600000, + "username": "pgarrettjj" + }, + { + "age": 69, + "cost": 22.94, + "country": "ES", + "price": 55, + "project": "x-pack", + "state": "done", + "time": 1468479600000, + "username": "tstonejk" + }, + { + "age": 74, + "cost": 23.1, + "country": "CN", + "price": 61, + "project": "opbeat", + "state": "done", + "time": 1472972400000, + "username": "jgriffinjl" + }, + { + "age": 30, + "cost": 20.85, + "country": "CN", + "price": 63, + "project": "kibana", + "state": "start", + "time": 1483426800000, + "username": "sholmesjm" + }, + { + "age": 56, + "cost": 23.14, + "country": "CN", + "price": 61, + "project": "logstash", + "state": "done", + "time": 1479193200000, + "username": "khayesjn" + }, + { + "age": 69, + "cost": 23.86, + "country": "BG", + "price": 57, + "project": "x-pack", + "state": "start", + "time": 1478761200000, + "username": "jfoxjo" + }, + { + "age": 32, + "cost": 22.75, + "country": "CN", + "price": 53, + "project": "opbeat", + "state": "done", + "time": 1460444400000, + "username": "swhitejp" + }, + { + "age": 45, + "cost": 21.48, + "country": "PH", + "price": 55, + "project": "elasticsearch", + "state": "start", + "time": 1488610800000, + "username": "hmorganjq" + }, + { + "age": 63, + "cost": 23.52, + "country": "CO", + "price": 63, + "project": "logstash", + "state": "start", + "time": 1462172400000, + "username": "agarciajr" + }, + { + "age": 48, + "cost": 24.18, + "country": "ID", + "price": 68, + "project": "x-pack", + "state": "start", + "time": 1489734000000, + "username": "sgeorgejs" + }, + { + "age": 18, + "cost": 23.02, + "country": "MX", + "price": 49, + "project": "opbeat", + "state": "done", + "time": 1486796400000, + "username": "agardnerjt" + }, + { + "age": 52, + "cost": 25.13, + "country": "CN", + "price": 63, + "project": "elasticsearch", + "state": "running", + "time": 1460703600000, + "username": "dsullivanju" + }, + { + "age": 46, + "cost": 23.58, + "country": "CO", + "price": 46, + "project": "opbeat", + "state": "start", + "time": 1464159600000, + "username": "mmoralesjv" + }, + { + "age": 42, + "cost": 23.93, + "country": "ID", + "price": 48, + "project": "logstash", + "state": "start", + "time": 1482822000000, + "username": "pgonzalezjw" + }, + { + "age": 38, + "cost": 22.12, + "country": "CN", + "price": 64, + "project": "elasticsearch", + "state": "running", + "time": 1475650800000, + "username": "jbanksjx" + }, + { + "age": 34, + "cost": 20.84, + "country": "AR", + "price": 52, + "project": "x-pack", + "state": "start", + "time": 1490252400000, + "username": "bricejy" + }, + { + "age": 59, + "cost": 22.7, + "country": "CN", + "price": 51, + "project": "elasticsearch", + "state": "done", + "time": 1470121200000, + "username": "eburnsjz" + }, + { + "age": 50, + "cost": 23.41, + "country": "CL", + "price": 59, + "project": "beats", + "state": "done", + "time": 1477033200000, + "username": "awallacek0" + }, + { + "age": 21, + "cost": 22.31, + "country": "DO", + "price": 69, + "project": "logstash", + "state": "running", + "time": 1472799600000, + "username": "bhamiltonk1" + }, + { + "age": 51, + "cost": 22.98, + "country": "TN", + "price": 62, + "project": "kibana", + "state": "done", + "time": 1483945200000, + "username": "lstevensk2" + }, + { + "age": 26, + "cost": 23.88, + "country": "XK", + "price": 53, + "project": "machine-learning", + "state": "start", + "time": 1462777200000, + "username": "emartinezk3" + }, + { + "age": 72, + "cost": 21.86, + "country": "JP", + "price": 48, + "project": "kibana", + "state": "running", + "time": 1473404400000, + "username": "driverak4" + }, + { + "age": 72, + "cost": 22.86, + "country": "CN", + "price": 46, + "project": "kibana", + "state": "done", + "time": 1483772400000, + "username": "khamiltonk5" + }, + { + "age": 48, + "cost": 21.64, + "country": "PE", + "price": 64, + "project": "x-pack", + "state": "running", + "time": 1476687600000, + "username": "tandersonk6" + }, + { + "age": 54, + "cost": 22.4, + "country": "PK", + "price": 53, + "project": "machine-learning", + "state": "running", + "time": 1486710000000, + "username": "ljenkinsk7" + }, + { + "age": 45, + "cost": 22.52, + "country": "RU", + "price": 51, + "project": "elasticsearch", + "state": "done", + "time": 1472540400000, + "username": "asandersk8" + }, + { + "age": 22, + "cost": 24.14, + "country": "MN", + "price": 52, + "project": "beats", + "state": "start", + "time": 1470034800000, + "username": "fwilliamsonk9" + }, + { + "age": 20, + "cost": 24.37, + "country": "JP", + "price": 67, + "project": "x-pack", + "state": "start", + "time": 1483081200000, + "username": "dortizka" + }, + { + "age": 37, + "cost": 24.62, + "country": "BD", + "price": 50, + "project": "kibana", + "state": "running", + "time": 1477983600000, + "username": "jpalmerkb" + }, + { + "age": 49, + "cost": 23.75, + "country": "BW", + "price": 40, + "project": "logstash", + "state": "start", + "time": 1482130800000, + "username": "areyeskc" + }, + { + "age": 39, + "cost": 22.17, + "country": "PL", + "price": 52, + "project": "opbeat", + "state": "running", + "time": 1463986800000, + "username": "jtuckerkd" + }, + { + "age": 71, + "cost": 21.39, + "country": "CN", + "price": 68, + "project": "beats", + "state": "start", + "time": 1485932400000, + "username": "rhickske" + }, + { + "age": 39, + "cost": 23.42, + "country": "CN", + "price": 49, + "project": "opbeat", + "state": "done", + "time": 1463122800000, + "username": "cgrahamkf" + }, + { + "age": 67, + "cost": 22.17, + "country": "ZA", + "price": 61, + "project": "opbeat", + "state": "running", + "time": 1472713200000, + "username": "jwestkg" + }, + { + "age": 56, + "cost": 21.53, + "country": "CN", + "price": 66, + "project": "logstash", + "state": "running", + "time": 1486969200000, + "username": "cpricekh" + }, + { + "age": 39, + "cost": 22.95, + "country": "PH", + "price": 45, + "project": "opbeat", + "state": "done", + "time": 1469948400000, + "username": "hyoungki" + }, + { + "age": 22, + "cost": 22.89, + "country": "EC", + "price": 59, + "project": "beats", + "state": "done", + "time": 1475737200000, + "username": "lsanderskj" + }, + { + "age": 49, + "cost": 22.96, + "country": "SE", + "price": 67, + "project": "logstash", + "state": "done", + "time": 1473836400000, + "username": "mfrazierkk" + }, + { + "age": 54, + "cost": 22.97, + "country": "AS", + "price": 60, + "project": "machine-learning", + "state": "done", + "time": 1466146800000, + "username": "sowenskl" + }, + { + "age": 68, + "cost": 22.21, + "country": "PA", + "price": 51, + "project": "machine-learning", + "state": "running", + "time": 1475391600000, + "username": "atuckerkm" + }, + { + "age": 74, + "cost": 21.47, + "country": "SE", + "price": 56, + "project": "opbeat", + "state": "start", + "time": 1475305200000, + "username": "cstanleykn" + }, + { + "age": 63, + "cost": 23.89, + "country": "FR", + "price": 55, + "project": "logstash", + "state": "start", + "time": 1484031600000, + "username": "jgrayko" + }, + { + "age": 57, + "cost": 23.97, + "country": "TT", + "price": 59, + "project": "beats", + "state": "start", + "time": 1472454000000, + "username": "ldeankp" + }, + { + "age": 43, + "cost": 21.55, + "country": "CN", + "price": 52, + "project": "beats", + "state": "running", + "time": 1463468400000, + "username": "rphillipskq" + }, + { + "age": 18, + "cost": 23.64, + "country": "RU", + "price": 62, + "project": "opbeat", + "state": "start", + "time": 1486882800000, + "username": "jnicholskr" + }, + { + "age": 54, + "cost": 22.83, + "country": "PT", + "price": 47, + "project": "machine-learning", + "state": "done", + "time": 1490684400000, + "username": "rthomasks" + }, + { + "age": 68, + "cost": 22.98, + "country": "JP", + "price": 52, + "project": "machine-learning", + "state": "done", + "time": 1476082800000, + "username": "wdaykt" + }, + { + "age": 62, + "cost": 23.17, + "country": "FR", + "price": 67, + "project": "x-pack", + "state": "done", + "time": 1491548400000, + "username": "kboydku" + }, + { + "age": 66, + "cost": 21.87, + "country": "NI", + "price": 62, + "project": "elasticsearch", + "state": "running", + "time": 1484550000000, + "username": "kmillskv" + }, + { + "age": 77, + "cost": 24.15, + "country": "FR", + "price": 63, + "project": "logstash", + "state": "start", + "time": 1465282800000, + "username": "dporterkw" + }, + { + "age": 36, + "cost": 23.24, + "country": "BD", + "price": 43, + "project": "beats", + "state": "done", + "time": 1479193200000, + "username": "rbradleykx" + }, + { + "age": 75, + "cost": 23.8, + "country": "CA", + "price": 50, + "project": "machine-learning", + "state": "start", + "time": 1464246000000, + "username": "kbradleyky" + }, + { + "age": 72, + "cost": 23.15, + "country": "MD", + "price": 62, + "project": "kibana", + "state": "done", + "time": 1465887600000, + "username": "sortizkz" + }, + { + "age": 27, + "cost": 22.38, + "country": "PL", + "price": 51, + "project": "x-pack", + "state": "running", + "time": 1475823600000, + "username": "mrodriguezl0" + }, + { + "age": 56, + "cost": 22.82, + "country": "AZ", + "price": 64, + "project": "logstash", + "state": "done", + "time": 1470639600000, + "username": "pwatkinsl1" + }, + { + "age": 57, + "cost": 23.17, + "country": "FR", + "price": 62, + "project": "beats", + "state": "done", + "time": 1471158000000, + "username": "jbrooksl2" + }, + { + "age": 76, + "cost": 22.55, + "country": "FR", + "price": 69, + "project": "x-pack", + "state": "done", + "time": 1472194800000, + "username": "rgardnerl3" + }, + { + "age": 21, + "cost": 23.76, + "country": "SE", + "price": 60, + "project": "logstash", + "state": "start", + "time": 1471935600000, + "username": "adeanl4" + }, + { + "age": 64, + "cost": 24.01, + "country": "PL", + "price": 60, + "project": "beats", + "state": "start", + "time": 1471935600000, + "username": "wwarrenl5" + }, + { + "age": 58, + "cost": 22.33, + "country": "RU", + "price": 55, + "project": "kibana", + "state": "running", + "time": 1470553200000, + "username": "mellisl6" + }, + { + "age": 71, + "cost": 22.93, + "country": "RU", + "price": 53, + "project": "beats", + "state": "done", + "time": 1475564400000, + "username": "kwhitel7" + }, + { + "age": 75, + "cost": 24.37, + "country": "EE", + "price": 69, + "project": "machine-learning", + "state": "start", + "time": 1484550000000, + "username": "jburnsl8" + }, + { + "age": 74, + "cost": 22.96, + "country": "GR", + "price": 58, + "project": "opbeat", + "state": "done", + "time": 1478156400000, + "username": "dwillisl9" + }, + { + "age": 56, + "cost": 24.64, + "country": "US", + "price": 56, + "project": "logstash", + "state": "running", + "time": 1468134000000, + "username": "lfoxla" + }, + { + "age": 20, + "cost": 23, + "country": "FR", + "price": 54, + "project": "x-pack", + "state": "done", + "time": 1478934000000, + "username": "mreedlb" + }, + { + "age": 32, + "cost": 23.91, + "country": "GR", + "price": 58, + "project": "opbeat", + "state": "start", + "time": 1462086000000, + "username": "cpiercelc" + }, + { + "age": 68, + "cost": 22.7, + "country": "CA", + "price": 42, + "project": "machine-learning", + "state": "done", + "time": 1472194800000, + "username": "rreynoldsld" + }, + { + "age": 56, + "cost": 23.64, + "country": "PH", + "price": 64, + "project": "logstash", + "state": "start", + "time": 1489906800000, + "username": "mwilsonle" + }, + { + "age": 29, + "cost": 24.34, + "country": "PE", + "price": 53, + "project": "beats", + "state": "start", + "time": 1477292400000, + "username": "msimmonslf" + }, + { + "age": 23, + "cost": 23.55, + "country": "RU", + "price": 63, + "project": "kibana", + "state": "start", + "time": 1476601200000, + "username": "smyerslg" + }, + { + "age": 27, + "cost": 22.07, + "country": "PT", + "price": 52, + "project": "x-pack", + "state": "running", + "time": 1483513200000, + "username": "jowenslh" + }, + { + "age": 79, + "cost": 22.69, + "country": "ID", + "price": 54, + "project": "kibana", + "state": "done", + "time": 1470380400000, + "username": "dadamsli" + }, + { + "age": 40, + "cost": 22.55, + "country": "UA", + "price": 60, + "project": "machine-learning", + "state": "done", + "time": 1467356400000, + "username": "sdavislj" + }, + { + "age": 38, + "cost": 22.83, + "country": "CN", + "price": 49, + "project": "elasticsearch", + "state": "done", + "time": 1491634800000, + "username": "anguyenlk" + }, + { + "age": 45, + "cost": 22.83, + "country": "CD", + "price": 55, + "project": "elasticsearch", + "state": "done", + "time": 1474786800000, + "username": "bgreenell" + }, + { + "age": 77, + "cost": 23.5, + "country": "BR", + "price": 47, + "project": "logstash", + "state": "start", + "time": 1467183600000, + "username": "jfullerlm" + }, + { + "age": 73, + "cost": 23.12, + "country": "CN", + "price": 63, + "project": "elasticsearch", + "state": "done", + "time": 1474527600000, + "username": "jhernandezln" + }, + { + "age": 66, + "cost": 21.12, + "country": "BR", + "price": 66, + "project": "elasticsearch", + "state": "start", + "time": 1463554800000, + "username": "lcruzlo" + }, + { + "age": 48, + "cost": 23, + "country": "IR", + "price": 44, + "project": "x-pack", + "state": "done", + "time": 1475996400000, + "username": "afloreslp" + }, + { + "age": 75, + "cost": 24.27, + "country": "SI", + "price": 57, + "project": "machine-learning", + "state": "start", + "time": 1470898800000, + "username": "trichardslq" + }, + { + "age": 49, + "cost": 24.58, + "country": "PH", + "price": 64, + "project": "logstash", + "state": "running", + "time": 1471676400000, + "username": "lweaverlr" + }, + { + "age": 76, + "cost": 22.8, + "country": "KN", + "price": 46, + "project": "x-pack", + "state": "done", + "time": 1481094000000, + "username": "hjohnstonls" + }, + { + "age": 73, + "cost": 24.34, + "country": "JO", + "price": 69, + "project": "elasticsearch", + "state": "start", + "time": 1485327600000, + "username": "roliverlt" + }, + { + "age": 63, + "cost": 23.63, + "country": "RU", + "price": 51, + "project": "logstash", + "state": "start", + "time": 1481180400000, + "username": "jfernandezlu" + }, + { + "age": 51, + "cost": 23.79, + "country": "PH", + "price": 67, + "project": "kibana", + "state": "start", + "time": 1470985200000, + "username": "rpattersonlv" + }, + { + "age": 27, + "cost": 24.02, + "country": "CN", + "price": 42, + "project": "x-pack", + "state": "start", + "time": 1474354800000, + "username": "rburtonlw" + }, + { + "age": 60, + "cost": 22.32, + "country": "CO", + "price": 63, + "project": "opbeat", + "state": "running", + "time": 1481094000000, + "username": "ehickslx" + }, + { + "age": 70, + "cost": 22.92, + "country": "MX", + "price": 59, + "project": "logstash", + "state": "done", + "time": 1487833200000, + "username": "mstevensly" + }, + { + "age": 37, + "cost": 21.53, + "country": "PH", + "price": 55, + "project": "kibana", + "state": "running", + "time": 1486969200000, + "username": "cbutlerlz" + }, + { + "age": 34, + "cost": 22.3, + "country": "FR", + "price": 54, + "project": "x-pack", + "state": "running", + "time": 1472281200000, + "username": "kbarnesm0" + }, + { + "age": 43, + "cost": 22.29, + "country": "SV", + "price": 58, + "project": "beats", + "state": "running", + "time": 1479625200000, + "username": "ajohnstonm1" + }, + { + "age": 58, + "cost": 22.92, + "country": "ID", + "price": 57, + "project": "kibana", + "state": "done", + "time": 1488438000000, + "username": "afieldsm2" + }, + { + "age": 69, + "cost": 24.44, + "country": "PT", + "price": 52, + "project": "x-pack", + "state": "start", + "time": 1463209200000, + "username": "jgilbertm3" + }, + { + "age": 43, + "cost": 23.54, + "country": "KZ", + "price": 62, + "project": "beats", + "state": "start", + "time": 1474354800000, + "username": "sgarrettm4" + }, + { + "age": 31, + "cost": 22.92, + "country": "RU", + "price": 54, + "project": "elasticsearch", + "state": "done", + "time": 1478847600000, + "username": "hsimsm5" + }, + { + "age": 23, + "cost": 23.61, + "country": "MG", + "price": 65, + "project": "kibana", + "state": "start", + "time": 1467529200000, + "username": "ehallm6" + }, + { + "age": 35, + "cost": 22.2, + "country": "RU", + "price": 48, + "project": "logstash", + "state": "running", + "time": 1482476400000, + "username": "msimsm7" + }, + { + "age": 33, + "cost": 23.94, + "country": "PT", + "price": 42, + "project": "machine-learning", + "state": "start", + "time": 1489215600000, + "username": "djamesm8" + }, + { + "age": 65, + "cost": 21.95, + "country": "RU", + "price": 61, + "project": "kibana", + "state": "running", + "time": 1465369200000, + "username": "ahansonm9" + }, + { + "age": 31, + "cost": 21.43, + "country": "CN", + "price": 65, + "project": "elasticsearch", + "state": "start", + "time": 1491634800000, + "username": "whunterma" + }, + { + "age": 36, + "cost": 23.01, + "country": "ET", + "price": 62, + "project": "beats", + "state": "done", + "time": 1462258800000, + "username": "jstanleymb" + }, + { + "age": 52, + "cost": 23.95, + "country": "ID", + "price": 50, + "project": "elasticsearch", + "state": "start", + "time": 1482217200000, + "username": "ngriffinmc" + }, + { + "age": 63, + "cost": 23.27, + "country": "SE", + "price": 66, + "project": "logstash", + "state": "done", + "time": 1474354800000, + "username": "talexandermd" + }, + { + "age": 68, + "cost": 24.56, + "country": "MX", + "price": 56, + "project": "machine-learning", + "state": "running", + "time": 1481266800000, + "username": "tgonzalezme" + }, + { + "age": 78, + "cost": 23.07, + "country": "RU", + "price": 61, + "project": "beats", + "state": "done", + "time": 1478761200000, + "username": "sfreemanmf" + }, + { + "age": 65, + "cost": 24.37, + "country": "CN", + "price": 54, + "project": "kibana", + "state": "start", + "time": 1481094000000, + "username": "hhuntmg" + }, + { + "age": 65, + "cost": 21.48, + "country": "CZ", + "price": 47, + "project": "kibana", + "state": "start", + "time": 1476342000000, + "username": "sdeanmh" + }, + { + "age": 21, + "cost": 23.25, + "country": "RU", + "price": 62, + "project": "logstash", + "state": "done", + "time": 1461567600000, + "username": "kellismi" + }, + { + "age": 76, + "cost": 23.03, + "country": "VN", + "price": 59, + "project": "x-pack", + "state": "done", + "time": 1487401200000, + "username": "emillermj" + }, + { + "age": 41, + "cost": 23.05, + "country": "PH", + "price": 63, + "project": "x-pack", + "state": "done", + "time": 1462518000000, + "username": "mbaileymk" + }, + { + "age": 50, + "cost": 23.57, + "country": "US", + "price": 56, + "project": "beats", + "state": "start", + "time": 1477724400000, + "username": "rfosterml" + }, + { + "age": 21, + "cost": 23.43, + "country": "UA", + "price": 69, + "project": "logstash", + "state": "done", + "time": 1486191600000, + "username": "praymm" + }, + { + "age": 25, + "cost": 22.65, + "country": "TH", + "price": 56, + "project": "opbeat", + "state": "done", + "time": 1475046000000, + "username": "shuntmn" + }, + { + "age": 69, + "cost": 22.23, + "country": "PH", + "price": 63, + "project": "x-pack", + "state": "running", + "time": 1468047600000, + "username": "aromeromo" + }, + { + "age": 28, + "cost": 24.8, + "country": "MY", + "price": 44, + "project": "logstash", + "state": "running", + "time": 1476428400000, + "username": "lmeyermp" + }, + { + "age": 72, + "cost": 22.49, + "country": "CZ", + "price": 46, + "project": "kibana", + "state": "running", + "time": 1482130800000, + "username": "jreynoldsmq" + }, + { + "age": 50, + "cost": 21.4, + "country": "MA", + "price": 38, + "project": "beats", + "state": "start", + "time": 1477119600000, + "username": "bfieldsmr" + }, + { + "age": 56, + "cost": 22.34, + "country": "GR", + "price": 53, + "project": "logstash", + "state": "running", + "time": 1461826800000, + "username": "jnguyenms" + }, + { + "age": 64, + "cost": 22.22, + "country": "CO", + "price": 62, + "project": "beats", + "state": "running", + "time": 1464678000000, + "username": "tchapmanmt" + }, + { + "age": 20, + "cost": 23.03, + "country": "CN", + "price": 61, + "project": "x-pack", + "state": "done", + "time": 1489042800000, + "username": "ajacobsmu" + }, + { + "age": 18, + "cost": 23.43, + "country": "CN", + "price": 54, + "project": "opbeat", + "state": "done", + "time": 1460271600000, + "username": "kphillipsmv" + }, + { + "age": 30, + "cost": 23.6, + "country": "CO", + "price": 67, + "project": "kibana", + "state": "start", + "time": 1467961200000, + "username": "glongmw" + }, + { + "age": 53, + "cost": 23.57, + "country": "PH", + "price": 52, + "project": "opbeat", + "state": "start", + "time": 1479625200000, + "username": "lmitchellmx" + }, + { + "age": 68, + "cost": 24.13, + "country": "PL", + "price": 53, + "project": "machine-learning", + "state": "start", + "time": 1486710000000, + "username": "jcarrollmy" + }, + { + "age": 55, + "cost": 21.96, + "country": "CN", + "price": 57, + "project": "x-pack", + "state": "running", + "time": 1490079600000, + "username": "wspencermz" + }, + { + "age": 35, + "cost": 21.84, + "country": "NO", + "price": 71, + "project": "logstash", + "state": "running", + "time": 1460271600000, + "username": "jfloresn0" + }, + { + "age": 34, + "cost": 23.2, + "country": "BR", + "price": 53, + "project": "x-pack", + "state": "done", + "time": 1486969200000, + "username": "jramirezn1" + }, + { + "age": 73, + "cost": 24.19, + "country": "CN", + "price": 52, + "project": "elasticsearch", + "state": "start", + "time": 1487919600000, + "username": "plarsonn2" + }, + { + "age": 58, + "cost": 23.32, + "country": "PH", + "price": 51, + "project": "kibana", + "state": "done", + "time": 1487055600000, + "username": "mreidn3" + }, + { + "age": 21, + "cost": 22.42, + "country": "CN", + "price": 49, + "project": "logstash", + "state": "running", + "time": 1463295600000, + "username": "sfraziern4" + }, + { + "age": 39, + "cost": 23.2, + "country": "IT", + "price": 52, + "project": "opbeat", + "state": "done", + "time": 1467442800000, + "username": "agarcian5" + }, + { + "age": 50, + "cost": 22.33, + "country": "PL", + "price": 52, + "project": "beats", + "state": "running", + "time": 1473145200000, + "username": "jryann6" + }, + { + "age": 43, + "cost": 23, + "country": "IR", + "price": 61, + "project": "beats", + "state": "done", + "time": 1475823600000, + "username": "wmyersn7" + }, + { + "age": 67, + "cost": 22.35, + "country": "ID", + "price": 55, + "project": "opbeat", + "state": "running", + "time": 1485327600000, + "username": "btuckern8" + }, + { + "age": 50, + "cost": 23.54, + "country": "FR", + "price": 60, + "project": "beats", + "state": "start", + "time": 1489474800000, + "username": "agarcian9" + }, + { + "age": 27, + "cost": 23.86, + "country": "RU", + "price": 44, + "project": "x-pack", + "state": "start", + "time": 1488783600000, + "username": "dcampbellna" + }, + { + "age": 27, + "cost": 23.1, + "country": "FR", + "price": 52, + "project": "x-pack", + "state": "done", + "time": 1477724400000, + "username": "nwarrennb" + }, + { + "age": 33, + "cost": 22.7, + "country": "HU", + "price": 51, + "project": "machine-learning", + "state": "done", + "time": 1474182000000, + "username": "ajenkinsnc" + }, + { + "age": 26, + "cost": 23.57, + "country": "PE", + "price": 47, + "project": "machine-learning", + "state": "start", + "time": 1487660400000, + "username": "crichardsnd" + }, + { + "age": 39, + "cost": 23.16, + "country": "CN", + "price": 62, + "project": "opbeat", + "state": "done", + "time": 1462431600000, + "username": "edavisne" + }, + { + "age": 43, + "cost": 22.96, + "country": "ID", + "price": 71, + "project": "beats", + "state": "done", + "time": 1468825200000, + "username": "cbaileynf" + }, + { + "age": 75, + "cost": 23.09, + "country": "PL", + "price": 58, + "project": "machine-learning", + "state": "done", + "time": 1481871600000, + "username": "cpetersng" + }, + { + "age": 38, + "cost": 23.48, + "country": "MA", + "price": 53, + "project": "elasticsearch", + "state": "done", + "time": 1462086000000, + "username": "jlanenh" + }, + { + "age": 67, + "cost": 23.05, + "country": "CO", + "price": 50, + "project": "opbeat", + "state": "done", + "time": 1476169200000, + "username": "dsimmonsni" + }, + { + "age": 47, + "cost": 24.1, + "country": "VN", + "price": 56, + "project": "machine-learning", + "state": "start", + "time": 1490770800000, + "username": "kwarrennj" + }, + { + "age": 39, + "cost": 22.49, + "country": "ID", + "price": 69, + "project": "opbeat", + "state": "running", + "time": 1476255600000, + "username": "ptorresnk" + }, + { + "age": 78, + "cost": 22.26, + "country": "IR", + "price": 71, + "project": "beats", + "state": "running", + "time": 1489647600000, + "username": "aramireznl" + }, + { + "age": 63, + "cost": 22.76, + "country": "AM", + "price": 57, + "project": "logstash", + "state": "done", + "time": 1466665200000, + "username": "jjenkinsnm" + }, + { + "age": 28, + "cost": 21.97, + "country": "BR", + "price": 51, + "project": "logstash", + "state": "running", + "time": 1466578800000, + "username": "swalkernn" + }, + { + "age": 65, + "cost": 24.83, + "country": "RU", + "price": 45, + "project": "kibana", + "state": "running", + "time": 1486882800000, + "username": "brobertsno" + }, + { + "age": 59, + "cost": 22.21, + "country": "CN", + "price": 69, + "project": "elasticsearch", + "state": "running", + "time": 1490943600000, + "username": "bberrynp" + }, + { + "age": 62, + "cost": 23.66, + "country": "CN", + "price": 60, + "project": "x-pack", + "state": "start", + "time": 1462777200000, + "username": "lrodrigueznq" + }, + { + "age": 77, + "cost": 23.61, + "country": "CN", + "price": 46, + "project": "logstash", + "state": "start", + "time": 1471244400000, + "username": "jreidnr" + }, + { + "age": 18, + "cost": 23.58, + "country": "TZ", + "price": 62, + "project": "opbeat", + "state": "start", + "time": 1475132400000, + "username": "jmurrayns" + }, + { + "age": 47, + "cost": 24.46, + "country": "VE", + "price": 67, + "project": "machine-learning", + "state": "start", + "time": 1468911600000, + "username": "gtaylornt" + }, + { + "age": 23, + "cost": 21.78, + "country": "ID", + "price": 63, + "project": "kibana", + "state": "running", + "time": 1486191600000, + "username": "rgriffinnu" + }, + { + "age": 35, + "cost": 23.07, + "country": "IL", + "price": 59, + "project": "logstash", + "state": "done", + "time": 1466838000000, + "username": "sfieldsnv" + }, + { + "age": 55, + "cost": 23.21, + "country": "CN", + "price": 69, + "project": "x-pack", + "state": "done", + "time": 1476601200000, + "username": "ereidnw" + }, + { + "age": 23, + "cost": 24.26, + "country": "PL", + "price": 45, + "project": "kibana", + "state": "start", + "time": 1464678000000, + "username": "bhawkinsnx" + }, + { + "age": 18, + "cost": 22.98, + "country": "CN", + "price": 65, + "project": "opbeat", + "state": "done", + "time": 1474441200000, + "username": "cgrayny" + }, + { + "age": 66, + "cost": 22.83, + "country": "MX", + "price": 47, + "project": "elasticsearch", + "state": "done", + "time": 1470466800000, + "username": "fhughesnz" + }, + { + "age": 57, + "cost": 22.92, + "country": "NG", + "price": 53, + "project": "beats", + "state": "done", + "time": 1470466800000, + "username": "lwelcho0" + }, + { + "age": 80, + "cost": 23.97, + "country": "SE", + "price": 61, + "project": "elasticsearch", + "state": "start", + "time": 1491375600000, + "username": "jwatkinso1" + }, + { + "age": 46, + "cost": 23.36, + "country": "JP", + "price": 59, + "project": "opbeat", + "state": "done", + "time": 1481353200000, + "username": "awoodo2" + }, + { + "age": 74, + "cost": 21.17, + "country": "CN", + "price": 65, + "project": "opbeat", + "state": "start", + "time": 1486969200000, + "username": "pmatthewso3" + }, + { + "age": 36, + "cost": 22.96, + "country": "CZ", + "price": 55, + "project": "beats", + "state": "done", + "time": 1474009200000, + "username": "rhudsono4" + }, + { + "age": 62, + "cost": 21.58, + "country": "SE", + "price": 57, + "project": "x-pack", + "state": "running", + "time": 1481094000000, + "username": "rwardo5" + }, + { + "age": 54, + "cost": 24.58, + "country": "CN", + "price": 65, + "project": "machine-learning", + "state": "running", + "time": 1486882800000, + "username": "cgomezo6" + }, + { + "age": 58, + "cost": 22.56, + "country": "FR", + "price": 52, + "project": "kibana", + "state": "done", + "time": 1468393200000, + "username": "jburtono7" + }, + { + "age": 47, + "cost": 23.38, + "country": "IE", + "price": 45, + "project": "machine-learning", + "state": "done", + "time": 1461567600000, + "username": "dcarpentero8" + }, + { + "age": 41, + "cost": 23.42, + "country": "ID", + "price": 46, + "project": "x-pack", + "state": "done", + "time": 1485154800000, + "username": "thunto9" + }, + { + "age": 47, + "cost": 23.2, + "country": "PL", + "price": 53, + "project": "machine-learning", + "state": "done", + "time": 1489647600000, + "username": "ssnyderoa" + }, + { + "age": 80, + "cost": 22.7, + "country": "US", + "price": 57, + "project": "elasticsearch", + "state": "done", + "time": 1465282800000, + "username": "mkimob" + }, + { + "age": 22, + "cost": 21.66, + "country": "PH", + "price": 43, + "project": "beats", + "state": "running", + "time": 1462086000000, + "username": "ehansonoc" + }, + { + "age": 37, + "cost": 22.29, + "country": "RU", + "price": 56, + "project": "kibana", + "state": "running", + "time": 1463036400000, + "username": "wspencerod" + }, + { + "age": 65, + "cost": 23.28, + "country": "ES", + "price": 61, + "project": "kibana", + "state": "done", + "time": 1478934000000, + "username": "khansenoe" + }, + { + "age": 64, + "cost": 24.75, + "country": "JP", + "price": 63, + "project": "beats", + "state": "running", + "time": 1473836400000, + "username": "jcruzof" + }, + { + "age": 51, + "cost": 22.73, + "country": "PE", + "price": 54, + "project": "kibana", + "state": "done", + "time": 1467097200000, + "username": "tkelleyog" + }, + { + "age": 76, + "cost": 23.04, + "country": "RU", + "price": 51, + "project": "x-pack", + "state": "done", + "time": 1468998000000, + "username": "jbrooksoh" + }, + { + "age": 31, + "cost": 23.49, + "country": "PH", + "price": 55, + "project": "elasticsearch", + "state": "done", + "time": 1465542000000, + "username": "ncooperoi" + }, + { + "age": 78, + "cost": 23.66, + "country": "JP", + "price": 59, + "project": "beats", + "state": "start", + "time": 1475478000000, + "username": "ddiazoj" + }, + { + "age": 64, + "cost": 24.02, + "country": "HN", + "price": 57, + "project": "beats", + "state": "start", + "time": 1462345200000, + "username": "kpowellok" + }, + { + "age": 72, + "cost": 24.54, + "country": "ID", + "price": 60, + "project": "kibana", + "state": "running", + "time": 1476514800000, + "username": "pporterol" + }, + { + "age": 57, + "cost": 21.5, + "country": "DE", + "price": 59, + "project": "beats", + "state": "running", + "time": 1481266800000, + "username": "hgarzaom" + }, + { + "age": 71, + "cost": 22.81, + "country": "ZA", + "price": 45, + "project": "beats", + "state": "done", + "time": 1480748400000, + "username": "wevanson" + }, + { + "age": 50, + "cost": 24.02, + "country": "GR", + "price": 70, + "project": "beats", + "state": "start", + "time": 1470985200000, + "username": "jlaneoo" + }, + { + "age": 23, + "cost": 21.49, + "country": "PH", + "price": 49, + "project": "kibana", + "state": "start", + "time": 1467270000000, + "username": "gfergusonop" + }, + { + "age": 23, + "cost": 22.64, + "country": "ID", + "price": 60, + "project": "kibana", + "state": "done", + "time": 1482303600000, + "username": "rwillisoq" + }, + { + "age": 80, + "cost": 23.37, + "country": "CN", + "price": 68, + "project": "elasticsearch", + "state": "done", + "time": 1490598000000, + "username": "nfulleror" + }, + { + "age": 65, + "cost": 23.47, + "country": "SE", + "price": 48, + "project": "kibana", + "state": "done", + "time": 1484463600000, + "username": "tellisos" + }, + { + "age": 38, + "cost": 24.55, + "country": "RS", + "price": 53, + "project": "elasticsearch", + "state": "running", + "time": 1461654000000, + "username": "mfosterot" + }, + { + "age": 70, + "cost": 21.01, + "country": "CN", + "price": 66, + "project": "logstash", + "state": "start", + "time": 1484895600000, + "username": "astevensou" + }, + { + "age": 65, + "cost": 23.49, + "country": "TH", + "price": 51, + "project": "kibana", + "state": "done", + "time": 1468911600000, + "username": "bdixonov" + }, + { + "age": 36, + "cost": 24.22, + "country": "ID", + "price": 45, + "project": "beats", + "state": "start", + "time": 1481785200000, + "username": "fbrooksow" + }, + { + "age": 61, + "cost": 23.65, + "country": "UA", + "price": 48, + "project": "machine-learning", + "state": "start", + "time": 1479798000000, + "username": "pbryantox" + }, + { + "age": 44, + "cost": 23.09, + "country": "CN", + "price": 53, + "project": "kibana", + "state": "done", + "time": 1471417200000, + "username": "jsullivanoy" + }, + { + "age": 27, + "cost": 23.29, + "country": "ID", + "price": 49, + "project": "x-pack", + "state": "done", + "time": 1491375600000, + "username": "revansoz" + }, + { + "age": 38, + "cost": 23.81, + "country": "LU", + "price": 56, + "project": "elasticsearch", + "state": "start", + "time": 1465887600000, + "username": "schavezp0" + }, + { + "age": 64, + "cost": 23.07, + "country": "CN", + "price": 56, + "project": "beats", + "state": "done", + "time": 1462518000000, + "username": "imorganp1" + }, + { + "age": 80, + "cost": 21.95, + "country": "MX", + "price": 49, + "project": "elasticsearch", + "state": "running", + "time": 1468998000000, + "username": "sfullerp2" + }, + { + "age": 36, + "cost": 21.16, + "country": "ID", + "price": 56, + "project": "beats", + "state": "start", + "time": 1491202800000, + "username": "hjonesp3" + }, + { + "age": 51, + "cost": 23.67, + "country": "SI", + "price": 56, + "project": "kibana", + "state": "start", + "time": 1472454000000, + "username": "abaileyp4" + }, + { + "age": 35, + "cost": 23.26, + "country": "IS", + "price": 69, + "project": "logstash", + "state": "done", + "time": 1472540400000, + "username": "chowardp5" + }, + { + "age": 72, + "cost": 22.88, + "country": "ZA", + "price": 50, + "project": "kibana", + "state": "done", + "time": 1474441200000, + "username": "jandersonp6" + }, + { + "age": 25, + "cost": 23.37, + "country": "RU", + "price": 61, + "project": "opbeat", + "state": "done", + "time": 1472022000000, + "username": "pclarkp7" + }, + { + "age": 58, + "cost": 23.99, + "country": "NG", + "price": 48, + "project": "kibana", + "state": "start", + "time": 1484722800000, + "username": "trichardsonp8" + }, + { + "age": 63, + "cost": 21.74, + "country": "MY", + "price": 50, + "project": "logstash", + "state": "running", + "time": 1467010800000, + "username": "jleep9" + }, + { + "age": 50, + "cost": 22.3, + "country": "SY", + "price": 63, + "project": "beats", + "state": "running", + "time": 1491116400000, + "username": "gbowmanpa" + }, + { + "age": 31, + "cost": 22.65, + "country": "CN", + "price": 52, + "project": "elasticsearch", + "state": "done", + "time": 1483686000000, + "username": "hburtonpb" + }, + { + "age": 61, + "cost": 22.95, + "country": "SI", + "price": 61, + "project": "machine-learning", + "state": "done", + "time": 1489129200000, + "username": "ewoodspc" + }, + { + "age": 61, + "cost": 23.15, + "country": "KE", + "price": 53, + "project": "machine-learning", + "state": "done", + "time": 1467529200000, + "username": "hhawkinspd" + }, + { + "age": 76, + "cost": 22.12, + "country": "CN", + "price": 65, + "project": "x-pack", + "state": "running", + "time": 1488178800000, + "username": "ebowmanpe" + }, + { + "age": 68, + "cost": 24.14, + "country": "LK", + "price": 57, + "project": "machine-learning", + "state": "start", + "time": 1463554800000, + "username": "bflorespf" + }, + { + "age": 33, + "cost": 22.67, + "country": "TH", + "price": 47, + "project": "machine-learning", + "state": "done", + "time": 1464246000000, + "username": "plawsonpg" + }, + { + "age": 27, + "cost": 20.8, + "country": "SE", + "price": 69, + "project": "x-pack", + "state": "start", + "time": 1490338800000, + "username": "hfullerph" + }, + { + "age": 32, + "cost": 21.8, + "country": "AM", + "price": 56, + "project": "opbeat", + "state": "running", + "time": 1471330800000, + "username": "tfranklinpi" + }, + { + "age": 42, + "cost": 23.23, + "country": "BR", + "price": 54, + "project": "logstash", + "state": "done", + "time": 1462086000000, + "username": "jwebbpj" + }, + { + "age": 49, + "cost": 24.36, + "country": "MK", + "price": 45, + "project": "logstash", + "state": "start", + "time": 1462345200000, + "username": "cwarrenpk" + }, + { + "age": 51, + "cost": 24.68, + "country": "PH", + "price": 54, + "project": "kibana", + "state": "running", + "time": 1463986800000, + "username": "dmurphypl" + }, + { + "age": 63, + "cost": 22.54, + "country": "RS", + "price": 62, + "project": "logstash", + "state": "done", + "time": 1472454000000, + "username": "bwestpm" + }, + { + "age": 50, + "cost": 24.51, + "country": "AU", + "price": 40, + "project": "beats", + "state": "running", + "time": 1470380400000, + "username": "bhernandezpn" + }, + { + "age": 51, + "cost": 23.23, + "country": "JP", + "price": 70, + "project": "kibana", + "state": "done", + "time": 1489474800000, + "username": "jalexanderpo" + }, + { + "age": 73, + "cost": 22.16, + "country": "AF", + "price": 63, + "project": "elasticsearch", + "state": "running", + "time": 1476946800000, + "username": "dmorganpp" + }, + { + "age": 78, + "cost": 22.69, + "country": "FI", + "price": 59, + "project": "beats", + "state": "done", + "time": 1482908400000, + "username": "lweaverpq" + }, + { + "age": 65, + "cost": 21.09, + "country": "BI", + "price": 58, + "project": "kibana", + "state": "start", + "time": 1460876400000, + "username": "jbaileypr" + }, + { + "age": 32, + "cost": 23.25, + "country": "RU", + "price": 50, + "project": "opbeat", + "state": "done", + "time": 1471244400000, + "username": "kreyesps" + }, + { + "age": 27, + "cost": 22.53, + "country": "PT", + "price": 61, + "project": "x-pack", + "state": "done", + "time": 1487919600000, + "username": "mlynchpt" + }, + { + "age": 59, + "cost": 23.47, + "country": "CO", + "price": 48, + "project": "elasticsearch", + "state": "done", + "time": 1465196400000, + "username": "tpiercepu" + }, + { + "age": 77, + "cost": 23.02, + "country": "SE", + "price": 47, + "project": "logstash", + "state": "done", + "time": 1480489200000, + "username": "ewebbpv" + }, + { + "age": 44, + "cost": 24.28, + "country": "RU", + "price": 64, + "project": "kibana", + "state": "start", + "time": 1480316400000, + "username": "drosspw" + }, + { + "age": 34, + "cost": 22.06, + "country": "IR", + "price": 58, + "project": "x-pack", + "state": "running", + "time": 1476601200000, + "username": "jmccoypx" + }, + { + "age": 60, + "cost": 21.06, + "country": "AR", + "price": 40, + "project": "opbeat", + "state": "start", + "time": 1467874800000, + "username": "bwhitepy" + }, + { + "age": 33, + "cost": 22.08, + "country": "BR", + "price": 44, + "project": "machine-learning", + "state": "running", + "time": 1480662000000, + "username": "jkellypz" + }, + { + "age": 59, + "cost": 23.5, + "country": "CZ", + "price": 54, + "project": "elasticsearch", + "state": "start", + "time": 1473231600000, + "username": "darmstrongq0" + }, + { + "age": 73, + "cost": 21.15, + "country": "GR", + "price": 52, + "project": "elasticsearch", + "state": "start", + "time": 1481612400000, + "username": "dperryq1" + }, + { + "age": 49, + "cost": 22.39, + "country": "TH", + "price": 54, + "project": "logstash", + "state": "running", + "time": 1488178800000, + "username": "dschmidtq2" + }, + { + "age": 65, + "cost": 24.42, + "country": "CZ", + "price": 54, + "project": "kibana", + "state": "start", + "time": 1468220400000, + "username": "aandrewsq3" + }, + { + "age": 49, + "cost": 21.22, + "country": "GH", + "price": 60, + "project": "logstash", + "state": "start", + "time": 1491030000000, + "username": "ameyerq4" + }, + { + "age": 39, + "cost": 23.53, + "country": "ID", + "price": 64, + "project": "opbeat", + "state": "start", + "time": 1484463600000, + "username": "dwoodsq5" + }, + { + "age": 19, + "cost": 23.49, + "country": "CN", + "price": 51, + "project": "machine-learning", + "state": "done", + "time": 1467183600000, + "username": "jmooreq6" + }, + { + "age": 27, + "cost": 23.14, + "country": "NG", + "price": 56, + "project": "x-pack", + "state": "done", + "time": 1482822000000, + "username": "sspencerq7" + }, + { + "age": 30, + "cost": 23.07, + "country": "MX", + "price": 66, + "project": "kibana", + "state": "done", + "time": 1481785200000, + "username": "jtorresq8" + }, + { + "age": 67, + "cost": 23.06, + "country": "DO", + "price": 49, + "project": "opbeat", + "state": "done", + "time": 1466924400000, + "username": "mwestq9" + }, + { + "age": 38, + "cost": 23.48, + "country": "CN", + "price": 61, + "project": "elasticsearch", + "state": "done", + "time": 1487487600000, + "username": "mnguyenqa" + }, + { + "age": 68, + "cost": 23.41, + "country": "MX", + "price": 52, + "project": "machine-learning", + "state": "done", + "time": 1476169200000, + "username": "mroseqb" + }, + { + "age": 25, + "cost": 22.11, + "country": "TH", + "price": 39, + "project": "opbeat", + "state": "running", + "time": 1477983600000, + "username": "mjacksonqc" + }, + { + "age": 20, + "cost": 22.46, + "country": "ID", + "price": 55, + "project": "x-pack", + "state": "running", + "time": 1487833200000, + "username": "cyoungqd" + }, + { + "age": 64, + "cost": 21.55, + "country": "ET", + "price": 60, + "project": "beats", + "state": "running", + "time": 1464850800000, + "username": "cjenkinsqe" + }, + { + "age": 25, + "cost": 22.36, + "country": "SE", + "price": 50, + "project": "opbeat", + "state": "running", + "time": 1482822000000, + "username": "jhartqf" + }, + { + "age": 45, + "cost": 23.73, + "country": "CN", + "price": 47, + "project": "elasticsearch", + "state": "start", + "time": 1465887600000, + "username": "cgutierrezqg" + }, + { + "age": 49, + "cost": 24.47, + "country": "CN", + "price": 63, + "project": "logstash", + "state": "start", + "time": 1487228400000, + "username": "pgarzaqh" + }, + { + "age": 72, + "cost": 21.43, + "country": "ID", + "price": 74, + "project": "kibana", + "state": "start", + "time": 1474959600000, + "username": "jbrownqi" + }, + { + "age": 48, + "cost": 22.55, + "country": "TH", + "price": 54, + "project": "x-pack", + "state": "done", + "time": 1462863600000, + "username": "jhartqj" + }, + { + "age": 80, + "cost": 23.91, + "country": "CN", + "price": 56, + "project": "elasticsearch", + "state": "start", + "time": 1488610800000, + "username": "dscottqk" + }, + { + "age": 54, + "cost": 23.68, + "country": "SE", + "price": 58, + "project": "machine-learning", + "state": "start", + "time": 1481785200000, + "username": "wlittleql" + }, + { + "age": 18, + "cost": 22.39, + "country": "PH", + "price": 49, + "project": "opbeat", + "state": "running", + "time": 1465974000000, + "username": "sfieldsqm" + }, + { + "age": 32, + "cost": 23.4, + "country": "CN", + "price": 66, + "project": "opbeat", + "state": "done", + "time": 1487833200000, + "username": "alarsonqn" + }, + { + "age": 71, + "cost": 22.45, + "country": "RU", + "price": 49, + "project": "beats", + "state": "running", + "time": 1487314800000, + "username": "dowensqo" + }, + { + "age": 68, + "cost": 22.67, + "country": "PE", + "price": 66, + "project": "machine-learning", + "state": "done", + "time": 1485673200000, + "username": "jolsonqp" + }, + { + "age": 45, + "cost": 21.87, + "country": "FR", + "price": 63, + "project": "elasticsearch", + "state": "running", + "time": 1483945200000, + "username": "galvarezqq" + }, + { + "age": 74, + "cost": 23.68, + "country": "BR", + "price": 51, + "project": "opbeat", + "state": "start", + "time": 1479279600000, + "username": "rcooperqr" + }, + { + "age": 21, + "cost": 22, + "country": "FR", + "price": 55, + "project": "logstash", + "state": "running", + "time": 1473922800000, + "username": "dclarkqs" + }, + { + "age": 48, + "cost": 21.99, + "country": "CN", + "price": 66, + "project": "x-pack", + "state": "running", + "time": 1463986800000, + "username": "rdunnqt" + }, + { + "age": 58, + "cost": 23.27, + "country": "CN", + "price": 63, + "project": "kibana", + "state": "done", + "time": 1470380400000, + "username": "jpetersqu" + }, + { + "age": 26, + "cost": 22.51, + "country": "CN", + "price": 59, + "project": "machine-learning", + "state": "done", + "time": 1476082800000, + "username": "nrobertsqv" + }, + { + "age": 29, + "cost": 23.92, + "country": "HT", + "price": 66, + "project": "beats", + "state": "start", + "time": 1490079600000, + "username": "jwestqw" + }, + { + "age": 69, + "cost": 23.67, + "country": "AR", + "price": 52, + "project": "x-pack", + "state": "start", + "time": 1475650800000, + "username": "awardqx" + }, + { + "age": 66, + "cost": 24.03, + "country": "US", + "price": 74, + "project": "elasticsearch", + "state": "start", + "time": 1479970800000, + "username": "tgilbertqy" + }, + { + "age": 30, + "cost": 24.41, + "country": "HN", + "price": 63, + "project": "kibana", + "state": "start", + "time": 1465455600000, + "username": "jstevensqz" + }, + { + "age": 43, + "cost": 22.71, + "country": "ID", + "price": 57, + "project": "beats", + "state": "done", + "time": 1489734000000, + "username": "kmeyerr0" + }, + { + "age": 19, + "cost": 23.65, + "country": "PL", + "price": 42, + "project": "machine-learning", + "state": "start", + "time": 1480143600000, + "username": "kpiercer1" + }, + { + "age": 48, + "cost": 24.15, + "country": "ID", + "price": 54, + "project": "x-pack", + "state": "start", + "time": 1481958000000, + "username": "dscottr2" + }, + { + "age": 19, + "cost": 22.23, + "country": "PT", + "price": 52, + "project": "machine-learning", + "state": "running", + "time": 1474700400000, + "username": "hstewartr3" + }, + { + "age": 22, + "cost": 22.95, + "country": "ID", + "price": 53, + "project": "beats", + "state": "done", + "time": 1482649200000, + "username": "ckimr4" + }, + { + "age": 26, + "cost": 24.6, + "country": "RU", + "price": 61, + "project": "machine-learning", + "state": "running", + "time": 1486278000000, + "username": "mgutierrezr5" + }, + { + "age": 22, + "cost": 25.56, + "country": "TH", + "price": 60, + "project": "beats", + "state": "done", + "time": 1460876400000, + "username": "hgrahamr6" + }, + { + "age": 57, + "cost": 21.5, + "country": "ID", + "price": 52, + "project": "beats", + "state": "running", + "time": 1461308400000, + "username": "dfosterr7" + }, + { + "age": 22, + "cost": 23.1, + "country": "CN", + "price": 39, + "project": "beats", + "state": "done", + "time": 1468911600000, + "username": "awebbr8" + }, + { + "age": 42, + "cost": 23.1, + "country": "GH", + "price": 62, + "project": "logstash", + "state": "done", + "time": 1488438000000, + "username": "lpetersr9" + }, + { + "age": 67, + "cost": 22.55, + "country": "ID", + "price": 54, + "project": "opbeat", + "state": "done", + "time": 1486450800000, + "username": "tpattersonra" + }, + { + "age": 76, + "cost": 21.94, + "country": "PL", + "price": 46, + "project": "x-pack", + "state": "running", + "time": 1476342000000, + "username": "rsimsrb" + }, + { + "age": 76, + "cost": 23.93, + "country": "VE", + "price": 52, + "project": "x-pack", + "state": "start", + "time": 1481612400000, + "username": "spetersonrc" + }, + { + "age": 61, + "cost": 22.17, + "country": "ID", + "price": 53, + "project": "machine-learning", + "state": "running", + "time": 1471330800000, + "username": "amontgomeryrd" + }, + { + "age": 60, + "cost": 22.7, + "country": "CN", + "price": 66, + "project": "opbeat", + "state": "done", + "time": 1465542000000, + "username": "swelchre" + }, + { + "age": 72, + "cost": 24.2, + "country": "FR", + "price": 59, + "project": "kibana", + "state": "start", + "time": 1467961200000, + "username": "cgordonrf" + }, + { + "age": 78, + "cost": 24.82, + "country": "NZ", + "price": 62, + "project": "beats", + "state": "running", + "time": 1462777200000, + "username": "dmartinezrg" + }, + { + "age": 62, + "cost": 22.55, + "country": "ID", + "price": 48, + "project": "x-pack", + "state": "done", + "time": 1490166000000, + "username": "cwashingtonrh" + }, + { + "age": 35, + "cost": 22.99, + "country": "ID", + "price": 60, + "project": "logstash", + "state": "done", + "time": 1475478000000, + "username": "cpayneri" + }, + { + "age": 68, + "cost": 22.32, + "country": "CN", + "price": 62, + "project": "machine-learning", + "state": "running", + "time": 1488092400000, + "username": "aperkinsrj" + }, + { + "age": 58, + "cost": 24.25, + "country": "RU", + "price": 57, + "project": "kibana", + "state": "start", + "time": 1463468400000, + "username": "swatkinsrk" + }, + { + "age": 77, + "cost": 23.67, + "country": "ID", + "price": 41, + "project": "logstash", + "state": "start", + "time": 1466146800000, + "username": "smoorerl" + }, + { + "age": 67, + "cost": 22.45, + "country": "ID", + "price": 52, + "project": "opbeat", + "state": "running", + "time": 1484895600000, + "username": "mlopezrm" + }, + { + "age": 49, + "cost": 24.46, + "country": "CN", + "price": 47, + "project": "logstash", + "state": "start", + "time": 1474959600000, + "username": "pshawrn" + }, + { + "age": 26, + "cost": 21.59, + "country": "CN", + "price": 45, + "project": "machine-learning", + "state": "running", + "time": 1482303600000, + "username": "rwagnerro" + }, + { + "age": 36, + "cost": 23.84, + "country": "BR", + "price": 44, + "project": "beats", + "state": "start", + "time": 1488006000000, + "username": "dmatthewsrp" + }, + { + "age": 46, + "cost": 23.65, + "country": "CN", + "price": 56, + "project": "opbeat", + "state": "start", + "time": 1476946800000, + "username": "btorresrq" + }, + { + "age": 49, + "cost": 22.57, + "country": "ID", + "price": 52, + "project": "logstash", + "state": "done", + "time": 1472367600000, + "username": "elawrencerr" + } +] \ No newline at end of file diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/demodata/get_demo_rows.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/demodata/get_demo_rows.js new file mode 100644 index 0000000000000..16077d9b69efa --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/demodata/get_demo_rows.js @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { cloneDeep } from 'lodash'; +import ci from './ci.json'; +import shirts from './shirts.json'; + +export function getDemoRows(arg) { + if (arg === 'ci') return cloneDeep(ci); + if (arg === 'shirts') return cloneDeep(shirts); + throw new Error(`Invalid data set: ${arg}, use 'ci' or 'shirts'.`); +} diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/demodata/index.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/demodata/index.js new file mode 100644 index 0000000000000..5c3b000100eb0 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/demodata/index.js @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { sortBy } from 'lodash'; +import { queryDatatable } from '../../../../common/lib/datatable/query'; +import { getDemoRows } from './get_demo_rows'; + +export const demodata = () => ({ + name: 'demodata', + aliases: [], + type: 'datatable', + help: 'A mock data set that includes project CI times with usernames, countries and run phases.', + context: { + types: ['filter'], + }, + args: { + type: { + types: ['string', 'null'], + aliases: ['_'], + help: 'The name of the demo data set to use', + default: 'ci', + }, + }, + fn: (context, args) => { + const demoRows = getDemoRows(args.type); + let set = {}; + if (args.type === 'ci') { + set = { + columns: [ + { name: 'time', type: 'date' }, + { name: 'cost', type: 'number' }, + { name: 'username', type: 'string' }, + { name: 'price', type: 'number' }, + { name: 'age', type: 'number' }, + { name: 'country', type: 'string' }, + { name: 'state', type: 'string' }, + { name: 'project', type: 'string' }, + ], + rows: sortBy(demoRows, 'time'), + }; + } else if (args.type === 'shirts') { + set = { + columns: [ + { name: 'size', type: 'string' }, + { name: 'color', type: 'string' }, + { name: 'price', type: 'number' }, + { name: 'cut', type: 'string' }, + ], + rows: demoRows, + }; + } + + const { columns, rows } = set; + return queryDatatable( + { + type: 'datatable', + columns, + rows, + }, + context + ); + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/demodata/shirts.json b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/demodata/shirts.json new file mode 100644 index 0000000000000..21b978a345512 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/demodata/shirts.json @@ -0,0 +1,1000 @@ +[{"size":"XS","color":"Yellow","price":14,"cut":"fitted"}, +{"size":"XS","color":"Mauv","price":13,"cut":"fitted"}, +{"size":"3XL","color":"Yellow","price":14,"cut":"fitted"}, +{"size":"M","color":"Violet","price":20,"cut":"straight"}, +{"size":"3XL","color":"Maroon","price":16,"cut":"straight"}, +{"size":"2XL","color":"Aquamarine","price":18,"cut":"straight"}, +{"size":"3XL","color":"Red","price":18,"cut":"straight"}, +{"size":"XL","color":"Orange","price":16,"cut":"fitted"}, +{"size":"2XL","color":"Goldenrod","price":20,"cut":"straight"}, +{"size":"S","color":"Turquoise","price":13,"cut":"straight"}, +{"size":"2XL","color":"Khaki","price":15,"cut":"straight"}, +{"size":"S","color":"Maroon","price":16,"cut":"straight"}, +{"size":"XS","color":"Blue","price":20,"cut":"straight"}, +{"size":"3XL","color":"Pink","price":20,"cut":"straight"}, +{"size":"3XL","color":"Violet","price":12,"cut":"straight"}, +{"size":"2XL","color":"Green","price":17,"cut":"straight"}, +{"size":"S","color":"Fuscia","price":18,"cut":"fitted"}, +{"size":"L","color":"Blue","price":19,"cut":"fitted"}, +{"size":"XS","color":"Green","price":14,"cut":"fitted"}, +{"size":"3XL","color":"Turquoise","price":12,"cut":"fitted"}, +{"size":"XL","color":"Indigo","price":19,"cut":"straight"}, +{"size":"M","color":"Yellow","price":13,"cut":"fitted"}, +{"size":"XL","color":"Fuscia","price":15,"cut":"straight"}, +{"size":"3XL","color":"Mauv","price":11,"cut":"straight"}, +{"size":"2XL","color":"Fuscia","price":17,"cut":"fitted"}, +{"size":"3XL","color":"Teal","price":14,"cut":"straight"}, +{"size":"XS","color":"Crimson","price":17,"cut":"straight"}, +{"size":"XS","color":"Pink","price":11,"cut":"fitted"}, +{"size":"M","color":"Pink","price":12,"cut":"straight"}, +{"size":"XL","color":"Pink","price":15,"cut":"straight"}, +{"size":"3XL","color":"Red","price":10,"cut":"fitted"}, +{"size":"S","color":"Red","price":18,"cut":"straight"}, +{"size":"XL","color":"Teal","price":15,"cut":"straight"}, +{"size":"L","color":"Purple","price":11,"cut":"straight"}, +{"size":"2XL","color":"Orange","price":10,"cut":"fitted"}, +{"size":"S","color":"Aquamarine","price":17,"cut":"fitted"}, +{"size":"S","color":"Yellow","price":11,"cut":"fitted"}, +{"size":"M","color":"Turquoise","price":11,"cut":"straight"}, +{"size":"XS","color":"Violet","price":15,"cut":"fitted"}, +{"size":"XS","color":"Purple","price":16,"cut":"straight"}, +{"size":"XS","color":"Mauv","price":15,"cut":"straight"}, +{"size":"XS","color":"Puce","price":13,"cut":"fitted"}, +{"size":"3XL","color":"Turquoise","price":10,"cut":"straight"}, +{"size":"XS","color":"Yellow","price":13,"cut":"fitted"}, +{"size":"L","color":"Orange","price":13,"cut":"straight"}, +{"size":"2XL","color":"Blue","price":16,"cut":"fitted"}, +{"size":"3XL","color":"Indigo","price":20,"cut":"straight"}, +{"size":"S","color":"Yellow","price":13,"cut":"fitted"}, +{"size":"S","color":"Turquoise","price":13,"cut":"fitted"}, +{"size":"S","color":"Turquoise","price":16,"cut":"straight"}, +{"size":"XS","color":"Fuscia","price":16,"cut":"fitted"}, +{"size":"XS","color":"Red","price":11,"cut":"fitted"}, +{"size":"XS","color":"Fuscia","price":16,"cut":"straight"}, +{"size":"3XL","color":"Crimson","price":16,"cut":"fitted"}, +{"size":"3XL","color":"Green","price":14,"cut":"straight"}, +{"size":"M","color":"Teal","price":14,"cut":"fitted"}, +{"size":"XL","color":"Goldenrod","price":14,"cut":"straight"}, +{"size":"XL","color":"Blue","price":14,"cut":"straight"}, +{"size":"3XL","color":"Mauv","price":13,"cut":"fitted"}, +{"size":"2XL","color":"Puce","price":13,"cut":"straight"}, +{"size":"M","color":"Indigo","price":18,"cut":"fitted"}, +{"size":"L","color":"Aquamarine","price":11,"cut":"straight"}, +{"size":"XS","color":"Indigo","price":19,"cut":"straight"}, +{"size":"3XL","color":"Mauv","price":15,"cut":"straight"}, +{"size":"3XL","color":"Blue","price":10,"cut":"straight"}, +{"size":"L","color":"Crimson","price":11,"cut":"fitted"}, +{"size":"XL","color":"Crimson","price":16,"cut":"straight"}, +{"size":"S","color":"Indigo","price":12,"cut":"fitted"}, +{"size":"3XL","color":"Pink","price":12,"cut":"straight"}, +{"size":"2XL","color":"Indigo","price":11,"cut":"straight"}, +{"size":"3XL","color":"Indigo","price":11,"cut":"straight"}, +{"size":"S","color":"Blue","price":10,"cut":"straight"}, +{"size":"L","color":"Khaki","price":15,"cut":"straight"}, +{"size":"S","color":"Blue","price":14,"cut":"straight"}, +{"size":"S","color":"Khaki","price":14,"cut":"straight"}, +{"size":"L","color":"Fuscia","price":13,"cut":"fitted"}, +{"size":"M","color":"Violet","price":18,"cut":"fitted"}, +{"size":"XL","color":"Indigo","price":12,"cut":"fitted"}, +{"size":"2XL","color":"Yellow","price":19,"cut":"fitted"}, +{"size":"S","color":"Khaki","price":18,"cut":"fitted"}, +{"size":"S","color":"Yellow","price":18,"cut":"fitted"}, +{"size":"3XL","color":"Yellow","price":14,"cut":"fitted"}, +{"size":"XL","color":"Puce","price":15,"cut":"straight"}, +{"size":"S","color":"Indigo","price":18,"cut":"fitted"}, +{"size":"XL","color":"Violet","price":20,"cut":"fitted"}, +{"size":"XL","color":"Aquamarine","price":13,"cut":"straight"}, +{"size":"XS","color":"Fuscia","price":18,"cut":"fitted"}, +{"size":"2XL","color":"Red","price":13,"cut":"straight"}, +{"size":"2XL","color":"Purple","price":12,"cut":"straight"}, +{"size":"3XL","color":"Aquamarine","price":20,"cut":"fitted"}, +{"size":"3XL","color":"Yellow","price":14,"cut":"straight"}, +{"size":"3XL","color":"Turquoise","price":15,"cut":"straight"}, +{"size":"M","color":"Pink","price":14,"cut":"fitted"}, +{"size":"L","color":"Puce","price":18,"cut":"fitted"}, +{"size":"3XL","color":"Indigo","price":11,"cut":"fitted"}, +{"size":"XS","color":"Teal","price":18,"cut":"straight"}, +{"size":"XS","color":"Khaki","price":13,"cut":"straight"}, +{"size":"3XL","color":"Fuscia","price":17,"cut":"straight"}, +{"size":"L","color":"Maroon","price":16,"cut":"fitted"}, +{"size":"M","color":"Maroon","price":13,"cut":"fitted"}, +{"size":"3XL","color":"Pink","price":18,"cut":"straight"}, +{"size":"XL","color":"Mauv","price":19,"cut":"straight"}, +{"size":"M","color":"Yellow","price":18,"cut":"straight"}, +{"size":"XS","color":"Purple","price":11,"cut":"straight"}, +{"size":"XL","color":"Turquoise","price":20,"cut":"straight"}, +{"size":"S","color":"Teal","price":18,"cut":"fitted"}, +{"size":"M","color":"Yellow","price":16,"cut":"straight"}, +{"size":"L","color":"Aquamarine","price":19,"cut":"straight"}, +{"size":"L","color":"Crimson","price":17,"cut":"straight"}, +{"size":"S","color":"Red","price":15,"cut":"straight"}, +{"size":"2XL","color":"Mauv","price":19,"cut":"straight"}, +{"size":"3XL","color":"Fuscia","price":18,"cut":"straight"}, +{"size":"XL","color":"Fuscia","price":18,"cut":"straight"}, +{"size":"M","color":"Crimson","price":11,"cut":"fitted"}, +{"size":"M","color":"Yellow","price":18,"cut":"straight"}, +{"size":"M","color":"Pink","price":14,"cut":"fitted"}, +{"size":"S","color":"Green","price":13,"cut":"fitted"}, +{"size":"2XL","color":"Aquamarine","price":13,"cut":"straight"}, +{"size":"M","color":"Fuscia","price":20,"cut":"straight"}, +{"size":"2XL","color":"Green","price":16,"cut":"fitted"}, +{"size":"3XL","color":"Crimson","price":19,"cut":"fitted"}, +{"size":"3XL","color":"Purple","price":20,"cut":"straight"}, +{"size":"S","color":"Yellow","price":15,"cut":"straight"}, +{"size":"S","color":"Fuscia","price":18,"cut":"straight"}, +{"size":"XL","color":"Red","price":13,"cut":"fitted"}, +{"size":"XS","color":"Teal","price":11,"cut":"fitted"}, +{"size":"M","color":"Pink","price":12,"cut":"straight"}, +{"size":"XS","color":"Turquoise","price":18,"cut":"fitted"}, +{"size":"XS","color":"Puce","price":18,"cut":"fitted"}, +{"size":"XS","color":"Puce","price":11,"cut":"fitted"}, +{"size":"M","color":"Yellow","price":12,"cut":"fitted"}, +{"size":"XS","color":"Khaki","price":16,"cut":"straight"}, +{"size":"M","color":"Violet","price":10,"cut":"straight"}, +{"size":"XS","color":"Blue","price":13,"cut":"straight"}, +{"size":"L","color":"Aquamarine","price":17,"cut":"straight"}, +{"size":"XS","color":"Teal","price":19,"cut":"straight"}, +{"size":"L","color":"Turquoise","price":20,"cut":"straight"}, +{"size":"2XL","color":"Puce","price":15,"cut":"straight"}, +{"size":"2XL","color":"Fuscia","price":12,"cut":"straight"}, +{"size":"L","color":"Violet","price":14,"cut":"fitted"}, +{"size":"S","color":"Orange","price":18,"cut":"fitted"}, +{"size":"3XL","color":"Violet","price":10,"cut":"straight"}, +{"size":"3XL","color":"Blue","price":10,"cut":"fitted"}, +{"size":"3XL","color":"Teal","price":17,"cut":"straight"}, +{"size":"3XL","color":"Pink","price":13,"cut":"straight"}, +{"size":"L","color":"Khaki","price":19,"cut":"fitted"}, +{"size":"L","color":"Red","price":15,"cut":"fitted"}, +{"size":"S","color":"Green","price":15,"cut":"straight"}, +{"size":"2XL","color":"Purple","price":19,"cut":"straight"}, +{"size":"2XL","color":"Pink","price":15,"cut":"fitted"}, +{"size":"XS","color":"Purple","price":18,"cut":"straight"}, +{"size":"S","color":"Blue","price":11,"cut":"fitted"}, +{"size":"2XL","color":"Khaki","price":17,"cut":"fitted"}, +{"size":"2XL","color":"Blue","price":14,"cut":"straight"}, +{"size":"XL","color":"Turquoise","price":11,"cut":"fitted"}, +{"size":"L","color":"Fuscia","price":14,"cut":"fitted"}, +{"size":"3XL","color":"Aquamarine","price":13,"cut":"straight"}, +{"size":"3XL","color":"Maroon","price":18,"cut":"fitted"}, +{"size":"M","color":"Goldenrod","price":19,"cut":"straight"}, +{"size":"S","color":"Violet","price":19,"cut":"straight"}, +{"size":"M","color":"Puce","price":20,"cut":"fitted"}, +{"size":"S","color":"Green","price":20,"cut":"fitted"}, +{"size":"XS","color":"Crimson","price":17,"cut":"straight"}, +{"size":"XL","color":"Blue","price":14,"cut":"straight"}, +{"size":"3XL","color":"Green","price":11,"cut":"straight"}, +{"size":"XL","color":"Puce","price":16,"cut":"fitted"}, +{"size":"XL","color":"Teal","price":20,"cut":"fitted"}, +{"size":"S","color":"Turquoise","price":12,"cut":"fitted"}, +{"size":"2XL","color":"Red","price":14,"cut":"straight"}, +{"size":"3XL","color":"Red","price":19,"cut":"straight"}, +{"size":"XL","color":"Goldenrod","price":20,"cut":"fitted"}, +{"size":"XL","color":"Green","price":16,"cut":"straight"}, +{"size":"2XL","color":"Pink","price":12,"cut":"fitted"}, +{"size":"2XL","color":"Red","price":10,"cut":"straight"}, +{"size":"XS","color":"Fuscia","price":11,"cut":"straight"}, +{"size":"XL","color":"Indigo","price":12,"cut":"straight"}, +{"size":"3XL","color":"Indigo","price":16,"cut":"straight"}, +{"size":"XL","color":"Pink","price":18,"cut":"straight"}, +{"size":"L","color":"Indigo","price":16,"cut":"straight"}, +{"size":"M","color":"Purple","price":12,"cut":"straight"}, +{"size":"XS","color":"Purple","price":19,"cut":"straight"}, +{"size":"M","color":"Aquamarine","price":15,"cut":"fitted"}, +{"size":"XS","color":"Aquamarine","price":18,"cut":"straight"}, +{"size":"S","color":"Green","price":10,"cut":"straight"}, +{"size":"S","color":"Turquoise","price":11,"cut":"straight"}, +{"size":"XL","color":"Indigo","price":10,"cut":"straight"}, +{"size":"L","color":"Teal","price":16,"cut":"straight"}, +{"size":"S","color":"Aquamarine","price":13,"cut":"straight"}, +{"size":"XL","color":"Teal","price":19,"cut":"fitted"}, +{"size":"L","color":"Blue","price":16,"cut":"straight"}, +{"size":"M","color":"Yellow","price":17,"cut":"straight"}, +{"size":"L","color":"Red","price":10,"cut":"fitted"}, +{"size":"3XL","color":"Orange","price":13,"cut":"fitted"}, +{"size":"2XL","color":"Crimson","price":11,"cut":"straight"}, +{"size":"S","color":"Purple","price":18,"cut":"straight"}, +{"size":"XS","color":"Yellow","price":19,"cut":"fitted"}, +{"size":"3XL","color":"Purple","price":20,"cut":"fitted"}, +{"size":"3XL","color":"Fuscia","price":18,"cut":"straight"}, +{"size":"XL","color":"Pink","price":16,"cut":"straight"}, +{"size":"L","color":"Aquamarine","price":18,"cut":"straight"}, +{"size":"2XL","color":"Green","price":11,"cut":"straight"}, +{"size":"L","color":"Red","price":11,"cut":"fitted"}, +{"size":"XS","color":"Khaki","price":13,"cut":"straight"}, +{"size":"XS","color":"Purple","price":13,"cut":"fitted"}, +{"size":"S","color":"Blue","price":20,"cut":"fitted"}, +{"size":"XL","color":"Red","price":18,"cut":"straight"}, +{"size":"XL","color":"Maroon","price":14,"cut":"straight"}, +{"size":"S","color":"Purple","price":13,"cut":"straight"}, +{"size":"XL","color":"Maroon","price":13,"cut":"straight"}, +{"size":"XL","color":"Orange","price":17,"cut":"straight"}, +{"size":"L","color":"Maroon","price":11,"cut":"straight"}, +{"size":"2XL","color":"Blue","price":19,"cut":"straight"}, +{"size":"XS","color":"Fuscia","price":16,"cut":"straight"}, +{"size":"XL","color":"Red","price":10,"cut":"straight"}, +{"size":"2XL","color":"Teal","price":13,"cut":"straight"}, +{"size":"3XL","color":"Indigo","price":15,"cut":"straight"}, +{"size":"2XL","color":"Fuscia","price":14,"cut":"straight"}, +{"size":"2XL","color":"Red","price":12,"cut":"straight"}, +{"size":"S","color":"Goldenrod","price":13,"cut":"straight"}, +{"size":"L","color":"Teal","price":20,"cut":"fitted"}, +{"size":"2XL","color":"Orange","price":18,"cut":"fitted"}, +{"size":"XS","color":"Yellow","price":20,"cut":"fitted"}, +{"size":"S","color":"Indigo","price":18,"cut":"straight"}, +{"size":"M","color":"Crimson","price":13,"cut":"straight"}, +{"size":"L","color":"Maroon","price":12,"cut":"fitted"}, +{"size":"S","color":"Green","price":18,"cut":"straight"}, +{"size":"M","color":"Red","price":19,"cut":"fitted"}, +{"size":"L","color":"Orange","price":13,"cut":"fitted"}, +{"size":"XL","color":"Violet","price":17,"cut":"straight"}, +{"size":"M","color":"Goldenrod","price":10,"cut":"fitted"}, +{"size":"XS","color":"Purple","price":19,"cut":"straight"}, +{"size":"M","color":"Orange","price":16,"cut":"fitted"}, +{"size":"XS","color":"Khaki","price":13,"cut":"fitted"}, +{"size":"M","color":"Yellow","price":13,"cut":"fitted"}, +{"size":"2XL","color":"Khaki","price":13,"cut":"fitted"}, +{"size":"S","color":"Puce","price":15,"cut":"straight"}, +{"size":"S","color":"Pink","price":15,"cut":"straight"}, +{"size":"3XL","color":"Yellow","price":13,"cut":"straight"}, +{"size":"XS","color":"Fuscia","price":20,"cut":"fitted"}, +{"size":"XS","color":"Khaki","price":20,"cut":"fitted"}, +{"size":"L","color":"Purple","price":19,"cut":"fitted"}, +{"size":"2XL","color":"Aquamarine","price":17,"cut":"fitted"}, +{"size":"M","color":"Violet","price":16,"cut":"fitted"}, +{"size":"XL","color":"Pink","price":13,"cut":"fitted"}, +{"size":"XL","color":"Khaki","price":19,"cut":"straight"}, +{"size":"XS","color":"Orange","price":11,"cut":"straight"}, +{"size":"3XL","color":"Purple","price":17,"cut":"fitted"}, +{"size":"2XL","color":"Pink","price":17,"cut":"straight"}, +{"size":"M","color":"Blue","price":17,"cut":"fitted"}, +{"size":"L","color":"Goldenrod","price":20,"cut":"fitted"}, +{"size":"S","color":"Goldenrod","price":10,"cut":"straight"}, +{"size":"XS","color":"Turquoise","price":14,"cut":"straight"}, +{"size":"M","color":"Khaki","price":15,"cut":"fitted"}, +{"size":"3XL","color":"Blue","price":11,"cut":"fitted"}, +{"size":"XL","color":"Purple","price":19,"cut":"fitted"}, +{"size":"XS","color":"Mauv","price":13,"cut":"fitted"}, +{"size":"XL","color":"Maroon","price":20,"cut":"straight"}, +{"size":"2XL","color":"Puce","price":18,"cut":"straight"}, +{"size":"M","color":"Goldenrod","price":13,"cut":"straight"}, +{"size":"3XL","color":"Fuscia","price":17,"cut":"straight"}, +{"size":"L","color":"Teal","price":14,"cut":"fitted"}, +{"size":"M","color":"Fuscia","price":14,"cut":"straight"}, +{"size":"2XL","color":"Red","price":15,"cut":"straight"}, +{"size":"M","color":"Khaki","price":17,"cut":"fitted"}, +{"size":"XL","color":"Goldenrod","price":13,"cut":"fitted"}, +{"size":"XS","color":"Orange","price":14,"cut":"fitted"}, +{"size":"L","color":"Khaki","price":19,"cut":"fitted"}, +{"size":"XL","color":"Indigo","price":18,"cut":"fitted"}, +{"size":"S","color":"Fuscia","price":18,"cut":"fitted"}, +{"size":"M","color":"Mauv","price":20,"cut":"fitted"}, +{"size":"M","color":"Blue","price":13,"cut":"straight"}, +{"size":"XL","color":"Pink","price":18,"cut":"straight"}, +{"size":"2XL","color":"Purple","price":12,"cut":"straight"}, +{"size":"XL","color":"Teal","price":20,"cut":"straight"}, +{"size":"S","color":"Violet","price":13,"cut":"fitted"}, +{"size":"XS","color":"Goldenrod","price":17,"cut":"fitted"}, +{"size":"L","color":"Teal","price":11,"cut":"fitted"}, +{"size":"L","color":"Khaki","price":13,"cut":"straight"}, +{"size":"M","color":"Indigo","price":16,"cut":"fitted"}, +{"size":"2XL","color":"Maroon","price":19,"cut":"straight"}, +{"size":"M","color":"Pink","price":18,"cut":"straight"}, +{"size":"XS","color":"Indigo","price":20,"cut":"fitted"}, +{"size":"3XL","color":"Purple","price":20,"cut":"fitted"}, +{"size":"L","color":"Teal","price":17,"cut":"fitted"}, +{"size":"XS","color":"Violet","price":16,"cut":"straight"}, +{"size":"M","color":"Pink","price":19,"cut":"straight"}, +{"size":"S","color":"Teal","price":15,"cut":"straight"}, +{"size":"3XL","color":"Crimson","price":20,"cut":"fitted"}, +{"size":"S","color":"Red","price":13,"cut":"fitted"}, +{"size":"L","color":"Red","price":11,"cut":"fitted"}, +{"size":"XL","color":"Purple","price":13,"cut":"fitted"}, +{"size":"3XL","color":"Puce","price":12,"cut":"straight"}, +{"size":"XL","color":"Yellow","price":12,"cut":"straight"}, +{"size":"2XL","color":"Crimson","price":18,"cut":"fitted"}, +{"size":"XS","color":"Purple","price":13,"cut":"fitted"}, +{"size":"3XL","color":"Turquoise","price":16,"cut":"straight"}, +{"size":"M","color":"Green","price":13,"cut":"fitted"}, +{"size":"L","color":"Teal","price":13,"cut":"fitted"}, +{"size":"XL","color":"Maroon","price":13,"cut":"fitted"}, +{"size":"XS","color":"Goldenrod","price":12,"cut":"straight"}, +{"size":"M","color":"Indigo","price":19,"cut":"straight"}, +{"size":"XL","color":"Purple","price":14,"cut":"fitted"}, +{"size":"L","color":"Turquoise","price":12,"cut":"straight"}, +{"size":"S","color":"Khaki","price":12,"cut":"fitted"}, +{"size":"L","color":"Purple","price":15,"cut":"straight"}, +{"size":"XL","color":"Yellow","price":10,"cut":"straight"}, +{"size":"XL","color":"Goldenrod","price":17,"cut":"fitted"}, +{"size":"3XL","color":"Mauv","price":11,"cut":"fitted"}, +{"size":"M","color":"Orange","price":19,"cut":"fitted"}, +{"size":"S","color":"Red","price":15,"cut":"straight"}, +{"size":"S","color":"Fuscia","price":14,"cut":"straight"}, +{"size":"M","color":"Aquamarine","price":18,"cut":"fitted"}, +{"size":"L","color":"Orange","price":19,"cut":"fitted"}, +{"size":"XS","color":"Pink","price":17,"cut":"straight"}, +{"size":"S","color":"Maroon","price":17,"cut":"fitted"}, +{"size":"3XL","color":"Puce","price":11,"cut":"straight"}, +{"size":"2XL","color":"Fuscia","price":10,"cut":"straight"}, +{"size":"2XL","color":"Green","price":19,"cut":"fitted"}, +{"size":"XL","color":"Teal","price":18,"cut":"straight"}, +{"size":"XL","color":"Khaki","price":18,"cut":"fitted"}, +{"size":"S","color":"Orange","price":15,"cut":"straight"}, +{"size":"XL","color":"Violet","price":18,"cut":"straight"}, +{"size":"XL","color":"Fuscia","price":10,"cut":"straight"}, +{"size":"3XL","color":"Yellow","price":12,"cut":"straight"}, +{"size":"XL","color":"Orange","price":17,"cut":"fitted"}, +{"size":"2XL","color":"Khaki","price":14,"cut":"fitted"}, +{"size":"XS","color":"Turquoise","price":11,"cut":"fitted"}, +{"size":"2XL","color":"Pink","price":17,"cut":"straight"}, +{"size":"L","color":"Khaki","price":16,"cut":"straight"}, +{"size":"XS","color":"Violet","price":12,"cut":"fitted"}, +{"size":"3XL","color":"Red","price":10,"cut":"fitted"}, +{"size":"M","color":"Turquoise","price":11,"cut":"straight"}, +{"size":"S","color":"Fuscia","price":18,"cut":"straight"}, +{"size":"L","color":"Turquoise","price":15,"cut":"straight"}, +{"size":"L","color":"Green","price":11,"cut":"fitted"}, +{"size":"XL","color":"Pink","price":13,"cut":"fitted"}, +{"size":"XL","color":"Crimson","price":16,"cut":"fitted"}, +{"size":"XL","color":"Red","price":10,"cut":"straight"}, +{"size":"S","color":"Indigo","price":12,"cut":"fitted"}, +{"size":"XL","color":"Pink","price":13,"cut":"fitted"}, +{"size":"XS","color":"Purple","price":14,"cut":"straight"}, +{"size":"2XL","color":"Crimson","price":14,"cut":"straight"}, +{"size":"XS","color":"Maroon","price":11,"cut":"fitted"}, +{"size":"L","color":"Khaki","price":14,"cut":"fitted"}, +{"size":"XL","color":"Violet","price":14,"cut":"fitted"}, +{"size":"M","color":"Puce","price":17,"cut":"fitted"}, +{"size":"XL","color":"Purple","price":13,"cut":"straight"}, +{"size":"XL","color":"Fuscia","price":13,"cut":"straight"}, +{"size":"S","color":"Yellow","price":15,"cut":"straight"}, +{"size":"3XL","color":"Orange","price":10,"cut":"straight"}, +{"size":"L","color":"Purple","price":20,"cut":"straight"}, +{"size":"2XL","color":"Pink","price":19,"cut":"straight"}, +{"size":"L","color":"Aquamarine","price":20,"cut":"fitted"}, +{"size":"L","color":"Purple","price":13,"cut":"fitted"}, +{"size":"L","color":"Pink","price":14,"cut":"fitted"}, +{"size":"XS","color":"Purple","price":16,"cut":"straight"}, +{"size":"2XL","color":"Green","price":19,"cut":"straight"}, +{"size":"2XL","color":"Indigo","price":18,"cut":"straight"}, +{"size":"M","color":"Goldenrod","price":13,"cut":"fitted"}, +{"size":"M","color":"Orange","price":11,"cut":"straight"}, +{"size":"3XL","color":"Green","price":18,"cut":"straight"}, +{"size":"S","color":"Pink","price":12,"cut":"fitted"}, +{"size":"L","color":"Green","price":12,"cut":"straight"}, +{"size":"M","color":"Red","price":11,"cut":"straight"}, +{"size":"L","color":"Pink","price":19,"cut":"straight"}, +{"size":"3XL","color":"Aquamarine","price":12,"cut":"straight"}, +{"size":"XS","color":"Red","price":18,"cut":"straight"}, +{"size":"L","color":"Pink","price":20,"cut":"straight"}, +{"size":"XS","color":"Aquamarine","price":19,"cut":"fitted"}, +{"size":"XS","color":"Green","price":20,"cut":"fitted"}, +{"size":"3XL","color":"Orange","price":12,"cut":"straight"}, +{"size":"XL","color":"Pink","price":14,"cut":"straight"}, +{"size":"S","color":"Turquoise","price":14,"cut":"straight"}, +{"size":"3XL","color":"Teal","price":10,"cut":"fitted"}, +{"size":"3XL","color":"Violet","price":15,"cut":"straight"}, +{"size":"L","color":"Turquoise","price":20,"cut":"straight"}, +{"size":"L","color":"Violet","price":20,"cut":"fitted"}, +{"size":"3XL","color":"Purple","price":17,"cut":"straight"}, +{"size":"L","color":"Violet","price":12,"cut":"fitted"}, +{"size":"M","color":"Teal","price":14,"cut":"straight"}, +{"size":"2XL","color":"Yellow","price":11,"cut":"straight"}, +{"size":"S","color":"Goldenrod","price":10,"cut":"fitted"}, +{"size":"XL","color":"Red","price":16,"cut":"fitted"}, +{"size":"XS","color":"Goldenrod","price":18,"cut":"straight"}, +{"size":"XS","color":"Crimson","price":10,"cut":"straight"}, +{"size":"S","color":"Turquoise","price":12,"cut":"fitted"}, +{"size":"3XL","color":"Goldenrod","price":18,"cut":"straight"}, +{"size":"2XL","color":"Mauv","price":20,"cut":"straight"}, +{"size":"3XL","color":"Yellow","price":10,"cut":"fitted"}, +{"size":"S","color":"Indigo","price":20,"cut":"straight"}, +{"size":"L","color":"Violet","price":12,"cut":"fitted"}, +{"size":"XS","color":"Crimson","price":14,"cut":"straight"}, +{"size":"3XL","color":"Khaki","price":19,"cut":"straight"}, +{"size":"L","color":"Fuscia","price":14,"cut":"fitted"}, +{"size":"XL","color":"Green","price":15,"cut":"straight"}, +{"size":"3XL","color":"Green","price":20,"cut":"straight"}, +{"size":"S","color":"Orange","price":15,"cut":"fitted"}, +{"size":"2XL","color":"Puce","price":11,"cut":"straight"}, +{"size":"S","color":"Yellow","price":11,"cut":"fitted"}, +{"size":"S","color":"Goldenrod","price":19,"cut":"fitted"}, +{"size":"S","color":"Turquoise","price":13,"cut":"fitted"}, +{"size":"S","color":"Fuscia","price":19,"cut":"fitted"}, +{"size":"L","color":"Turquoise","price":15,"cut":"fitted"}, +{"size":"XL","color":"Maroon","price":16,"cut":"straight"}, +{"size":"S","color":"Teal","price":13,"cut":"fitted"}, +{"size":"3XL","color":"Yellow","price":18,"cut":"straight"}, +{"size":"3XL","color":"Aquamarine","price":15,"cut":"fitted"}, +{"size":"L","color":"Crimson","price":12,"cut":"straight"}, +{"size":"3XL","color":"Aquamarine","price":15,"cut":"straight"}, +{"size":"3XL","color":"Purple","price":16,"cut":"fitted"}, +{"size":"M","color":"Green","price":20,"cut":"fitted"}, +{"size":"XS","color":"Aquamarine","price":11,"cut":"fitted"}, +{"size":"2XL","color":"Goldenrod","price":11,"cut":"straight"}, +{"size":"M","color":"Teal","price":19,"cut":"fitted"}, +{"size":"3XL","color":"Blue","price":15,"cut":"fitted"}, +{"size":"XS","color":"Aquamarine","price":19,"cut":"straight"}, +{"size":"3XL","color":"Orange","price":19,"cut":"fitted"}, +{"size":"2XL","color":"Violet","price":13,"cut":"straight"}, +{"size":"XS","color":"Orange","price":15,"cut":"fitted"}, +{"size":"XS","color":"Fuscia","price":18,"cut":"fitted"}, +{"size":"2XL","color":"Maroon","price":18,"cut":"straight"}, +{"size":"M","color":"Crimson","price":12,"cut":"fitted"}, +{"size":"XL","color":"Blue","price":16,"cut":"fitted"}, +{"size":"S","color":"Turquoise","price":15,"cut":"straight"}, +{"size":"2XL","color":"Orange","price":11,"cut":"straight"}, +{"size":"3XL","color":"Purple","price":12,"cut":"straight"}, +{"size":"XL","color":"Crimson","price":11,"cut":"fitted"}, +{"size":"3XL","color":"Goldenrod","price":16,"cut":"straight"}, +{"size":"L","color":"Goldenrod","price":18,"cut":"straight"}, +{"size":"XS","color":"Green","price":14,"cut":"fitted"}, +{"size":"S","color":"Maroon","price":12,"cut":"straight"}, +{"size":"S","color":"Turquoise","price":16,"cut":"straight"}, +{"size":"2XL","color":"Blue","price":16,"cut":"straight"}, +{"size":"XS","color":"Red","price":14,"cut":"straight"}, +{"size":"XL","color":"Khaki","price":17,"cut":"straight"}, +{"size":"3XL","color":"Blue","price":14,"cut":"fitted"}, +{"size":"XS","color":"Indigo","price":19,"cut":"straight"}, +{"size":"XL","color":"Pink","price":19,"cut":"fitted"}, +{"size":"XL","color":"Purple","price":13,"cut":"straight"}, +{"size":"XS","color":"Indigo","price":12,"cut":"fitted"}, +{"size":"2XL","color":"Khaki","price":11,"cut":"straight"}, +{"size":"L","color":"Aquamarine","price":17,"cut":"fitted"}, +{"size":"M","color":"Red","price":15,"cut":"straight"}, +{"size":"M","color":"Indigo","price":18,"cut":"straight"}, +{"size":"S","color":"Green","price":18,"cut":"straight"}, +{"size":"3XL","color":"Purple","price":11,"cut":"straight"}, +{"size":"L","color":"Goldenrod","price":19,"cut":"fitted"}, +{"size":"M","color":"Maroon","price":20,"cut":"fitted"}, +{"size":"L","color":"Khaki","price":11,"cut":"fitted"}, +{"size":"L","color":"Khaki","price":18,"cut":"fitted"}, +{"size":"2XL","color":"Orange","price":10,"cut":"straight"}, +{"size":"S","color":"Khaki","price":20,"cut":"fitted"}, +{"size":"XS","color":"Violet","price":14,"cut":"straight"}, +{"size":"XL","color":"Fuscia","price":19,"cut":"straight"}, +{"size":"2XL","color":"Goldenrod","price":14,"cut":"fitted"}, +{"size":"3XL","color":"Maroon","price":16,"cut":"straight"}, +{"size":"2XL","color":"Aquamarine","price":19,"cut":"straight"}, +{"size":"2XL","color":"Mauv","price":19,"cut":"straight"}, +{"size":"3XL","color":"Mauv","price":13,"cut":"straight"}, +{"size":"M","color":"Teal","price":10,"cut":"fitted"}, +{"size":"XS","color":"Goldenrod","price":19,"cut":"fitted"}, +{"size":"XS","color":"Purple","price":11,"cut":"straight"}, +{"size":"M","color":"Puce","price":18,"cut":"fitted"}, +{"size":"XS","color":"Khaki","price":11,"cut":"fitted"}, +{"size":"XL","color":"Puce","price":14,"cut":"straight"}, +{"size":"M","color":"Teal","price":13,"cut":"fitted"}, +{"size":"L","color":"Violet","price":11,"cut":"straight"}, +{"size":"3XL","color":"Aquamarine","price":13,"cut":"fitted"}, +{"size":"L","color":"Green","price":18,"cut":"fitted"}, +{"size":"2XL","color":"Crimson","price":19,"cut":"straight"}, +{"size":"L","color":"Yellow","price":17,"cut":"fitted"}, +{"size":"3XL","color":"Khaki","price":19,"cut":"fitted"}, +{"size":"S","color":"Fuscia","price":20,"cut":"fitted"}, +{"size":"L","color":"Fuscia","price":10,"cut":"fitted"}, +{"size":"S","color":"Violet","price":19,"cut":"straight"}, +{"size":"L","color":"Goldenrod","price":13,"cut":"straight"}, +{"size":"M","color":"Puce","price":17,"cut":"fitted"}, +{"size":"3XL","color":"Crimson","price":18,"cut":"straight"}, +{"size":"3XL","color":"Red","price":18,"cut":"fitted"}, +{"size":"L","color":"Blue","price":11,"cut":"fitted"}, +{"size":"M","color":"Blue","price":11,"cut":"fitted"}, +{"size":"3XL","color":"Blue","price":14,"cut":"straight"}, +{"size":"S","color":"Blue","price":18,"cut":"straight"}, +{"size":"XL","color":"Fuscia","price":17,"cut":"straight"}, +{"size":"S","color":"Mauv","price":20,"cut":"fitted"}, +{"size":"3XL","color":"Khaki","price":13,"cut":"straight"}, +{"size":"M","color":"Aquamarine","price":15,"cut":"fitted"}, +{"size":"2XL","color":"Mauv","price":18,"cut":"straight"}, +{"size":"M","color":"Yellow","price":12,"cut":"fitted"}, +{"size":"M","color":"Purple","price":12,"cut":"straight"}, +{"size":"2XL","color":"Puce","price":16,"cut":"fitted"}, +{"size":"L","color":"Aquamarine","price":12,"cut":"fitted"}, +{"size":"M","color":"Aquamarine","price":18,"cut":"fitted"}, +{"size":"3XL","color":"Indigo","price":16,"cut":"straight"}, +{"size":"M","color":"Pink","price":16,"cut":"fitted"}, +{"size":"M","color":"Teal","price":19,"cut":"fitted"}, +{"size":"2XL","color":"Yellow","price":16,"cut":"fitted"}, +{"size":"XL","color":"Purple","price":13,"cut":"straight"}, +{"size":"M","color":"Mauv","price":18,"cut":"straight"}, +{"size":"XS","color":"Fuscia","price":19,"cut":"fitted"}, +{"size":"XL","color":"Mauv","price":19,"cut":"fitted"}, +{"size":"XL","color":"Indigo","price":14,"cut":"straight"}, +{"size":"S","color":"Puce","price":10,"cut":"straight"}, +{"size":"XL","color":"Turquoise","price":13,"cut":"fitted"}, +{"size":"3XL","color":"Orange","price":13,"cut":"fitted"}, +{"size":"XS","color":"Orange","price":19,"cut":"straight"}, +{"size":"XL","color":"Orange","price":16,"cut":"straight"}, +{"size":"XS","color":"Green","price":18,"cut":"fitted"}, +{"size":"2XL","color":"Fuscia","price":11,"cut":"straight"}, +{"size":"2XL","color":"Teal","price":15,"cut":"fitted"}, +{"size":"M","color":"Mauv","price":20,"cut":"fitted"}, +{"size":"L","color":"Crimson","price":17,"cut":"fitted"}, +{"size":"L","color":"Green","price":14,"cut":"straight"}, +{"size":"L","color":"Violet","price":11,"cut":"fitted"}, +{"size":"L","color":"Indigo","price":18,"cut":"fitted"}, +{"size":"2XL","color":"Purple","price":10,"cut":"fitted"}, +{"size":"S","color":"Khaki","price":13,"cut":"fitted"}, +{"size":"M","color":"Purple","price":20,"cut":"straight"}, +{"size":"3XL","color":"Yellow","price":20,"cut":"fitted"}, +{"size":"XL","color":"Yellow","price":11,"cut":"fitted"}, +{"size":"S","color":"Teal","price":16,"cut":"straight"}, +{"size":"XS","color":"Green","price":14,"cut":"fitted"}, +{"size":"3XL","color":"Red","price":17,"cut":"straight"}, +{"size":"XS","color":"Khaki","price":19,"cut":"fitted"}, +{"size":"S","color":"Fuscia","price":14,"cut":"fitted"}, +{"size":"M","color":"Turquoise","price":12,"cut":"fitted"}, +{"size":"XS","color":"Indigo","price":10,"cut":"straight"}, +{"size":"S","color":"Orange","price":17,"cut":"fitted"}, +{"size":"XL","color":"Goldenrod","price":15,"cut":"straight"}, +{"size":"XL","color":"Turquoise","price":12,"cut":"straight"}, +{"size":"3XL","color":"Puce","price":12,"cut":"fitted"}, +{"size":"M","color":"Yellow","price":14,"cut":"fitted"}, +{"size":"3XL","color":"Red","price":11,"cut":"fitted"}, +{"size":"S","color":"Fuscia","price":19,"cut":"fitted"}, +{"size":"XS","color":"Khaki","price":13,"cut":"straight"}, +{"size":"S","color":"Turquoise","price":11,"cut":"straight"}, +{"size":"XS","color":"Pink","price":18,"cut":"fitted"}, +{"size":"M","color":"Blue","price":19,"cut":"fitted"}, +{"size":"2XL","color":"Aquamarine","price":10,"cut":"straight"}, +{"size":"2XL","color":"Khaki","price":17,"cut":"fitted"}, +{"size":"XS","color":"Fuscia","price":20,"cut":"straight"}, +{"size":"3XL","color":"Fuscia","price":20,"cut":"straight"}, +{"size":"L","color":"Pink","price":14,"cut":"fitted"}, +{"size":"3XL","color":"Crimson","price":15,"cut":"straight"}, +{"size":"XL","color":"Maroon","price":15,"cut":"fitted"}, +{"size":"M","color":"Puce","price":10,"cut":"fitted"}, +{"size":"L","color":"Purple","price":19,"cut":"straight"}, +{"size":"XS","color":"Green","price":19,"cut":"straight"}, +{"size":"S","color":"Mauv","price":19,"cut":"fitted"}, +{"size":"L","color":"Pink","price":10,"cut":"straight"}, +{"size":"L","color":"Crimson","price":18,"cut":"straight"}, +{"size":"S","color":"Purple","price":18,"cut":"straight"}, +{"size":"2XL","color":"Blue","price":10,"cut":"fitted"}, +{"size":"2XL","color":"Aquamarine","price":10,"cut":"fitted"}, +{"size":"XS","color":"Turquoise","price":15,"cut":"fitted"}, +{"size":"M","color":"Red","price":20,"cut":"fitted"}, +{"size":"XS","color":"Pink","price":12,"cut":"fitted"}, +{"size":"M","color":"Goldenrod","price":18,"cut":"straight"}, +{"size":"S","color":"Purple","price":14,"cut":"fitted"}, +{"size":"S","color":"Puce","price":10,"cut":"straight"}, +{"size":"3XL","color":"Indigo","price":11,"cut":"straight"}, +{"size":"XS","color":"Orange","price":15,"cut":"fitted"}, +{"size":"M","color":"Mauv","price":16,"cut":"straight"}, +{"size":"XL","color":"Red","price":17,"cut":"straight"}, +{"size":"2XL","color":"Purple","price":11,"cut":"straight"}, +{"size":"L","color":"Goldenrod","price":11,"cut":"fitted"}, +{"size":"3XL","color":"Puce","price":20,"cut":"straight"}, +{"size":"L","color":"Indigo","price":11,"cut":"fitted"}, +{"size":"2XL","color":"Fuscia","price":16,"cut":"straight"}, +{"size":"XS","color":"Red","price":19,"cut":"fitted"}, +{"size":"XS","color":"Aquamarine","price":16,"cut":"fitted"}, +{"size":"M","color":"Crimson","price":13,"cut":"straight"}, +{"size":"XS","color":"Violet","price":14,"cut":"straight"}, +{"size":"S","color":"Teal","price":20,"cut":"fitted"}, +{"size":"3XL","color":"Red","price":17,"cut":"fitted"}, +{"size":"XS","color":"Pink","price":12,"cut":"fitted"}, +{"size":"3XL","color":"Yellow","price":20,"cut":"straight"}, +{"size":"XS","color":"Aquamarine","price":10,"cut":"straight"}, +{"size":"M","color":"Red","price":10,"cut":"straight"}, +{"size":"M","color":"Pink","price":10,"cut":"straight"}, +{"size":"2XL","color":"Maroon","price":10,"cut":"straight"}, +{"size":"S","color":"Purple","price":16,"cut":"straight"}, +{"size":"M","color":"Blue","price":18,"cut":"straight"}, +{"size":"XL","color":"Violet","price":18,"cut":"straight"}, +{"size":"S","color":"Maroon","price":19,"cut":"straight"}, +{"size":"3XL","color":"Pink","price":14,"cut":"straight"}, +{"size":"S","color":"Khaki","price":18,"cut":"straight"}, +{"size":"XS","color":"Aquamarine","price":11,"cut":"fitted"}, +{"size":"2XL","color":"Khaki","price":13,"cut":"straight"}, +{"size":"2XL","color":"Maroon","price":13,"cut":"fitted"}, +{"size":"2XL","color":"Red","price":20,"cut":"straight"}, +{"size":"2XL","color":"Teal","price":12,"cut":"fitted"}, +{"size":"XL","color":"Maroon","price":17,"cut":"straight"}, +{"size":"2XL","color":"Indigo","price":17,"cut":"straight"}, +{"size":"L","color":"Blue","price":12,"cut":"straight"}, +{"size":"L","color":"Puce","price":16,"cut":"fitted"}, +{"size":"L","color":"Green","price":14,"cut":"straight"}, +{"size":"3XL","color":"Pink","price":16,"cut":"fitted"}, +{"size":"XS","color":"Pink","price":16,"cut":"fitted"}, +{"size":"S","color":"Pink","price":16,"cut":"straight"}, +{"size":"S","color":"Puce","price":12,"cut":"fitted"}, +{"size":"M","color":"Indigo","price":16,"cut":"straight"}, +{"size":"L","color":"Fuscia","price":11,"cut":"fitted"}, +{"size":"L","color":"Goldenrod","price":14,"cut":"straight"}, +{"size":"S","color":"Violet","price":10,"cut":"straight"}, +{"size":"3XL","color":"Red","price":14,"cut":"straight"}, +{"size":"L","color":"Violet","price":14,"cut":"straight"}, +{"size":"L","color":"Turquoise","price":15,"cut":"fitted"}, +{"size":"L","color":"Aquamarine","price":12,"cut":"fitted"}, +{"size":"XS","color":"Puce","price":13,"cut":"fitted"}, +{"size":"L","color":"Purple","price":20,"cut":"straight"}, +{"size":"M","color":"Maroon","price":10,"cut":"fitted"}, +{"size":"3XL","color":"Indigo","price":17,"cut":"straight"}, +{"size":"S","color":"Crimson","price":15,"cut":"fitted"}, +{"size":"M","color":"Khaki","price":17,"cut":"fitted"}, +{"size":"XL","color":"Mauv","price":17,"cut":"straight"}, +{"size":"3XL","color":"Khaki","price":15,"cut":"fitted"}, +{"size":"2XL","color":"Purple","price":10,"cut":"straight"}, +{"size":"XS","color":"Orange","price":10,"cut":"straight"}, +{"size":"L","color":"Turquoise","price":20,"cut":"fitted"}, +{"size":"M","color":"Yellow","price":17,"cut":"straight"}, +{"size":"2XL","color":"Pink","price":11,"cut":"fitted"}, +{"size":"S","color":"Maroon","price":19,"cut":"straight"}, +{"size":"3XL","color":"Khaki","price":10,"cut":"straight"}, +{"size":"2XL","color":"Fuscia","price":18,"cut":"straight"}, +{"size":"XL","color":"Puce","price":17,"cut":"straight"}, +{"size":"M","color":"Aquamarine","price":20,"cut":"fitted"}, +{"size":"XL","color":"Orange","price":20,"cut":"fitted"}, +{"size":"S","color":"Turquoise","price":19,"cut":"straight"}, +{"size":"2XL","color":"Fuscia","price":13,"cut":"straight"}, +{"size":"L","color":"Pink","price":12,"cut":"straight"}, +{"size":"M","color":"Goldenrod","price":10,"cut":"fitted"}, +{"size":"M","color":"Aquamarine","price":14,"cut":"straight"}, +{"size":"2XL","color":"Fuscia","price":12,"cut":"straight"}, +{"size":"2XL","color":"Khaki","price":13,"cut":"straight"}, +{"size":"XS","color":"Red","price":11,"cut":"fitted"}, +{"size":"L","color":"Purple","price":10,"cut":"straight"}, +{"size":"XL","color":"Aquamarine","price":16,"cut":"straight"}, +{"size":"S","color":"Puce","price":13,"cut":"straight"}, +{"size":"2XL","color":"Yellow","price":18,"cut":"fitted"}, +{"size":"S","color":"Yellow","price":17,"cut":"straight"}, +{"size":"S","color":"Teal","price":11,"cut":"fitted"}, +{"size":"M","color":"Teal","price":13,"cut":"fitted"}, +{"size":"M","color":"Green","price":14,"cut":"fitted"}, +{"size":"XS","color":"Teal","price":17,"cut":"straight"}, +{"size":"XL","color":"Fuscia","price":10,"cut":"straight"}, +{"size":"XS","color":"Pink","price":19,"cut":"straight"}, +{"size":"L","color":"Aquamarine","price":10,"cut":"fitted"}, +{"size":"XL","color":"Green","price":17,"cut":"straight"}, +{"size":"3XL","color":"Yellow","price":14,"cut":"straight"}, +{"size":"3XL","color":"Pink","price":17,"cut":"straight"}, +{"size":"XS","color":"Blue","price":16,"cut":"fitted"}, +{"size":"M","color":"Teal","price":15,"cut":"fitted"}, +{"size":"2XL","color":"Aquamarine","price":15,"cut":"fitted"}, +{"size":"S","color":"Green","price":16,"cut":"fitted"}, +{"size":"S","color":"Goldenrod","price":17,"cut":"straight"}, +{"size":"2XL","color":"Violet","price":12,"cut":"fitted"}, +{"size":"3XL","color":"Green","price":10,"cut":"straight"}, +{"size":"3XL","color":"Puce","price":14,"cut":"straight"}, +{"size":"S","color":"Crimson","price":10,"cut":"straight"}, +{"size":"2XL","color":"Crimson","price":14,"cut":"straight"}, +{"size":"3XL","color":"Indigo","price":19,"cut":"straight"}, +{"size":"2XL","color":"Violet","price":16,"cut":"straight"}, +{"size":"XL","color":"Violet","price":11,"cut":"straight"}, +{"size":"M","color":"Teal","price":11,"cut":"fitted"}, +{"size":"M","color":"Puce","price":14,"cut":"straight"}, +{"size":"XL","color":"Green","price":17,"cut":"straight"}, +{"size":"M","color":"Turquoise","price":13,"cut":"fitted"}, +{"size":"XS","color":"Goldenrod","price":18,"cut":"fitted"}, +{"size":"S","color":"Puce","price":20,"cut":"fitted"}, +{"size":"L","color":"Pink","price":18,"cut":"fitted"}, +{"size":"2XL","color":"Fuscia","price":18,"cut":"straight"}, +{"size":"XS","color":"Yellow","price":12,"cut":"straight"}, +{"size":"XS","color":"Turquoise","price":14,"cut":"fitted"}, +{"size":"L","color":"Turquoise","price":16,"cut":"straight"}, +{"size":"XS","color":"Yellow","price":10,"cut":"fitted"}, +{"size":"XL","color":"Pink","price":19,"cut":"straight"}, +{"size":"S","color":"Violet","price":14,"cut":"straight"}, +{"size":"XL","color":"Khaki","price":16,"cut":"straight"}, +{"size":"XL","color":"Puce","price":11,"cut":"fitted"}, +{"size":"3XL","color":"Violet","price":18,"cut":"straight"}, +{"size":"L","color":"Turquoise","price":11,"cut":"fitted"}, +{"size":"2XL","color":"Khaki","price":15,"cut":"straight"}, +{"size":"XS","color":"Indigo","price":20,"cut":"straight"}, +{"size":"XL","color":"Violet","price":17,"cut":"straight"}, +{"size":"L","color":"Fuscia","price":10,"cut":"fitted"}, +{"size":"M","color":"Blue","price":13,"cut":"straight"}, +{"size":"S","color":"Blue","price":11,"cut":"fitted"}, +{"size":"S","color":"Maroon","price":17,"cut":"straight"}, +{"size":"L","color":"Teal","price":18,"cut":"straight"}, +{"size":"3XL","color":"Fuscia","price":13,"cut":"straight"}, +{"size":"2XL","color":"Fuscia","price":17,"cut":"straight"}, +{"size":"L","color":"Crimson","price":17,"cut":"fitted"}, +{"size":"XL","color":"Maroon","price":14,"cut":"fitted"}, +{"size":"M","color":"Yellow","price":19,"cut":"straight"}, +{"size":"L","color":"Turquoise","price":10,"cut":"straight"}, +{"size":"L","color":"Purple","price":20,"cut":"straight"}, +{"size":"L","color":"Turquoise","price":11,"cut":"straight"}, +{"size":"S","color":"Red","price":12,"cut":"straight"}, +{"size":"2XL","color":"Indigo","price":15,"cut":"fitted"}, +{"size":"S","color":"Turquoise","price":11,"cut":"straight"}, +{"size":"XS","color":"Aquamarine","price":20,"cut":"straight"}, +{"size":"3XL","color":"Teal","price":12,"cut":"straight"}, +{"size":"3XL","color":"Yellow","price":18,"cut":"straight"}, +{"size":"L","color":"Teal","price":16,"cut":"straight"}, +{"size":"2XL","color":"Purple","price":13,"cut":"fitted"}, +{"size":"XS","color":"Fuscia","price":20,"cut":"straight"}, +{"size":"2XL","color":"Turquoise","price":17,"cut":"straight"}, +{"size":"3XL","color":"Purple","price":20,"cut":"straight"}, +{"size":"2XL","color":"Red","price":18,"cut":"fitted"}, +{"size":"M","color":"Teal","price":17,"cut":"fitted"}, +{"size":"XL","color":"Turquoise","price":11,"cut":"straight"}, +{"size":"L","color":"Purple","price":13,"cut":"fitted"}, +{"size":"M","color":"Blue","price":14,"cut":"fitted"}, +{"size":"M","color":"Yellow","price":11,"cut":"straight"}, +{"size":"3XL","color":"Pink","price":12,"cut":"straight"}, +{"size":"3XL","color":"Maroon","price":17,"cut":"straight"}, +{"size":"XS","color":"Yellow","price":11,"cut":"straight"}, +{"size":"XS","color":"Orange","price":13,"cut":"straight"}, +{"size":"S","color":"Fuscia","price":14,"cut":"straight"}, +{"size":"XL","color":"Purple","price":14,"cut":"fitted"}, +{"size":"2XL","color":"Indigo","price":20,"cut":"fitted"}, +{"size":"3XL","color":"Mauv","price":19,"cut":"straight"}, +{"size":"2XL","color":"Maroon","price":14,"cut":"straight"}, +{"size":"XL","color":"Teal","price":20,"cut":"fitted"}, +{"size":"3XL","color":"Teal","price":16,"cut":"fitted"}, +{"size":"L","color":"Khaki","price":13,"cut":"fitted"}, +{"size":"XS","color":"Violet","price":12,"cut":"fitted"}, +{"size":"2XL","color":"Khaki","price":19,"cut":"fitted"}, +{"size":"3XL","color":"Red","price":17,"cut":"straight"}, +{"size":"2XL","color":"Indigo","price":17,"cut":"straight"}, +{"size":"L","color":"Violet","price":11,"cut":"fitted"}, +{"size":"XS","color":"Crimson","price":17,"cut":"straight"}, +{"size":"M","color":"Mauv","price":20,"cut":"fitted"}, +{"size":"XL","color":"Purple","price":15,"cut":"straight"}, +{"size":"3XL","color":"Red","price":10,"cut":"straight"}, +{"size":"XS","color":"Yellow","price":10,"cut":"fitted"}, +{"size":"L","color":"Goldenrod","price":15,"cut":"straight"}, +{"size":"XL","color":"Turquoise","price":13,"cut":"straight"}, +{"size":"XL","color":"Mauv","price":11,"cut":"fitted"}, +{"size":"S","color":"Blue","price":15,"cut":"fitted"}, +{"size":"M","color":"Red","price":15,"cut":"fitted"}, +{"size":"XL","color":"Green","price":13,"cut":"fitted"}, +{"size":"M","color":"Teal","price":14,"cut":"fitted"}, +{"size":"XS","color":"Teal","price":19,"cut":"straight"}, +{"size":"M","color":"Green","price":13,"cut":"straight"}, +{"size":"L","color":"Aquamarine","price":17,"cut":"straight"}, +{"size":"3XL","color":"Fuscia","price":17,"cut":"straight"}, +{"size":"XL","color":"Maroon","price":18,"cut":"straight"}, +{"size":"M","color":"Violet","price":19,"cut":"straight"}, +{"size":"XS","color":"Fuscia","price":13,"cut":"straight"}, +{"size":"M","color":"Violet","price":11,"cut":"fitted"}, +{"size":"L","color":"Indigo","price":15,"cut":"straight"}, +{"size":"S","color":"Teal","price":13,"cut":"straight"}, +{"size":"M","color":"Puce","price":18,"cut":"straight"}, +{"size":"3XL","color":"Red","price":10,"cut":"straight"}, +{"size":"M","color":"Maroon","price":16,"cut":"straight"}, +{"size":"M","color":"Green","price":10,"cut":"straight"}, +{"size":"3XL","color":"Blue","price":19,"cut":"straight"}, +{"size":"XL","color":"Purple","price":15,"cut":"fitted"}, +{"size":"S","color":"Maroon","price":20,"cut":"fitted"}, +{"size":"L","color":"Violet","price":11,"cut":"fitted"}, +{"size":"M","color":"Red","price":14,"cut":"fitted"}, +{"size":"2XL","color":"Blue","price":19,"cut":"straight"}, +{"size":"XL","color":"Blue","price":20,"cut":"straight"}, +{"size":"2XL","color":"Khaki","price":18,"cut":"straight"}, +{"size":"L","color":"Purple","price":12,"cut":"fitted"}, +{"size":"XS","color":"Goldenrod","price":20,"cut":"fitted"}, +{"size":"XS","color":"Crimson","price":17,"cut":"fitted"}, +{"size":"2XL","color":"Blue","price":10,"cut":"fitted"}, +{"size":"XL","color":"Purple","price":19,"cut":"fitted"}, +{"size":"M","color":"Orange","price":12,"cut":"fitted"}, +{"size":"S","color":"Green","price":16,"cut":"fitted"}, +{"size":"XS","color":"Aquamarine","price":15,"cut":"straight"}, +{"size":"XS","color":"Indigo","price":10,"cut":"straight"}, +{"size":"3XL","color":"Indigo","price":19,"cut":"straight"}, +{"size":"L","color":"Puce","price":12,"cut":"fitted"}, +{"size":"L","color":"Aquamarine","price":14,"cut":"straight"}, +{"size":"XS","color":"Purple","price":17,"cut":"fitted"}, +{"size":"3XL","color":"Fuscia","price":16,"cut":"straight"}, +{"size":"2XL","color":"Green","price":12,"cut":"straight"}, +{"size":"XL","color":"Indigo","price":20,"cut":"straight"}, +{"size":"M","color":"Khaki","price":20,"cut":"straight"}, +{"size":"M","color":"Fuscia","price":14,"cut":"fitted"}, +{"size":"L","color":"Goldenrod","price":13,"cut":"fitted"}, +{"size":"XS","color":"Red","price":15,"cut":"fitted"}, +{"size":"M","color":"Indigo","price":18,"cut":"fitted"}, +{"size":"L","color":"Violet","price":12,"cut":"straight"}, +{"size":"3XL","color":"Aquamarine","price":18,"cut":"straight"}, +{"size":"L","color":"Crimson","price":11,"cut":"fitted"}, +{"size":"L","color":"Teal","price":12,"cut":"fitted"}, +{"size":"M","color":"Mauv","price":14,"cut":"straight"}, +{"size":"2XL","color":"Puce","price":20,"cut":"straight"}, +{"size":"2XL","color":"Orange","price":20,"cut":"straight"}, +{"size":"L","color":"Fuscia","price":18,"cut":"straight"}, +{"size":"L","color":"Blue","price":19,"cut":"fitted"}, +{"size":"L","color":"Teal","price":17,"cut":"fitted"}, +{"size":"2XL","color":"Khaki","price":10,"cut":"straight"}, +{"size":"2XL","color":"Red","price":20,"cut":"fitted"}, +{"size":"M","color":"Indigo","price":10,"cut":"straight"}, +{"size":"XL","color":"Indigo","price":15,"cut":"fitted"}, +{"size":"L","color":"Purple","price":10,"cut":"straight"}, +{"size":"XL","color":"Mauv","price":13,"cut":"straight"}, +{"size":"S","color":"Teal","price":16,"cut":"fitted"}, +{"size":"L","color":"Aquamarine","price":15,"cut":"straight"}, +{"size":"L","color":"Blue","price":14,"cut":"fitted"}, +{"size":"XS","color":"Pink","price":16,"cut":"fitted"}, +{"size":"S","color":"Orange","price":15,"cut":"straight"}, +{"size":"S","color":"Goldenrod","price":16,"cut":"fitted"}, +{"size":"M","color":"Orange","price":18,"cut":"straight"}, +{"size":"XL","color":"Pink","price":14,"cut":"straight"}, +{"size":"3XL","color":"Puce","price":10,"cut":"fitted"}, +{"size":"XS","color":"Fuscia","price":13,"cut":"fitted"}, +{"size":"XS","color":"Green","price":16,"cut":"straight"}, +{"size":"XS","color":"Green","price":11,"cut":"straight"}, +{"size":"XL","color":"Pink","price":16,"cut":"straight"}, +{"size":"2XL","color":"Green","price":15,"cut":"straight"}, +{"size":"XL","color":"Blue","price":18,"cut":"straight"}, +{"size":"3XL","color":"Red","price":17,"cut":"fitted"}, +{"size":"2XL","color":"Aquamarine","price":18,"cut":"straight"}, +{"size":"M","color":"Fuscia","price":16,"cut":"fitted"}, +{"size":"L","color":"Orange","price":19,"cut":"fitted"}, +{"size":"S","color":"Teal","price":12,"cut":"straight"}, +{"size":"3XL","color":"Goldenrod","price":11,"cut":"straight"}, +{"size":"M","color":"Crimson","price":12,"cut":"fitted"}, +{"size":"2XL","color":"Puce","price":15,"cut":"straight"}, +{"size":"XL","color":"Fuscia","price":13,"cut":"straight"}, +{"size":"XS","color":"Orange","price":19,"cut":"fitted"}, +{"size":"2XL","color":"Pink","price":17,"cut":"straight"}, +{"size":"M","color":"Blue","price":20,"cut":"straight"}, +{"size":"L","color":"Goldenrod","price":17,"cut":"straight"}, +{"size":"M","color":"Violet","price":20,"cut":"fitted"}, +{"size":"L","color":"Blue","price":10,"cut":"straight"}, +{"size":"3XL","color":"Yellow","price":11,"cut":"straight"}, +{"size":"L","color":"Khaki","price":20,"cut":"straight"}, +{"size":"2XL","color":"Turquoise","price":20,"cut":"straight"}, +{"size":"XL","color":"Orange","price":17,"cut":"fitted"}, +{"size":"XS","color":"Mauv","price":12,"cut":"fitted"}, +{"size":"2XL","color":"Mauv","price":14,"cut":"straight"}, +{"size":"XL","color":"Pink","price":13,"cut":"fitted"}, +{"size":"3XL","color":"Indigo","price":14,"cut":"straight"}, +{"size":"M","color":"Pink","price":15,"cut":"fitted"}, +{"size":"L","color":"Pink","price":18,"cut":"fitted"}, +{"size":"3XL","color":"Orange","price":20,"cut":"straight"}, +{"size":"2XL","color":"Mauv","price":20,"cut":"fitted"}, +{"size":"2XL","color":"Pink","price":15,"cut":"fitted"}, +{"size":"XL","color":"Green","price":20,"cut":"straight"}, +{"size":"XL","color":"Maroon","price":14,"cut":"straight"}, +{"size":"3XL","color":"Red","price":11,"cut":"straight"}, +{"size":"3XL","color":"Fuscia","price":10,"cut":"fitted"}, +{"size":"L","color":"Violet","price":20,"cut":"straight"}, +{"size":"3XL","color":"Teal","price":10,"cut":"fitted"}, +{"size":"2XL","color":"Fuscia","price":19,"cut":"straight"}, +{"size":"M","color":"Fuscia","price":13,"cut":"fitted"}, +{"size":"XS","color":"Purple","price":19,"cut":"fitted"}, +{"size":"2XL","color":"Violet","price":13,"cut":"fitted"}, +{"size":"2XL","color":"Khaki","price":20,"cut":"straight"}, +{"size":"M","color":"Red","price":13,"cut":"fitted"}, +{"size":"M","color":"Pink","price":13,"cut":"fitted"}, +{"size":"L","color":"Red","price":12,"cut":"straight"}, +{"size":"L","color":"Aquamarine","price":20,"cut":"straight"}, +{"size":"S","color":"Goldenrod","price":18,"cut":"fitted"}, +{"size":"XL","color":"Fuscia","price":18,"cut":"fitted"}, +{"size":"3XL","color":"Goldenrod","price":19,"cut":"fitted"}, +{"size":"S","color":"Goldenrod","price":10,"cut":"fitted"}, +{"size":"XS","color":"Indigo","price":13,"cut":"fitted"}, +{"size":"S","color":"Turquoise","price":15,"cut":"straight"}, +{"size":"M","color":"Purple","price":18,"cut":"straight"}, +{"size":"L","color":"Maroon","price":20,"cut":"fitted"}, +{"size":"3XL","color":"Mauv","price":17,"cut":"straight"}, +{"size":"M","color":"Yellow","price":15,"cut":"straight"}, +{"size":"XS","color":"Crimson","price":10,"cut":"straight"}, +{"size":"S","color":"Teal","price":16,"cut":"straight"}, +{"size":"M","color":"Pink","price":20,"cut":"straight"}, +{"size":"XS","color":"Mauv","price":14,"cut":"fitted"}, +{"size":"L","color":"Purple","price":14,"cut":"fitted"}, +{"size":"XL","color":"Yellow","price":19,"cut":"straight"}, +{"size":"2XL","color":"Teal","price":14,"cut":"straight"}, +{"size":"M","color":"Mauv","price":15,"cut":"fitted"}, +{"size":"M","color":"Indigo","price":11,"cut":"straight"}, +{"size":"M","color":"Aquamarine","price":13,"cut":"straight"}, +{"size":"S","color":"Orange","price":20,"cut":"fitted"}, +{"size":"3XL","color":"Violet","price":13,"cut":"fitted"}, +{"size":"S","color":"Green","price":20,"cut":"fitted"}, +{"size":"L","color":"Turquoise","price":18,"cut":"straight"}, +{"size":"S","color":"Purple","price":15,"cut":"straight"}, +{"size":"L","color":"Indigo","price":19,"cut":"fitted"}, +{"size":"2XL","color":"Puce","price":13,"cut":"straight"}, +{"size":"L","color":"Orange","price":20,"cut":"straight"}, +{"size":"3XL","color":"Fuscia","price":14,"cut":"fitted"}, +{"size":"XS","color":"Red","price":16,"cut":"fitted"}, +{"size":"XL","color":"Crimson","price":13,"cut":"straight"}, +{"size":"2XL","color":"Crimson","price":19,"cut":"straight"}, +{"size":"M","color":"Goldenrod","price":17,"cut":"straight"}, +{"size":"2XL","color":"Red","price":13,"cut":"straight"}, +{"size":"XS","color":"Orange","price":20,"cut":"fitted"}, +{"size":"S","color":"Turquoise","price":15,"cut":"fitted"}, +{"size":"2XL","color":"Goldenrod","price":16,"cut":"fitted"}, +{"size":"M","color":"Maroon","price":14,"cut":"fitted"}, +{"size":"XL","color":"Fuscia","price":13,"cut":"straight"}, +{"size":"M","color":"Aquamarine","price":14,"cut":"fitted"}, +{"size":"3XL","color":"Red","price":17,"cut":"straight"}, +{"size":"L","color":"Pink","price":18,"cut":"fitted"}, +{"size":"L","color":"Yellow","price":10,"cut":"straight"}, +{"size":"L","color":"Pink","price":20,"cut":"straight"}, +{"size":"XL","color":"Khaki","price":11,"cut":"straight"}, +{"size":"3XL","color":"Khaki","price":11,"cut":"fitted"}, +{"size":"3XL","color":"Indigo","price":16,"cut":"straight"}, +{"size":"2XL","color":"Orange","price":10,"cut":"straight"}, +{"size":"S","color":"Crimson","price":15,"cut":"fitted"}, +{"size":"XL","color":"Khaki","price":17,"cut":"straight"}, +{"size":"3XL","color":"Maroon","price":10,"cut":"fitted"}, +{"size":"L","color":"Teal","price":14,"cut":"straight"}, +{"size":"2XL","color":"Red","price":17,"cut":"straight"}, +{"size":"3XL","color":"Orange","price":19,"cut":"fitted"}, +{"size":"M","color":"Turquoise","price":12,"cut":"fitted"}, +{"size":"2XL","color":"Maroon","price":11,"cut":"straight"}, +{"size":"3XL","color":"Puce","price":13,"cut":"straight"}, +{"size":"L","color":"Orange","price":13,"cut":"fitted"}, +{"size":"3XL","color":"Violet","price":17,"cut":"straight"}, +{"size":"3XL","color":"Turquoise","price":16,"cut":"fitted"}, +{"size":"2XL","color":"Pink","price":13,"cut":"straight"}, +{"size":"XL","color":"Red","price":19,"cut":"straight"}, +{"size":"XL","color":"Orange","price":18,"cut":"straight"}, +{"size":"L","color":"Mauv","price":19,"cut":"fitted"}, +{"size":"L","color":"Yellow","price":17,"cut":"fitted"}, +{"size":"S","color":"Mauv","price":15,"cut":"fitted"}, +{"size":"2XL","color":"Mauv","price":20,"cut":"straight"}, +{"size":"L","color":"Blue","price":11,"cut":"fitted"}, +{"size":"L","color":"Green","price":18,"cut":"straight"}, +{"size":"XS","color":"Red","price":14,"cut":"fitted"}, +{"size":"XL","color":"Goldenrod","price":10,"cut":"straight"}, +{"size":"M","color":"Crimson","price":13,"cut":"fitted"}, +{"size":"S","color":"Puce","price":15,"cut":"straight"}, +{"size":"XS","color":"Blue","price":10,"cut":"straight"}, +{"size":"XL","color":"Puce","price":17,"cut":"straight"}, +{"size":"XS","color":"Red","price":12,"cut":"fitted"}, +{"size":"2XL","color":"Maroon","price":11,"cut":"straight"}, +{"size":"2XL","color":"Violet","price":10,"cut":"straight"}, +{"size":"L","color":"Blue","price":15,"cut":"fitted"}, +{"size":"3XL","color":"Pink","price":10,"cut":"straight"}, +{"size":"XL","color":"Teal","price":19,"cut":"straight"}, +{"size":"XS","color":"Aquamarine","price":10,"cut":"straight"}, +{"size":"S","color":"Maroon","price":13,"cut":"straight"}, +{"size":"S","color":"Green","price":10,"cut":"straight"}, +{"size":"2XL","color":"Aquamarine","price":15,"cut":"straight"}, +{"size":"XS","color":"Pink","price":10,"cut":"straight"}, +{"size":"S","color":"Khaki","price":13,"cut":"fitted"}, +{"size":"3XL","color":"Turquoise","price":16,"cut":"straight"}, +{"size":"S","color":"Blue","price":16,"cut":"fitted"}, +{"size":"2XL","color":"Red","price":16,"cut":"fitted"}, +{"size":"2XL","color":"Puce","price":13,"cut":"fitted"}, +{"size":"M","color":"Orange","price":14,"cut":"straight"}, +{"size":"3XL","color":"Yellow","price":15,"cut":"straight"}, +{"size":"S","color":"Green","price":14,"cut":"fitted"}, +{"size":"XS","color":"Mauv","price":13,"cut":"straight"}, +{"size":"XS","color":"Khaki","price":14,"cut":"straight"}, +{"size":"M","color":"Blue","price":14,"cut":"fitted"}, +{"size":"L","color":"Green","price":13,"cut":"fitted"}, +{"size":"3XL","color":"Yellow","price":17,"cut":"straight"}, +{"size":"XS","color":"Maroon","price":16,"cut":"fitted"}, +{"size":"S","color":"Orange","price":18,"cut":"straight"}, +{"size":"XS","color":"Teal","price":15,"cut":"fitted"}, +{"size":"M","color":"Crimson","price":16,"cut":"fitted"}, +{"size":"XS","color":"Puce","price":11,"cut":"straight"}, +{"size":"3XL","color":"Turquoise","price":16,"cut":"fitted"}, +{"size":"XL","color":"Pink","price":11,"cut":"fitted"}, +{"size":"XL","color":"Indigo","price":13,"cut":"fitted"}, +{"size":"L","color":"Red","price":10,"cut":"straight"}, +{"size":"XL","color":"Turquoise","price":16,"cut":"straight"}, +{"size":"XL","color":"Maroon","price":16,"cut":"fitted"}, +{"size":"S","color":"Aquamarine","price":14,"cut":"fitted"}, +{"size":"XL","color":"Khaki","price":14,"cut":"straight"}, +{"size":"S","color":"Aquamarine","price":19,"cut":"fitted"}, +{"size":"M","color":"Maroon","price":10,"cut":"fitted"}, +{"size":"L","color":"Crimson","price":19,"cut":"straight"}, +{"size":"3XL","color":"Fuscia","price":14,"cut":"straight"}, +{"size":"3XL","color":"Orange","price":17,"cut":"straight"}, +{"size":"XL","color":"Maroon","price":12,"cut":"straight"}, +{"size":"2XL","color":"Teal","price":12,"cut":"straight"}, +{"size":"S","color":"Pink","price":11,"cut":"fitted"}, +{"size":"XS","color":"Turquoise","price":20,"cut":"straight"}, +{"size":"2XL","color":"Yellow","price":17,"cut":"straight"}, +{"size":"S","color":"Fuscia","price":16,"cut":"fitted"}, +{"size":"L","color":"Maroon","price":10,"cut":"straight"}, +{"size":"XL","color":"Aquamarine","price":14,"cut":"straight"}, +{"size":"3XL","color":"Teal","price":15,"cut":"straight"}, +{"size":"3XL","color":"Blue","price":19,"cut":"straight"}, +{"size":"3XL","color":"Goldenrod","price":12,"cut":"fitted"}, +{"size":"XL","color":"Aquamarine","price":10,"cut":"fitted"}, +{"size":"XL","color":"Khaki","price":19,"cut":"straight"}, +{"size":"M","color":"Aquamarine","price":11,"cut":"straight"}, +{"size":"3XL","color":"Red","price":15,"cut":"straight"}, +{"size":"L","color":"Puce","price":19,"cut":"fitted"}, +{"size":"M","color":"Aquamarine","price":19,"cut":"fitted"}, +{"size":"2XL","color":"Turquoise","price":12,"cut":"straight"}, +{"size":"2XL","color":"Puce","price":11,"cut":"straight"}, +{"size":"XS","color":"Fuscia","price":13,"cut":"straight"}, +{"size":"XS","color":"Puce","price":14,"cut":"straight"}, +{"size":"XS","color":"Green","price":18,"cut":"fitted"}] diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/escount.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/escount.js new file mode 100644 index 0000000000000..ab9926f91c687 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/escount.js @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { buildESRequest } from '../../../server/lib/build_es_request'; + +export const escount = () => ({ + name: 'escount', + type: 'number', + help: 'Query elasticsearch for a count of the number of hits matching a query', + context: { + types: ['filter'], + }, + args: { + index: { + types: ['string', 'null'], + default: '_all', + help: 'Specify an index pattern. Eg "logstash-*"', + }, + query: { + types: ['string'], + aliases: ['_', 'q'], + help: 'A Lucene query string', + default: '"-_index:.kibana"', + }, + }, + fn: (context, args, handlers) => { + context.and = context.and.concat([ + { + type: 'luceneQueryString', + query: args.query, + }, + ]); + + const esRequest = buildESRequest( + { + index: args.index, + body: { + query: { + bool: { + must: [{ match_all: {} }], + }, + }, + }, + }, + context + ); + + return handlers.elasticsearchClient('count', esRequest).then(resp => resp.count); + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/esdocs/index.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/esdocs/index.js new file mode 100644 index 0000000000000..7cb559b10464b --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/esdocs/index.js @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import squel from 'squel'; +import { map, zipObject } from 'lodash'; +import { buildBoolArray } from '../../../../server/lib/build_bool_array'; +import { normalizeType } from '../../../../server/lib/normalize_type'; +import { sanitizeName } from '../../../../server/lib/sanitize_name'; + +export const esdocs = () => ({ + name: 'esdocs', + type: 'datatable', + help: + 'Query elasticsearch and get back raw documents. We recommend you specify the fields you want, ' + + 'especially if you are going to ask for a lot of rows', + context: { + types: ['filter'], + }, + args: { + index: { + types: ['string', 'null'], + default: '_all', + help: 'Specify an index pattern. Eg "logstash-*"', + }, + query: { + types: ['string'], + aliases: ['_', 'q'], + help: 'A Lucene query string', + default: '-_index:.kibana', + }, + sort: { + types: ['string', 'null'], + help: 'Sort directions as "field, direction". Eg "@timestamp, desc" or "bytes, asc"', + }, + fields: { + help: 'Comma separated list of fields. Fewer fields will perform better.', + types: ['string', 'null'], + }, + metaFields: { + help: 'Comma separated list of meta fields, eg "_index,_type"', + types: ['string', 'null'], + }, + count: { + types: ['number'], + default: 100, + help: 'The number of docs to pull back. Smaller numbers perform better', + }, + }, + fn: (context, args, handlers) => { + context.and = context.and.concat([ + { + type: 'luceneQueryString', + query: args.query, + }, + ]); + + let query = squel + .select({ + autoQuoteTableNames: true, + autoQuoteFieldNames: true, + autoQuoteAliasNames: true, + nameQuoteCharacter: '"', + }) + .from(args.index.toLowerCase()); + + if (args.fields) { + const fields = args.fields.split(',').map(field => field.trim()); + fields.forEach(field => (query = query.field(field))); + } + + if (args.sort) { + const [sortField, sortOrder] = args.sort.split(',').map(str => str.trim()); + if (sortField) query.order(`"${sortField}"`, sortOrder.toLowerCase() === 'asc'); + } + + return handlers + .elasticsearchClient('transport.request', { + path: '/_xpack/sql?format=json', + method: 'POST', + body: { + fetch_size: args.count, + query: query.toString(), + filter: { + bool: { + must: [{ match_all: {} }, ...buildBoolArray(context.and)], + }, + }, + }, + }) + .then(res => { + const columns = res.columns.map(({ name, type }) => { + return { name: sanitizeName(name), type: normalizeType(type) }; + }); + const columnNames = map(columns, 'name'); + const rows = res.rows.map(row => zipObject(columnNames, row)); + return { + type: 'datatable', + columns, + rows, + }; + }); + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/essql/index.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/essql/index.js new file mode 100644 index 0000000000000..aeffee3bc9a44 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/essql/index.js @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { map, zipObject } from 'lodash'; +import { normalizeType } from '../../../../server/lib/normalize_type'; +import { buildBoolArray } from '../../../../server/lib/build_bool_array'; +import { sanitizeName } from '../../../../server/lib/sanitize_name'; + +export const essql = () => ({ + name: 'essql', + type: 'datatable', + context: { + types: ['filter'], + }, + help: 'Elasticsearch SQL', + args: { + query: { + aliases: ['_', 'q'], + types: ['string'], + help: 'SQL query', + }, + count: { + types: ['number'], + default: 1000, + }, + }, + fn(context, args, helpers) { + return helpers + .elasticsearchClient('transport.request', { + path: '/_xpack/sql?format=json', + method: 'POST', + body: { + fetch_size: args.count, + query: args.query, + filter: { + bool: { + must: [{ match_all: {} }, ...buildBoolArray(context.and)], + }, + }, + }, + }) + .then(res => { + const columns = res.columns.map(({ name, type }) => { + return { name: sanitizeName(name), type: normalizeType(type) }; + }); + const columnNames = map(columns, 'name'); + const rows = res.rows.map(row => zipObject(columnNames, row)); + return { + type: 'datatable', + columns, + rows, + }; + }) + .catch(e => { + if (e.message.indexOf('parsing_exception') > -1) { + throw new Error( + `Couldn't parse Elasticsearch SQL query. You may need to add double quotes to names containing special characters. Check your query and try again. Error: ${ + e.message + }` + ); + } + throw new Error(`Unexpected error from Elasticsearch: ${e.message}`); + }); + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/index.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/index.js new file mode 100644 index 0000000000000..03402d36709a9 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/index.js @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { demodata } from './demodata'; +import { escount } from './escount'; +import { esdocs } from './esdocs'; +import { pointseries } from './pointseries'; +import { server } from './server'; +import { timelion } from './timelion'; +import { essql } from './essql'; + +export const functions = [demodata, esdocs, escount, essql, pointseries, server, timelion]; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/pointseries/index.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/pointseries/index.js new file mode 100644 index 0000000000000..7e519f965ddbd --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/pointseries/index.js @@ -0,0 +1,177 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import uniqBy from 'lodash.uniqby'; +import { evaluate } from 'tinymath'; +import { groupBy, zipObject, omit, values } from 'lodash'; +import moment from 'moment'; +import { pivotObjectArray } from '../../../../common/lib/pivot_object_array'; +import { unquoteString } from '../../../../common/lib/unquote_string'; +import { isColumnReference } from './lib/is_column_reference'; +import { getExpressionType } from './lib/get_expression_type'; + +// TODO: pointseries performs poorly, that's why we run it on the server. + +const columnExists = (cols, colName) => cols.includes(unquoteString(colName)); + +export const pointseries = () => ({ + name: 'pointseries', + type: 'pointseries', + help: + 'Turn a datatable into a point series model. Currently we differentiate measure from dimensions by looking for a [TinyMath function](http://canvas.elastic.co/reference/tinymath.html). ' + + 'If you enter a TinyMath expression in your argument, we treat that argument as a measure, otherwise it is a dimension. Dimensions are combined to create unique ' + + 'keys. Measures are then deduplicated by those keys using the specified TinyMath function', + context: { + types: ['datatable'], + }, + args: { + x: { + types: ['string', 'null'], + help: 'The values along the X-axis', + }, + y: { + types: ['string', 'null'], + help: 'The values along the y-axis', + }, + color: { + types: ['string', 'null'], + help: "An expression to use in determining the mark's color", // If you need categorization, transform the field. + }, + size: { + types: ['string', 'null'], + help: 'For elements that support it, the size of the marks', + }, + text: { + types: ['string', 'null'], + help: 'For use in charts that support it, the text to show in the mark', + }, + // In the future it may make sense to add things like shape, or tooltip values, but I think what we have is good for now + // The way the function below is written you can add as many arbitrary named args as you want. + }, + fn: (context, args) => { + // Note: can't replace pivotObjectArray with datatableToMathContext, lose name of non-numeric columns + const columnNames = context.columns.map(col => col.name); + const mathScope = pivotObjectArray(context.rows, columnNames); + const autoQuoteColumn = col => { + if (!columnNames.includes(col)) return col; + return col.match(/\s/) ? `'${col}'` : col; + }; + + const measureNames = []; + const dimensions = []; + const columns = {}; + + // Separates args into dimensions and measures arrays + // by checking if arg is a column reference (dimension) + Object.keys(args).forEach(arg => { + const mathExp = autoQuoteColumn(args[arg]); + + if (mathExp != null && mathExp.trim() !== '') { + const col = { + type: '', + role: '', + expression: mathExp, + }; + + if (isColumnReference(mathExp)) { + // TODO: Do something better if the column does not exist + if (!columnExists(columnNames, mathExp)) return; + + dimensions.push({ + name: arg, + value: mathExp, + }); + col.type = getExpressionType(context.columns, mathExp); + col.role = 'dimension'; + } else { + measureNames.push(arg); + col.type = 'number'; + col.role = 'measure'; + } + + columns[arg] = col; + } + }); + + const PRIMARY_KEY = '%%CANVAS_POINTSERIES_PRIMARY_KEY%%'; + const rows = context.rows.map((row, i) => ({ ...row, [PRIMARY_KEY]: i })); + + function normalizeValue(expression, value) { + switch (getExpressionType(context.columns, expression)) { + case 'string': + return String(value); + case 'number': + return Number(value); + case 'date': + return moment(value).valueOf(); + default: + return value; + } + } + + // Dimensions + // Group rows by their dimension values, using the argument values and preserving the PRIMARY_KEY + // There's probably a better way to do this + const results = rows.reduce((acc, row, i) => { + const newRow = dimensions.reduce( + (acc, { name, value }) => { + try { + acc[name] = args[name] ? normalizeValue(value, evaluate(value, mathScope)[i]) : '_all'; + } catch (e) { + // TODO: handle invalid column names... + // Do nothing if column does not exist + // acc[dimension] = '_all'; + } + return acc; + }, + { [PRIMARY_KEY]: row[PRIMARY_KEY] } + ); + + return Object.assign(acc, { [row[PRIMARY_KEY]]: newRow }); + }, {}); + + // Measures + // First group up all of the distinct dimensioned bits. Each of these will be reduced to just 1 value + // for each measure + const measureKeys = groupBy(rows, row => + dimensions.map(({ name }) => (args[name] ? row[args[name]] : '_all')).join('::%BURLAP%::') + ); + + // Then compute that 1 value for each measure + values(measureKeys).forEach(rows => { + const subtable = { type: 'datatable', columns: context.columns, rows: rows }; + const subScope = pivotObjectArray(subtable.rows, subtable.columns.map(col => col.name)); + const measureValues = measureNames.map(measure => { + try { + const ev = evaluate(args[measure], subScope); + if (Array.isArray(ev)) + throw new Error('Expressions must be wrapped in a function such as sum()'); + + return ev; + } catch (e) { + // TODO: don't catch if eval to Array + return null; + } + }); + + rows.forEach(row => { + Object.assign(results[row[PRIMARY_KEY]], zipObject(measureNames, measureValues)); + }); + }); + + // It only makes sense to uniq the rows in a point series as 2 values can not exist in the exact same place at the same time. + const resultingRows = uniqBy( + values(results).map(row => omit(row, PRIMARY_KEY)), + JSON.stringify + ); + + return { + type: 'pointseries', + columns: columns, + rows: resultingRows, + }; + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/pointseries/lib/get_expression_type.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/pointseries/lib/get_expression_type.js new file mode 100644 index 0000000000000..ccfd8417d5cb4 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/pointseries/lib/get_expression_type.js @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { parse } from 'tinymath'; +import { getFieldType } from '../../../../../common/lib/get_field_type'; +import { isColumnReference } from './is_column_reference'; +import { getFieldNames } from './get_field_names'; + +export function getExpressionType(columns, mathExpression) { + // if isColumnReference returns true, then mathExpression is just a string + // referencing a column in a datatable + if (isColumnReference(mathExpression)) return getFieldType(columns, mathExpression); + + const parsedMath = parse(mathExpression); + + if (parsedMath.args) { + const fieldNames = parsedMath.args.reduce(getFieldNames, []); + + if (fieldNames.length > 0) { + const fieldTypes = fieldNames.reduce((types, name) => { + const type = getFieldType(columns, name); + if (type !== 'null' && types.indexOf(type) === -1) return types.concat(type); + + return types; + }, []); + + return fieldTypes.length === 1 ? fieldTypes[0] : 'string'; + } + return 'number'; + } + + return typeof parsedMath; +} diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/pointseries/lib/get_field_names.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/pointseries/lib/get_field_names.js new file mode 100644 index 0000000000000..835138477fcf7 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/pointseries/lib/get_field_names.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export function getFieldNames(names, arg) { + if (arg.args != null) return names.concat(arg.args.reduce(getFieldNames, [])); + + if (typeof arg === 'string') return names.concat(arg); + + return names; +} diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/pointseries/lib/is_column_reference.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/pointseries/lib/is_column_reference.js new file mode 100644 index 0000000000000..3d66bd9a97c2a --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/pointseries/lib/is_column_reference.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { parse } from 'tinymath'; + +export function isColumnReference(mathExpression) { + if (mathExpression == null) mathExpression = 'null'; + const parsedMath = parse(mathExpression); + return typeof parsedMath === 'string'; +} diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/register.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/register.js new file mode 100644 index 0000000000000..f4e7fa4b467b5 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/register.js @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { functions } from './index'; + +functions.forEach(canvas.register); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/server.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/server.js new file mode 100644 index 0000000000000..e0928f7bb3b76 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/server.js @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const server = () => ({ + name: 'server', + help: 'Force the interpreter to return to the server', + args: {}, + fn: context => context, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/timelion.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/timelion.js new file mode 100644 index 0000000000000..58db763bdb690 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/timelion.js @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { flatten } from 'lodash'; +import { fetch } from '../../../common/lib/fetch'; +import { buildBoolArray } from '../../../server/lib/build_bool_array'; + +export const timelion = () => ({ + name: 'timelion', + context: { + types: ['filter'], + }, + args: { + query: { + types: ['string'], + aliases: ['_', 'q'], + help: 'A timelion query', + default: '".es(*)"', + }, + interval: { + types: ['string'], + help: 'Bucket interval for the time series', + default: 'auto', + }, + from: { + type: ['string'], + help: 'Elasticsearch date math string for the start of the time range', + default: 'now-1y', + }, + to: { + type: ['string'], + help: 'Elasticsearch date math string for the end of the time range', + default: 'now', + }, + timezone: { + type: ['string'], + help: 'Timezone for the time range', + default: 'UTC', + }, + }, + type: 'datatable', + help: 'Use timelion to extract one or more timeseries from many sources.', + fn: (context, args, handlers) => { + // Timelion requires a time range. Use the time range from the timefilter element in the + // workpad, if it exists. Otherwise fall back on the function args. + const timeFilter = context.and.find(and => and.type === 'time'); + const range = timeFilter + ? { from: timeFilter.from, to: timeFilter.to } + : { from: args.from, to: args.to }; + + const body = { + extended: { + es: { + filter: { + bool: { + must: buildBoolArray(context.and), + }, + }, + }, + }, + sheet: [args.query], + time: { + from: range.from, + to: range.to, + interval: args.interval, + timezone: args.timezone, + }, + }; + + return fetch(`${handlers.serverUri}/api/timelion/run`, { + method: 'POST', + responseType: 'json', + headers: { + ...handlers.httpHeaders, + }, + data: body, + }).then(resp => { + const seriesList = resp.data.sheet[0].list; + + const rows = flatten( + seriesList.map(series => + series.data.map(row => ({ '@timestamp': row[0], value: row[1], label: series.label })) + ) + ); + + return { + type: 'datatable', + columns: [ + { name: '@timestamp', type: 'date' }, + { name: 'value', type: 'number' }, + { name: 'label', type: 'string' }, + ], + rows: rows, + }; + }); + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/lib/elastic_logo.js b/x-pack/plugins/canvas/canvas_plugin_src/lib/elastic_logo.js new file mode 100644 index 0000000000000..1ade7f1f269c0 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/lib/elastic_logo.js @@ -0,0 +1,2 @@ +/* eslint-disable */ +export const elasticLogo = 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgeG1sbnM6ZGM9Imh0dHA6Ly9wdXJsLm9yZy9kYy9lbGVtZW50cy8xLjEvIgogICB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIgogICB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiCiAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgdmlld0JveD0iMCAwIDI3MC42MDAwMSAyNjkuNTQ2NjYiCiAgIGhlaWdodD0iMjY5LjU0NjY2IgogICB3aWR0aD0iMjcwLjYwMDAxIgogICB4bWw6c3BhY2U9InByZXNlcnZlIgogICBpZD0ic3ZnMiIKICAgdmVyc2lvbj0iMS4xIj48bWV0YWRhdGEKICAgICBpZD0ibWV0YWRhdGE4Ij48cmRmOlJERj48Y2M6V29yawogICAgICAgICByZGY6YWJvdXQ9IiI+PGRjOmZvcm1hdD5pbWFnZS9zdmcreG1sPC9kYzpmb3JtYXQ+PGRjOnR5cGUKICAgICAgICAgICByZGY6cmVzb3VyY2U9Imh0dHA6Ly9wdXJsLm9yZy9kYy9kY21pdHlwZS9TdGlsbEltYWdlIiAvPjwvY2M6V29yaz48L3JkZjpSREY+PC9tZXRhZGF0YT48ZGVmcwogICAgIGlkPSJkZWZzNiIgLz48ZwogICAgIHRyYW5zZm9ybT0ibWF0cml4KDEuMzMzMzMzMywwLDAsLTEuMzMzMzMzMywwLDI2OS41NDY2NykiCiAgICAgaWQ9ImcxMCI+PGcKICAgICAgIHRyYW5zZm9ybT0ic2NhbGUoMC4xKSIKICAgICAgIGlkPSJnMTIiPjxwYXRoCiAgICAgICAgIGlkPSJwYXRoMTQiCiAgICAgICAgIHN0eWxlPSJmaWxsOiNmZmZmZmY7ZmlsbC1vcGFjaXR5OjE7ZmlsbC1ydWxlOm5vbnplcm87c3Ryb2tlOm5vbmUiCiAgICAgICAgIGQ9Im0gMjAyOS40OCw5NjIuNDQxIGMgMCwxNzAuMDk5IC0xMDUuNDYsMzE4Ljc5OSAtMjY0LjE3LDM3Ni42NTkgNi45OCwzNS44NiAxMC42Miw3MS43MSAxMC42MiwxMDkuMDUgMCwzMTYuMTkgLTI1Ny4yNCw1NzMuNDMgLTU3My40Nyw1NzMuNDMgLTE4NC43MiwwIC0zNTYuNTU4LC04OC41OSAtNDY0LjUzLC0yMzcuODUgLTUzLjA5LDQxLjE4IC0xMTguMjg1LDYzLjc1IC0xODYuMzA1LDYzLjc1IC0xNjcuODM2LDAgLTMwNC4zODMsLTEzNi41NCAtMzA0LjM4MywtMzA0LjM4IDAsLTM3LjA4IDYuNjE3LC03Mi41OCAxOS4wMzEsLTEwNi4wOCBDIDEwOC40ODgsMTM4MC4wOSAwLDEyMjcuODkgMCwxMDU4Ljg4IDAsODg3LjkxIDEwNS45NzcsNzM4LjUzOSAyNjUuMzk4LDY4MS4wOSBjIC02Ljc2OSwtMzUuNDQyIC0xMC40NiwtNzIuMDIgLTEwLjQ2LC0xMDkgQyAyNTQuOTM4LDI1Ni42MjEgNTExLjU2NiwwIDgyNy4wMjcsMCAxMDEyLjIsMCAxMTgzLjk0LDg4Ljk0MTQgMTI5MS4zLDIzOC44MzIgYyA1My40NSwtNDEuOTYxIDExOC44LC02NC45OTIgMTg2LjU2LC02NC45OTIgMTY3LjgzLDAgMzA0LjM4LDEzNi40OTIgMzA0LjM4LDMwNC4zMzIgMCwzNy4wNzggLTYuNjIsNzIuNjI5IC0xOS4wMywxMDYuMTI5IDE1Ny43OCw1Ni44NzkgMjY2LjI3LDIwOS4xMjkgMjY2LjI3LDM3OC4xNCIgLz48cGF0aAogICAgICAgICBpZD0icGF0aDE2IgogICAgICAgICBzdHlsZT0iZmlsbDojZmFjZjA5O2ZpbGwtb3BhY2l0eToxO2ZpbGwtcnVsZTpub256ZXJvO3N0cm9rZTpub25lIgogICAgICAgICBkPSJtIDc5Ny44OTgsMTE1MC45MyA0NDQuMDcyLC0yMDIuNDUgNDQ4LjA1LDM5Mi41OCBjIDYuNDksMzIuMzkgOS42Niw2NC42NyA5LjY2LDk4LjQ2IDAsMjc2LjIzIC0yMjQuNjgsNTAwLjk1IC01MDAuOSw1MDAuOTUgLTE2NS4yNCwwIC0zMTkuMzcsLTgxLjM2IC00MTMuMDUzLC0yMTcuNzkgbCAtNzQuNTI0LC0zODYuNjQgODYuNjk1LC0xODUuMTEiIC8+PHBhdGgKICAgICAgICAgaWQ9InBhdGgxOCIKICAgICAgICAgc3R5bGU9ImZpbGw6IzQ5YzFhZTtmaWxsLW9wYWNpdHk6MTtmaWxsLXJ1bGU6bm9uemVybztzdHJva2U6bm9uZSIKICAgICAgICAgZD0ibSAzMzguMjIzLDY4MC42NzIgYyAtNi40ODksLTMyLjM4MyAtOS44MDksLTY1Ljk4MSAtOS44MDksLTk5Ljk3MyAwLC0yNzYuOTI5IDIyNS4zMzYsLTUwMi4yNTc2IDUwMi4zMTMsLTUwMi4yNTc2IDE2Ni41OTMsMCAzMjEuNDczLDgyLjExNzYgNDE1LjAxMywyMTkuOTQ5NiBsIDczLjk3LDM4NS4zNDcgLTk4LjcyLDE4OC42MjEgTCA3NzUuMTU2LDEwNzUuNTcgMzM4LjIyMyw2ODAuNjcyIiAvPjxwYXRoCiAgICAgICAgIGlkPSJwYXRoMjAiCiAgICAgICAgIHN0eWxlPSJmaWxsOiNlZjI5OWI7ZmlsbC1vcGFjaXR5OjE7ZmlsbC1ydWxlOm5vbnplcm87c3Ryb2tlOm5vbmUiCiAgICAgICAgIGQ9Im0gMzM1LjQxLDE0NDkuMTggMzA0LjMzMiwtNzEuODYgNjYuNjgsMzQ2LjAyIGMgLTQxLjU4NiwzMS43OCAtOTIuOTMsNDkuMTggLTE0NS43MzEsNDkuMTggLTEzMi4yNSwwIC0yMzkuODEyLC0xMDcuNjEgLTIzOS44MTIsLTIzOS44NyAwLC0yOS4yMSA0Ljg3OSwtNTcuMjIgMTQuNTMxLC04My40NyIgLz48cGF0aAogICAgICAgICBpZD0icGF0aDIyIgogICAgICAgICBzdHlsZT0iZmlsbDojNGNhYmU0O2ZpbGwtb3BhY2l0eToxO2ZpbGwtcnVsZTpub256ZXJvO3N0cm9rZTpub25lIgogICAgICAgICBkPSJNIDMwOC45OTIsMTM3Ni43IEMgMTczLjAyLDEzMzEuNjQgNzguNDgwNSwxMjAxLjMgNzguNDgwNSwxMDU3LjkzIDc4LjQ4MDUsOTE4LjM0IDE2NC44Miw3OTMuNjggMjk0LjQwNiw3NDQuMzUyIGwgNDI2Ljk4MSwzODUuOTM4IC03OC4zOTUsMTY3LjUxIC0zMzQsNzguOSIgLz48cGF0aAogICAgICAgICBpZD0icGF0aDI0IgogICAgICAgICBzdHlsZT0iZmlsbDojODVjZTI2O2ZpbGwtb3BhY2l0eToxO2ZpbGwtcnVsZTpub256ZXJvO3N0cm9rZTpub25lIgogICAgICAgICBkPSJtIDEzMjMuOCwyOTguNDEgYyA0MS43NCwtMzIuMDkgOTIuODMsLTQ5LjU5IDE0NC45OCwtNDkuNTkgMTMyLjI1LDAgMjM5LjgxLDEwNy41NTkgMjM5LjgxLDIzOS44MjEgMCwyOS4xNiAtNC44OCw1Ny4xNjggLTE0LjUzLDgzLjQxOCBsIC0zMDQuMDgsNzEuMTYgLTY2LjE4LC0zNDQuODA5IiAvPjxwYXRoCiAgICAgICAgIGlkPSJwYXRoMjYiCiAgICAgICAgIHN0eWxlPSJmaWxsOiMzMTc3YTc7ZmlsbC1vcGFjaXR5OjE7ZmlsbC1ydWxlOm5vbnplcm87c3Ryb2tlOm5vbmUiCiAgICAgICAgIGQ9Im0gMTM4NS42Nyw3MjIuOTMgMzM0Ljc2LC03OC4zMDEgYyAxMzYuMDIsNDQuOTYxIDIzMC41NiwxNzUuMzUxIDIzMC41NiwzMTguNzYyIDAsMTM5LjMzOSAtODYuNTQsMjYzLjg1OSAtMjE2LjM4LDMxMy4wMzkgbCAtNDM3Ljg0LC0zODMuNTkgODguOSwtMTY5LjkxIiAvPjwvZz48L2c+PC9zdmc+'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/lib/elastic_outline.js b/x-pack/plugins/canvas/canvas_plugin_src/lib/elastic_outline.js new file mode 100644 index 0000000000000..7271f5b32d547 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/lib/elastic_outline.js @@ -0,0 +1,2 @@ +/* eslint-disable */ +export const elasticOutline = 'data:image/svg+xml,%3C%3Fxml%20version%3D%221.0%22%20encoding%3D%22utf-8%22%3F%3E%0A%3Csvg%20viewBox%3D%22-3.948730230331421%20-1.7549896240234375%20245.25946044921875%20241.40370178222656%22%20width%3D%22245.25946044921875%22%20height%3D%22241.40370178222656%22%20style%3D%22enable-background%3Anew%200%200%20686.2%20235.7%3B%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%3Cdefs%3E%0A%20%20%20%20%3Cstyle%20type%3D%22text%2Fcss%22%3E%0A%09.st0%7Bfill%3A%232D2D2D%3B%7D%0A%3C%2Fstyle%3E%0A%20%20%3C%2Fdefs%3E%0A%20%20%3Cg%20transform%3D%22matrix%281%2C%200%2C%200%2C%201%2C%200%2C%200%29%22%3E%0A%20%20%20%20%3Cg%3E%0A%20%20%20%20%20%20%3Cpath%20class%3D%22st0%22%20d%3D%22M329.4%2C160.3l4.7-0.5l0.3%2C9.6c-12.4%2C1.7-23%2C2.6-31.8%2C2.6c-11.7%2C0-20-3.4-24.9-10.2%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bc-4.9-6.8-7.3-17.4-7.3-31.7c0-28.6%2C11.4-42.9%2C34.1-42.9c11%2C0%2C19.2%2C3.1%2C24.6%2C9.2c5.4%2C6.1%2C8.1%2C15.8%2C8.1%2C28.9l-0.7%2C9.3h-53.8%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bc0%2C9%2C1.6%2C15.7%2C4.9%2C20c3.3%2C4.3%2C8.9%2C6.5%2C17%2C6.5C312.8%2C161.2%2C321.1%2C160.9%2C329.4%2C160.3z%20M325%2C124.9c0-10-1.6-17.1-4.8-21.2%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bc-3.2-4.1-8.4-6.2-15.6-6.2c-7.2%2C0-12.7%2C2.2-16.3%2C6.5c-3.6%2C4.3-5.5%2C11.3-5.6%2C20.9H325z%22%2F%3E%0A%20%20%20%20%20%20%3Cpath%20class%3D%22st0%22%20d%3D%22M354.3%2C171.4V64h12.2v107.4H354.3z%22%2F%3E%0A%20%20%20%20%20%20%3Cpath%20class%3D%22st0%22%20d%3D%22M443.5%2C113.5v41.1c0%2C4.1%2C10.1%2C3.9%2C10.1%2C3.9l-0.6%2C10.8c-8.6%2C0-15.7%2C0.7-20-3.4c-9.8%2C4.3-19.5%2C6.1-29.3%2C6.1%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bc-7.5%2C0-13.2-2.1-17.1-6.4c-3.9-4.2-5.9-10.3-5.9-18.3c0-7.9%2C2-13.8%2C6-17.5c4-3.7%2C10.3-6.1%2C18.9-6.9l25.6-2.4v-7%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bc0-5.5-1.2-9.5-3.6-11.9c-2.4-2.4-5.7-3.6-9.8-3.6l-32.1%2C0V87.2h31.3c9.2%2C0%2C15.9%2C2.1%2C20.1%2C6.4C441.4%2C97.8%2C443.5%2C104.5%2C443.5%2C113.5%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bz%20M393.3%2C146.7c0%2C10%2C4.1%2C15%2C12.4%2C15c7.4%2C0%2C14.7-1.2%2C21.8-3.7l3.7-1.3v-26.9l-24.1%2C2.3c-4.9%2C0.4-8.4%2C1.8-10.6%2C4.2%26%2310%3B%26%239%3B%26%239%3B%26%239%3BC394.4%2C138.7%2C393.3%2C142.2%2C393.3%2C146.7z%22%2F%3E%0A%20%20%20%20%20%20%3Cpath%20class%3D%22st0%22%20d%3D%22M491.2%2C98.2c-11.8%2C0-17.8%2C4.1-17.8%2C12.4c0%2C3.8%2C1.4%2C6.5%2C4.1%2C8.1c2.7%2C1.6%2C8.9%2C3.2%2C18.6%2C4.9%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bc9.7%2C1.7%2C16.5%2C4%2C20.5%2C7.1c4%2C3%2C6%2C8.7%2C6%2C17.1c0%2C8.4-2.7%2C14.5-8.1%2C18.4c-5.4%2C3.9-13.2%2C5.9-23.6%2C5.9c-6.7%2C0-29.2-2.5-29.2-2.5%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bl0.7-10.6c12.9%2C1.2%2C22.3%2C2.2%2C28.6%2C2.2c6.3%2C0%2C11.1-1%2C14.4-3c3.3-2%2C5-5.4%2C5-10.1c0-4.7-1.4-7.9-4.2-9.6c-2.8-1.7-9-3.3-18.6-4.8%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bc-9.6-1.5-16.4-3.7-20.4-6.7c-4-2.9-6-8.4-6-16.3c0-7.9%2C2.8-13.8%2C8.4-17.6c5.6-3.8%2C12.6-5.7%2C20.9-5.7c6.6%2C0%2C29.6%2C1.7%2C29.6%2C1.7%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bv10.7C508.1%2C99%2C498.2%2C98.2%2C491.2%2C98.2z%22%2F%3E%0A%20%20%20%20%20%20%3Cpath%20class%3D%22st0%22%20d%3D%22M581.7%2C99.5h-25.9v39c0%2C9.3%2C0.7%2C15.5%2C2%2C18.4c1.4%2C2.9%2C4.6%2C4.4%2C9.7%2C4.4l14.5-1l0.8%2C10.1%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bc-7.3%2C1.2-12.8%2C1.8-16.6%2C1.8c-8.5%2C0-14.3-2.1-17.6-6.2c-3.3-4.1-4.9-12-4.9-23.6V99.5h-11.6V88.9h11.6V63.9h12.1v24.9h25.9V99.5z%22%2F%3E%0A%20%20%20%20%20%20%3Cpath%20class%3D%22st0%22%20d%3D%22M598.7%2C78.4V64.3h12.2v14.2H598.7z%20M598.7%2C171.4V88.9h12.2v82.5H598.7z%22%2F%3E%0A%20%20%20%20%20%20%3Cpath%20class%3D%22st0%22%20d%3D%22M663.8%2C87.2c3.6%2C0%2C9.7%2C0.7%2C18.3%2C2l3.9%2C0.5l-0.5%2C9.9c-8.7-1-15.1-1.5-19.2-1.5c-9.2%2C0-15.5%2C2.2-18.8%2C6.6%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bc-3.3%2C4.4-5%2C12.6-5%2C24.5c0%2C11.9%2C1.5%2C20.2%2C4.6%2C24.9c3.1%2C4.7%2C9.5%2C7%2C19.3%2C7l19.2-1.5l0.5%2C10.1c-10.1%2C1.5-17.7%2C2.3-22.7%2C2.3%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bc-12.7%2C0-21.5-3.3-26.3-9.8c-4.8-6.5-7.3-17.5-7.3-33c0-15.5%2C2.6-26.4%2C7.8-32.6C643%2C90.4%2C651.7%2C87.2%2C663.8%2C87.2z%22%2F%3E%0A%20%20%20%20%3C%2Fg%3E%0A%20%20%20%20%3Cpath%20class%3D%22st0%22%20d%3D%22M236.6%2C123.5c0-19.8-12.3-37.2-30.8-43.9c0.8-4.2%2C1.2-8.4%2C1.2-12.7C207%2C30%2C177%2C0%2C140.2%2C0%26%2310%3B%26%239%3B%26%239%3BC118.6%2C0%2C98.6%2C10.3%2C86%2C27.7c-6.2-4.8-13.8-7.4-21.7-7.4c-19.6%2C0-35.5%2C15.9-35.5%2C35.5c0%2C4.3%2C0.8%2C8.5%2C2.2%2C12.4%26%2310%3B%26%239%3B%26%239%3BC12.6%2C74.8%2C0%2C92.5%2C0%2C112.2c0%2C19.9%2C12.4%2C37.3%2C30.9%2C44c-0.8%2C4.1-1.2%2C8.4-1.2%2C12.7c0%2C36.8%2C29.9%2C66.7%2C66.7%2C66.7%26%2310%3B%26%239%3B%26%239%3Bc21.6%2C0%2C41.6-10.4%2C54.1-27.8c6.2%2C4.9%2C13.8%2C7.6%2C21.7%2C7.6c19.6%2C0%2C35.5-15.9%2C35.5-35.5c0-4.3-0.8-8.5-2.2-12.4%26%2310%3B%26%239%3B%26%239%3BC223.9%2C160.9%2C236.6%2C143.2%2C236.6%2C123.5z%20M91.6%2C34.8c10.9-15.9%2C28.9-25.4%2C48.1-25.4c32.2%2C0%2C58.4%2C26.2%2C58.4%2C58.4%26%2310%3B%26%239%3B%26%239%3Bc0%2C3.9-0.4%2C7.7-1.1%2C11.5l-52.2%2C45.8L93%2C101.5L82.9%2C79.9L91.6%2C34.8z%20M65.4%2C29c6.2%2C0%2C12.1%2C2%2C17%2C5.7l-7.8%2C40.3l-35.5-8.4%26%2310%3B%26%239%3B%26%239%3Bc-1.1-3.1-1.7-6.3-1.7-9.7C37.4%2C41.6%2C49.9%2C29%2C65.4%2C29z%20M9.1%2C112.3c0-16.7%2C11-31.9%2C26.9-37.2L75%2C84.4l9.1%2C19.5l-49.8%2C45%26%2310%3B%26%239%3B%26%239%3BC19.2%2C143.1%2C9.1%2C128.6%2C9.1%2C112.3z%20M145.2%2C200.9c-10.9%2C16.1-29%2C25.6-48.4%2C25.6c-32.3%2C0-58.6-26.3-58.6-58.5c0-4%2C0.4-7.9%2C1.1-11.7%26%2310%3B%26%239%3B%26%239%3Bl50.9-46l52%2C23.7l11.5%2C22L145.2%2C200.9z%20M171.2%2C206.6c-6.1%2C0-12-2-16.9-5.8l7.7-40.2l35.4%2C8.3c1.1%2C3.1%2C1.7%2C6.3%2C1.7%2C9.7%26%2310%3B%26%239%3B%26%239%3BC199.2%2C194.1%2C186.6%2C206.6%2C171.2%2C206.6z%20M200.5%2C160.5l-39-9.1l-10.4-19.8l51-44.7c15.1%2C5.7%2C25.2%2C20.2%2C25.2%2C36.5%26%2310%3B%26%239%3B%26%239%3BC227.4%2C140.1%2C216.4%2C155.3%2C200.5%2C160.5z%22%2F%3E%0A%20%20%3C%2Fg%3E%0A%3C%2Fsvg%3E'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/lib/eui.scss b/x-pack/plugins/canvas/canvas_plugin_src/lib/eui.scss new file mode 100644 index 0000000000000..fec7dc41a902d --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/lib/eui.scss @@ -0,0 +1,7 @@ +// Making variables available to plugins + +@import '~@elastic/eui/src/themes/k6/k6_globals'; +@import '~@elastic/eui/src/themes/k6/k6_colors_light'; +@import '~@elastic/eui/src/global_styling/functions/index'; +@import '~@elastic/eui/src/global_styling/variables/index'; +@import '~@elastic/eui/src/global_styling/mixins/index'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/.eslintrc b/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/.eslintrc new file mode 100644 index 0000000000000..2a22ef440bd44 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/.eslintrc @@ -0,0 +1,2 @@ +env: + jquery: true \ No newline at end of file diff --git a/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/API.md b/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/API.md new file mode 100644 index 0000000000000..cd3927b4b9df0 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/API.md @@ -0,0 +1,1498 @@ +# Flot Reference # + +**Table of Contents** + +[Introduction](#introduction) +| [Data Format](#data-format) +| [Plot Options](#plot-options) +| [Customizing the legend](#customizing-the-legend) +| [Customizing the axes](#customizing-the-axes) +| [Multiple axes](#multiple-axes) +| [Time series data](#time-series-data) +| [Customizing the data series](#customizing-the-data-series) +| [Customizing the grid](#customizing-the-grid) +| [Specifying gradients](#specifying-gradients) +| [Plot Methods](#plot-methods) +| [Hooks](#hooks) +| [Plugins](#plugins) +| [Version number](#version-number) + +--- + +## Introduction ## + +Consider a call to the plot function: + +```js +var plot = $.plot(placeholder, data, options) +``` + +The placeholder is a jQuery object or DOM element or jQuery expression +that the plot will be put into. This placeholder needs to have its +width and height set as explained in the [README](README.md) (go read that now if +you haven't, it's short). The plot will modify some properties of the +placeholder so it's recommended you simply pass in a div that you +don't use for anything else. Make sure you check any fancy styling +you apply to the div, e.g. background images have been reported to be a +problem on IE 7. + +The plot function can also be used as a jQuery chainable property. This form +naturally can't return the plot object directly, but you can still access it +via the 'plot' data key, like this: + +```js +var plot = $("#placeholder").plot(data, options).data("plot"); +``` + +The format of the data is documented below, as is the available +options. The plot object returned from the call has some methods you +can call. These are documented separately below. + +Note that in general Flot gives no guarantees if you change any of the +objects you pass in to the plot function or get out of it since +they're not necessarily deep-copied. + + +## Data Format ## + +The data is an array of data series: + +```js +[ series1, series2, ... ] +``` + +A series can either be raw data or an object with properties. The raw +data format is an array of points: + +```js +[ [x1, y1], [x2, y2], ... ] +``` + +E.g. + +```js +[ [1, 3], [2, 14.01], [3.5, 3.14] ] +``` + +Note that to simplify the internal logic in Flot both the x and y +values must be numbers (even if specifying time series, see below for +how to do this). This is a common problem because you might retrieve +data from the database and serialize them directly to JSON without +noticing the wrong type. If you're getting mysterious errors, double +check that you're inputting numbers and not strings. + +If a null is specified as a point or if one of the coordinates is null +or couldn't be converted to a number, the point is ignored when +drawing. As a special case, a null value for lines is interpreted as a +line segment end, i.e. the points before and after the null value are +not connected. + +Lines and points take two coordinates. For filled lines and bars, you +can specify a third coordinate which is the bottom of the filled +area/bar (defaults to 0). + +The format of a single series object is as follows: + +```js +{ + color: color or number + data: rawdata + label: string + lines: specific lines options + bars: specific bars options + points: specific points options + xaxis: number + yaxis: number + clickable: boolean + hoverable: boolean + shadowSize: number + highlightColor: color or number +} +``` + +You don't have to specify any of them except the data, the rest are +options that will get default values. Typically you'd only specify +label and data, like this: + +```js +{ + label: "y = 3", + data: [[0, 3], [10, 3]] +} +``` + +The label is used for the legend, if you don't specify one, the series +will not show up in the legend. + +If you don't specify color, the series will get a color from the +auto-generated colors. The color is either a CSS color specification +(like "rgb(255, 100, 123)") or an integer that specifies which of +auto-generated colors to select, e.g. 0 will get color no. 0, etc. + +The latter is mostly useful if you let the user add and remove series, +in which case you can hard-code the color index to prevent the colors +from jumping around between the series. + +The "xaxis" and "yaxis" options specify which axis to use. The axes +are numbered from 1 (default), so { yaxis: 2} means that the series +should be plotted against the second y axis. + +"clickable" and "hoverable" can be set to false to disable +interactivity for specific series if interactivity is turned on in +the plot, see below. + +The rest of the options are all documented below as they are the same +as the default options passed in via the options parameter in the plot +command. When you specify them for a specific data series, they will +override the default options for the plot for that data series. + +Here's a complete example of a simple data specification: + +```js +[ { label: "Foo", data: [ [10, 1], [17, -14], [30, 5] ] }, + { label: "Bar", data: [ [11, 13], [19, 11], [30, -7] ] } +] +``` + + +## Plot Options ## + +All options are completely optional. They are documented individually +below, to change them you just specify them in an object, e.g. + +```js +var options = { + series: { + lines: { show: true }, + points: { show: true } + } +}; + +$.plot(placeholder, data, options); +``` + + +## Customizing the legend ## + +```js +legend: { + show: boolean + labelFormatter: null or (fn: string, series object -> string) + labelBoxBorderColor: color + noColumns: number + position: "ne" or "nw" or "se" or "sw" + margin: number of pixels or [x margin, y margin] + backgroundColor: null or color + backgroundOpacity: number between 0 and 1 + container: null or jQuery object/DOM element/jQuery expression + sorted: null/false, true, "ascending", "descending", "reverse", or a comparator +} +``` + +The legend is generated as a table with the data series labels and +small label boxes with the color of the series. If you want to format +the labels in some way, e.g. make them to links, you can pass in a +function for "labelFormatter". Here's an example that makes them +clickable: + +```js +labelFormatter: function(label, series) { + // series is the series object for the label + return '' + label + ''; +} +``` + +To prevent a series from showing up in the legend, simply have the function +return null. + +"noColumns" is the number of columns to divide the legend table into. +"position" specifies the overall placement of the legend within the +plot (top-right, top-left, etc.) and margin the distance to the plot +edge (this can be either a number or an array of two numbers like [x, +y]). "backgroundColor" and "backgroundOpacity" specifies the +background. The default is a partly transparent auto-detected +background. + +If you want the legend to appear somewhere else in the DOM, you can +specify "container" as a jQuery object/expression to put the legend +table into. The "position" and "margin" etc. options will then be +ignored. Note that Flot will overwrite the contents of the container. + +Legend entries appear in the same order as their series by default. If "sorted" +is "reverse" then they appear in the opposite order from their series. To sort +them alphabetically, you can specify true, "ascending" or "descending", where +true and "ascending" are equivalent. + +You can also provide your own comparator function that accepts two +objects with "label" and "color" properties, and returns zero if they +are equal, a positive value if the first is greater than the second, +and a negative value if the first is less than the second. + +```js +sorted: function(a, b) { + // sort alphabetically in ascending order + return a.label == b.label ? 0 : ( + a.label > b.label ? 1 : -1 + ) +} +``` + + +## Customizing the axes ## + +```js +xaxis, yaxis: { + show: null or true/false + position: "bottom" or "top" or "left" or "right" + mode: null or "time" ("time" requires jquery.flot.time.js plugin) + timezone: null, "browser" or timezone (only makes sense for mode: "time") + + color: null or color spec + tickColor: null or color spec + font: null or font spec object + + min: null or number + max: null or number + autoscaleMargin: null or number + + transform: null or fn: number -> number + inverseTransform: null or fn: number -> number + + ticks: null or number or ticks array or (fn: axis -> ticks array) + tickSize: number or array + minTickSize: number or array + tickFormatter: (fn: number, object -> string) or string + tickDecimals: null or number + + labelWidth: null or number + labelHeight: null or number + reserveSpace: null or true + + tickLength: null or number + + alignTicksWithAxis: null or number +} +``` + +All axes have the same kind of options. The following describes how to +configure one axis, see below for what to do if you've got more than +one x axis or y axis. + +If you don't set the "show" option (i.e. it is null), visibility is +auto-detected, i.e. the axis will show up if there's data associated +with it. You can override this by setting the "show" option to true or +false. + +The "position" option specifies where the axis is placed, bottom or +top for x axes, left or right for y axes. The "mode" option determines +how the data is interpreted, the default of null means as decimal +numbers. Use "time" for time series data; see the time series data +section. The time plugin (jquery.flot.time.js) is required for time +series support. + +The "color" option determines the color of the line and ticks for the axis, and +defaults to the grid color with transparency. For more fine-grained control you +can also set the color of the ticks separately with "tickColor". + +You can customize the font and color used to draw the axis tick labels with CSS +or directly via the "font" option. When "font" is null - the default - each +tick label is given the 'flot-tick-label' class. For compatibility with Flot +0.7 and earlier the labels are also given the 'tickLabel' class, but this is +deprecated and scheduled to be removed with the release of version 1.0.0. + +To enable more granular control over styles, labels are divided between a set +of text containers, with each holding the labels for one axis. These containers +are given the classes 'flot-[x|y]-axis', and 'flot-[x|y]#-axis', where '#' is +the number of the axis when there are multiple axes. For example, the x-axis +labels for a simple plot with only a single x-axis might look like this: + +```html +
+
January 2013
+ ... +
+``` + +For direct control over label styles you can also provide "font" as an object +with this format: + +```js +{ + size: 11, + lineHeight: 13, + style: "italic", + weight: "bold", + family: "sans-serif", + variant: "small-caps", + color: "#545454" +} +``` + +The size and lineHeight must be expressed in pixels; CSS units such as 'em' +or 'smaller' are not allowed. + +The options "min"/"max" are the precise minimum/maximum value on the +scale. If you don't specify either of them, a value will automatically +be chosen based on the minimum/maximum data values. Note that Flot +always examines all the data values you feed to it, even if a +restriction on another axis may make some of them invisible (this +makes interactive use more stable). + +The "autoscaleMargin" is a bit esoteric: it's the fraction of margin +that the scaling algorithm will add to avoid that the outermost points +ends up on the grid border. Note that this margin is only applied when +a min or max value is not explicitly set. If a margin is specified, +the plot will furthermore extend the axis end-point to the nearest +whole tick. The default value is "null" for the x axes and 0.02 for y +axes which seems appropriate for most cases. + +"transform" and "inverseTransform" are callbacks you can put in to +change the way the data is drawn. You can design a function to +compress or expand certain parts of the axis non-linearly, e.g. +suppress weekends or compress far away points with a logarithm or some +other means. When Flot draws the plot, each value is first put through +the transform function. Here's an example, the x axis can be turned +into a natural logarithm axis with the following code: + +```js +xaxis: { + transform: function (v) { return Math.log(v); }, + inverseTransform: function (v) { return Math.exp(v); } +} +``` + +Similarly, for reversing the y axis so the values appear in inverse +order: + +```js +yaxis: { + transform: function (v) { return -v; }, + inverseTransform: function (v) { return -v; } +} +``` + +Note that for finding extrema, Flot assumes that the transform +function does not reorder values (it should be monotone). + +The inverseTransform is simply the inverse of the transform function +(so v == inverseTransform(transform(v)) for all relevant v). It is +required for converting from canvas coordinates to data coordinates, +e.g. for a mouse interaction where a certain pixel is clicked. If you +don't use any interactive features of Flot, you may not need it. + + +The rest of the options deal with the ticks. + +If you don't specify any ticks, a tick generator algorithm will make +some for you. The algorithm has two passes. It first estimates how +many ticks would be reasonable and uses this number to compute a nice +round tick interval size. Then it generates the ticks. + +You can specify how many ticks the algorithm aims for by setting +"ticks" to a number. The algorithm always tries to generate reasonably +round tick values so even if you ask for three ticks, you might get +five if that fits better with the rounding. If you don't want any +ticks at all, set "ticks" to 0 or an empty array. + +Another option is to skip the rounding part and directly set the tick +interval size with "tickSize". If you set it to 2, you'll get ticks at +2, 4, 6, etc. Alternatively, you can specify that you just don't want +ticks at a size less than a specific tick size with "minTickSize". +Note that for time series, the format is an array like [2, "month"], +see the next section. + +If you want to completely override the tick algorithm, you can specify +an array for "ticks", either like this: + +```js +ticks: [0, 1.2, 2.4] +``` + +Or like this where the labels are also customized: + +```js +ticks: [[0, "zero"], [1.2, "one mark"], [2.4, "two marks"]] +``` + +You can mix the two if you like. + +For extra flexibility you can specify a function as the "ticks" +parameter. The function will be called with an object with the axis +min and max and should return a ticks array. Here's a simplistic tick +generator that spits out intervals of pi, suitable for use on the x +axis for trigonometric functions: + +```js +function piTickGenerator(axis) { + var res = [], i = Math.floor(axis.min / Math.PI); + do { + var v = i * Math.PI; + res.push([v, i + "\u03c0"]); + ++i; + } while (v < axis.max); + return res; +} +``` + +You can control how the ticks look like with "tickDecimals", the +number of decimals to display (default is auto-detected). + +Alternatively, for ultimate control over how ticks are formatted you can +provide a function to "tickFormatter". The function is passed two +parameters, the tick value and an axis object with information, and +should return a string. The default formatter looks like this: + +```js +function formatter(val, axis) { + return val.toFixed(axis.tickDecimals); +} +``` + +The axis object has "min" and "max" with the range of the axis, +"tickDecimals" with the number of decimals to round the value to and +"tickSize" with the size of the interval between ticks as calculated +by the automatic axis scaling algorithm (or specified by you). Here's +an example of a custom formatter: + +```js +function suffixFormatter(val, axis) { + if (val > 1000000) + return (val / 1000000).toFixed(axis.tickDecimals) + " MB"; + else if (val > 1000) + return (val / 1000).toFixed(axis.tickDecimals) + " kB"; + else + return val.toFixed(axis.tickDecimals) + " B"; +} +``` + +"labelWidth" and "labelHeight" specifies a fixed size of the tick +labels in pixels. They're useful in case you need to align several +plots. "reserveSpace" means that even if an axis isn't shown, Flot +should reserve space for it - it is useful in combination with +labelWidth and labelHeight for aligning multi-axis charts. + +"tickLength" is the length of the tick lines in pixels. By default, the +innermost axes will have ticks that extend all across the plot, while +any extra axes use small ticks. A value of null means use the default, +while a number means small ticks of that length - set it to 0 to hide +the lines completely. + +If you set "alignTicksWithAxis" to the number of another axis, e.g. +alignTicksWithAxis: 1, Flot will ensure that the autogenerated ticks +of this axis are aligned with the ticks of the other axis. This may +improve the looks, e.g. if you have one y axis to the left and one to +the right, because the grid lines will then match the ticks in both +ends. The trade-off is that the forced ticks won't necessarily be at +natural places. + + +## Multiple axes ## + +If you need more than one x axis or y axis, you need to specify for +each data series which axis they are to use, as described under the +format of the data series, e.g. { data: [...], yaxis: 2 } specifies +that a series should be plotted against the second y axis. + +To actually configure that axis, you can't use the xaxis/yaxis options +directly - instead there are two arrays in the options: + +```js +xaxes: [] +yaxes: [] +``` + +Here's an example of configuring a single x axis and two y axes (we +can leave options of the first y axis empty as the defaults are fine): + +```js +{ + xaxes: [ { position: "top" } ], + yaxes: [ { }, { position: "right", min: 20 } ] +} +``` + +The arrays get their default values from the xaxis/yaxis settings, so +say you want to have all y axes start at zero, you can simply specify +yaxis: { min: 0 } instead of adding a min parameter to all the axes. + +Generally, the various interfaces in Flot dealing with data points +either accept an xaxis/yaxis parameter to specify which axis number to +use (starting from 1), or lets you specify the coordinate directly as +x2/x3/... or x2axis/x3axis/... instead of "x" or "xaxis". + + +## Time series data ## + +Please note that it is now required to include the time plugin, +jquery.flot.time.js, for time series support. + +Time series are a bit more difficult than scalar data because +calendars don't follow a simple base 10 system. For many cases, Flot +abstracts most of this away, but it can still be a bit difficult to +get the data into Flot. So we'll first discuss the data format. + +The time series support in Flot is based on Javascript timestamps, +i.e. everywhere a time value is expected or handed over, a Javascript +timestamp number is used. This is a number, not a Date object. A +Javascript timestamp is the number of milliseconds since January 1, +1970 00:00:00 UTC. This is almost the same as Unix timestamps, except it's +in milliseconds, so remember to multiply by 1000! + +You can see a timestamp like this + +```js +alert((new Date()).getTime()) +``` + +There are different schools of thought when it comes to display of +timestamps. Many will want the timestamps to be displayed according to +a certain time zone, usually the time zone in which the data has been +produced. Some want the localized experience, where the timestamps are +displayed according to the local time of the visitor. Flot supports +both. Optionally you can include a third-party library to get +additional timezone support. + +Default behavior is that Flot always displays timestamps according to +UTC. The reason being that the core Javascript Date object does not +support other fixed time zones. Often your data is at another time +zone, so it may take a little bit of tweaking to work around this +limitation. + +The easiest way to think about it is to pretend that the data +production time zone is UTC, even if it isn't. So if you have a +datapoint at 2002-02-20 08:00, you can generate a timestamp for eight +o'clock UTC even if it really happened eight o'clock UTC+0200. + +In PHP you can get an appropriate timestamp with: + +```php +strtotime("2002-02-20 UTC") * 1000 +``` + +In Python you can get it with something like: + +```python +calendar.timegm(datetime_object.timetuple()) * 1000 +``` +In Ruby you can get it using the `#to_i` method on the +[`Time`](http://apidock.com/ruby/Time/to_i) object. If you're using the +`active_support` gem (default for Ruby on Rails applications) `#to_i` is also +available on the `DateTime` and `ActiveSupport::TimeWithZone` objects. You +simply need to multiply the result by 1000: + +```ruby +Time.now.to_i * 1000 # => 1383582043000 +# ActiveSupport examples: +DateTime.now.to_i * 1000 # => 1383582043000 +ActiveSupport::TimeZone.new('Asia/Shanghai').now.to_i * 1000 +# => 1383582043000 +``` + +In .NET you can get it with something like: + +```aspx +public static int GetJavascriptTimestamp(System.DateTime input) +{ + System.TimeSpan span = new System.TimeSpan(System.DateTime.Parse("1/1/1970").Ticks); + System.DateTime time = input.Subtract(span); + return (long)(time.Ticks / 10000); +} +``` + +Javascript also has some support for parsing date strings, so it is +possible to generate the timestamps manually client-side. + +If you've already got the real UTC timestamp, it's too late to use the +pretend trick described above. But you can fix up the timestamps by +adding the time zone offset, e.g. for UTC+0200 you would add 2 hours +to the UTC timestamp you got. Then it'll look right on the plot. Most +programming environments have some means of getting the timezone +offset for a specific date (note that you need to get the offset for +each individual timestamp to account for daylight savings). + +The alternative with core Javascript is to interpret the timestamps +according to the time zone that the visitor is in, which means that +the ticks will shift with the time zone and daylight savings of each +visitor. This behavior is enabled by setting the axis option +"timezone" to the value "browser". + +If you need more time zone functionality than this, there is still +another option. If you include the "timezone-js" library + in the page and set axis.timezone +to a value recognized by said library, Flot will use timezone-js to +interpret the timestamps according to that time zone. + +Once you've gotten the timestamps into the data and specified "time" +as the axis mode, Flot will automatically generate relevant ticks and +format them. As always, you can tweak the ticks via the "ticks" option +- just remember that the values should be timestamps (numbers), not +Date objects. + +Tick generation and formatting can also be controlled separately +through the following axis options: + +```js +minTickSize: array +timeformat: null or format string +monthNames: null or array of size 12 of strings +dayNames: null or array of size 7 of strings +twelveHourClock: boolean +``` + +Here "timeformat" is a format string to use. You might use it like +this: + +```js +xaxis: { + mode: "time", + timeformat: "%Y/%m/%d" +} +``` + +This will result in tick labels like "2000/12/24". A subset of the +standard strftime specifiers are supported (plus the nonstandard %q): + +```js +%a: weekday name (customizable) +%b: month name (customizable) +%d: day of month, zero-padded (01-31) +%e: day of month, space-padded ( 1-31) +%H: hours, 24-hour time, zero-padded (00-23) +%I: hours, 12-hour time, zero-padded (01-12) +%m: month, zero-padded (01-12) +%M: minutes, zero-padded (00-59) +%q: quarter (1-4) +%S: seconds, zero-padded (00-59) +%y: year (two digits) +%Y: year (four digits) +%p: am/pm +%P: AM/PM (uppercase version of %p) +%w: weekday as number (0-6, 0 being Sunday) +``` + +Flot 0.8 switched from %h to the standard %H hours specifier. The %h specifier +is still available, for backwards-compatibility, but is deprecated and +scheduled to be removed permanently with the release of version 1.0. + +You can customize the month names with the "monthNames" option. For +instance, for Danish you might specify: + +```js +monthNames: ["jan", "feb", "mar", "apr", "maj", "jun", "jul", "aug", "sep", "okt", "nov", "dec"] +``` + +Similarly you can customize the weekday names with the "dayNames" +option. An example in French: + +```js +dayNames: ["dim", "lun", "mar", "mer", "jeu", "ven", "sam"] +``` + +If you set "twelveHourClock" to true, the autogenerated timestamps +will use 12 hour AM/PM timestamps instead of 24 hour. This only +applies if you have not set "timeformat". Use the "%I" and "%p" or +"%P" options if you want to build your own format string with 12-hour +times. + +If the Date object has a strftime property (and it is a function), it +will be used instead of the built-in formatter. Thus you can include +a strftime library such as http://hacks.bluesmoon.info/strftime/ for +more powerful date/time formatting. + +If everything else fails, you can control the formatting by specifying +a custom tick formatter function as usual. Here's a simple example +which will format December 24 as 24/12: + +```js +tickFormatter: function (val, axis) { + var d = new Date(val); + return d.getUTCDate() + "/" + (d.getUTCMonth() + 1); +} +``` + +Note that for the time mode "tickSize" and "minTickSize" are a bit +special in that they are arrays on the form "[value, unit]" where unit +is one of "second", "minute", "hour", "day", "month" and "year". So +you can specify + +```js +minTickSize: [1, "month"] +``` + +to get a tick interval size of at least 1 month and correspondingly, +if axis.tickSize is [2, "day"] in the tick formatter, the ticks have +been produced with two days in-between. + + +## Customizing the data series ## + +```js +series: { + lines, points, bars: { + show: boolean + lineWidth: number + fill: boolean or number + fillColor: null or color/gradient + } + + lines, bars: { + zero: boolean + } + + points: { + radius: number + symbol: "circle" or function + } + + bars: { + barWidth: number + align: "left", "right" or "center" + horizontal: boolean + } + + lines: { + steps: boolean + } + + shadowSize: number + highlightColor: color or number +} + +colors: [ color1, color2, ... ] +``` + +The options inside "series: {}" are copied to each of the series. So +you can specify that all series should have bars by putting it in the +global options, or override it for individual series by specifying +bars in a particular the series object in the array of data. + +The most important options are "lines", "points" and "bars" that +specify whether and how lines, points and bars should be shown for +each data series. In case you don't specify anything at all, Flot will +default to showing lines (you can turn this off with +lines: { show: false }). You can specify the various types +independently of each other, and Flot will happily draw each of them +in turn (this is probably only useful for lines and points), e.g. + +```js +var options = { + series: { + lines: { show: true, fill: true, fillColor: "rgba(255, 255, 255, 0.8)" }, + points: { show: true, fill: false } + } +}; +``` + +"lineWidth" is the thickness of the line or outline in pixels. You can +set it to 0 to prevent a line or outline from being drawn; this will +also hide the shadow. + +"fill" is whether the shape should be filled. For lines, this produces +area graphs. You can use "fillColor" to specify the color of the fill. +If "fillColor" evaluates to false (default for everything except +points which are filled with white), the fill color is auto-set to the +color of the data series. You can adjust the opacity of the fill by +setting fill to a number between 0 (fully transparent) and 1 (fully +opaque). + +For bars, fillColor can be a gradient, see the gradient documentation +below. "barWidth" is the width of the bars in units of the x axis (or +the y axis if "horizontal" is true), contrary to most other measures +that are specified in pixels. For instance, for time series the unit +is milliseconds so 24 * 60 * 60 * 1000 produces bars with the width of +a day. "align" specifies whether a bar should be left-aligned +(default), right-aligned or centered on top of the value it represents. +When "horizontal" is on, the bars are drawn horizontally, i.e. from the +y axis instead of the x axis; note that the bar end points are still +defined in the same way so you'll probably want to swap the +coordinates if you've been plotting vertical bars first. + +Area and bar charts normally start from zero, regardless of the data's range. +This is because they convey information through size, and starting from a +different value would distort their meaning. In cases where the fill is purely +for decorative purposes, however, "zero" allows you to override this behavior. +It defaults to true for filled lines and bars; setting it to false tells the +series to use the same automatic scaling as an un-filled line. + +For lines, "steps" specifies whether two adjacent data points are +connected with a straight (possibly diagonal) line or with first a +horizontal and then a vertical line. Note that this transforms the +data by adding extra points. + +For points, you can specify the radius and the symbol. The only +built-in symbol type is circles, for other types you can use a plugin +or define them yourself by specifying a callback: + +```js +function cross(ctx, x, y, radius, shadow) { + var size = radius * Math.sqrt(Math.PI) / 2; + ctx.moveTo(x - size, y - size); + ctx.lineTo(x + size, y + size); + ctx.moveTo(x - size, y + size); + ctx.lineTo(x + size, y - size); +} +``` + +The parameters are the drawing context, x and y coordinates of the +center of the point, a radius which corresponds to what the circle +would have used and whether the call is to draw a shadow (due to +limited canvas support, shadows are currently faked through extra +draws). It's good practice to ensure that the area covered by the +symbol is the same as for the circle with the given radius, this +ensures that all symbols have approximately the same visual weight. + +"shadowSize" is the default size of shadows in pixels. Set it to 0 to +remove shadows. + +"highlightColor" is the default color of the translucent overlay used +to highlight the series when the mouse hovers over it. + +The "colors" array specifies a default color theme to get colors for +the data series from. You can specify as many colors as you like, like +this: + +```js +colors: ["#d18b2c", "#dba255", "#919733"] +``` + +If there are more data series than colors, Flot will try to generate +extra colors by lightening and darkening colors in the theme. + + +## Customizing the grid ## + +```js +grid: { + show: boolean + aboveData: boolean + color: color + backgroundColor: color/gradient or null + margin: number or margin object + labelMargin: number + axisMargin: number + markings: array of markings or (fn: axes -> array of markings) + borderWidth: number or object with "top", "right", "bottom" and "left" properties with different widths + borderColor: color or null or object with "top", "right", "bottom" and "left" properties with different colors + minBorderMargin: number or null + clickable: boolean + hoverable: boolean + autoHighlight: boolean + mouseActiveRadius: number +} + +interaction: { + redrawOverlayInterval: number or -1 +} +``` + +The grid is the thing with the axes and a number of ticks. Many of the +things in the grid are configured under the individual axes, but not +all. "color" is the color of the grid itself whereas "backgroundColor" +specifies the background color inside the grid area, here null means +that the background is transparent. You can also set a gradient, see +the gradient documentation below. + +You can turn off the whole grid including tick labels by setting +"show" to false. "aboveData" determines whether the grid is drawn +above the data or below (below is default). + +"margin" is the space in pixels between the canvas edge and the grid, +which can be either a number or an object with individual margins for +each side, in the form: + +```js +margin: { + top: top margin in pixels + left: left margin in pixels + bottom: bottom margin in pixels + right: right margin in pixels +} +``` + +"labelMargin" is the space in pixels between tick labels and axis +line, and "axisMargin" is the space in pixels between axes when there +are two next to each other. + +"borderWidth" is the width of the border around the plot. Set it to 0 +to disable the border. Set it to an object with "top", "right", +"bottom" and "left" properties to use different widths. You can +also set "borderColor" if you want the border to have a different color +than the grid lines. Set it to an object with "top", "right", "bottom" +and "left" properties to use different colors. "minBorderMargin" controls +the default minimum margin around the border - it's used to make sure +that points aren't accidentally clipped by the canvas edge so by default +the value is computed from the point radius. + +"markings" is used to draw simple lines and rectangular areas in the +background of the plot. You can either specify an array of ranges on +the form { xaxis: { from, to }, yaxis: { from, to } } (with multiple +axes, you can specify coordinates for other axes instead, e.g. as +x2axis/x3axis/...) or with a function that returns such an array given +the axes for the plot in an object as the first parameter. + +You can set the color of markings by specifying "color" in the ranges +object. Here's an example array: + +```js +markings: [ { xaxis: { from: 0, to: 2 }, yaxis: { from: 10, to: 10 }, color: "#bb0000" }, ... ] +``` + +If you leave out one of the values, that value is assumed to go to the +border of the plot. So for example if you only specify { xaxis: { +from: 0, to: 2 } } it means an area that extends from the top to the +bottom of the plot in the x range 0-2. + +A line is drawn if from and to are the same, e.g. + +```js +markings: [ { yaxis: { from: 1, to: 1 } }, ... ] +``` + +would draw a line parallel to the x axis at y = 1. You can control the +line width with "lineWidth" in the range object. + +An example function that makes vertical stripes might look like this: + +```js +markings: function (axes) { + var markings = []; + for (var x = Math.floor(axes.xaxis.min); x < axes.xaxis.max; x += 2) + markings.push({ xaxis: { from: x, to: x + 1 } }); + return markings; +} +``` + +If you set "clickable" to true, the plot will listen for click events +on the plot area and fire a "plotclick" event on the placeholder with +a position and a nearby data item object as parameters. The coordinates +are available both in the unit of the axes (not in pixels) and in +global screen coordinates. + +Likewise, if you set "hoverable" to true, the plot will listen for +mouse move events on the plot area and fire a "plothover" event with +the same parameters as the "plotclick" event. If "autoHighlight" is +true (the default), nearby data items are highlighted automatically. +If needed, you can disable highlighting and control it yourself with +the highlight/unhighlight plot methods described elsewhere. + +You can use "plotclick" and "plothover" events like this: + +```js +$.plot($("#placeholder"), [ d ], { grid: { clickable: true } }); + +$("#placeholder").bind("plotclick", function (event, pos, item) { + alert("You clicked at " + pos.x + ", " + pos.y); + // axis coordinates for other axes, if present, are in pos.x2, pos.x3, ... + // if you need global screen coordinates, they are pos.pageX, pos.pageY + + if (item) { + highlight(item.series, item.datapoint); + alert("You clicked a point!"); + } +}); +``` + +The item object in this example is either null or a nearby object on the form: + +```js +item: { + datapoint: the point, e.g. [0, 2] + dataIndex: the index of the point in the data array + series: the series object + seriesIndex: the index of the series + pageX, pageY: the global screen coordinates of the point +} +``` + +For instance, if you have specified the data like this + +```js +$.plot($("#placeholder"), [ { label: "Foo", data: [[0, 10], [7, 3]] } ], ...); +``` + +and the mouse is near the point (7, 3), "datapoint" is [7, 3], +"dataIndex" will be 1, "series" is a normalized series object with +among other things the "Foo" label in series.label and the color in +series.color, and "seriesIndex" is 0. Note that plugins and options +that transform the data can shift the indexes from what you specified +in the original data array. + +If you use the above events to update some other information and want +to clear out that info in case the mouse goes away, you'll probably +also need to listen to "mouseout" events on the placeholder div. + +"mouseActiveRadius" specifies how far the mouse can be from an item +and still activate it. If there are two or more points within this +radius, Flot chooses the closest item. For bars, the top-most bar +(from the latest specified data series) is chosen. + +If you want to disable interactivity for a specific data series, you +can set "hoverable" and "clickable" to false in the options for that +series, like this: + +```js +{ data: [...], label: "Foo", clickable: false } +``` + +"redrawOverlayInterval" specifies the maximum time to delay a redraw +of interactive things (this works as a rate limiting device). The +default is capped to 60 frames per second. You can set it to -1 to +disable the rate limiting. + + +## Specifying gradients ## + +A gradient is specified like this: + +```js +{ colors: [ color1, color2, ... ] } +``` + +For instance, you might specify a background on the grid going from +black to gray like this: + +```js +grid: { + backgroundColor: { colors: ["#000", "#999"] } +} +``` + +For the series you can specify the gradient as an object that +specifies the scaling of the brightness and the opacity of the series +color, e.g. + +```js +{ colors: [{ opacity: 0.8 }, { brightness: 0.6, opacity: 0.8 } ] } +``` + +where the first color simply has its alpha scaled, whereas the second +is also darkened. For instance, for bars the following makes the bars +gradually disappear, without outline: + +```js +bars: { + show: true, + lineWidth: 0, + fill: true, + fillColor: { colors: [ { opacity: 0.8 }, { opacity: 0.1 } ] } +} +``` + +Flot currently only supports vertical gradients drawn from top to +bottom because that's what works with IE. + + +## Plot Methods ## + +The Plot object returned from the plot function has some methods you +can call: + + - highlight(series, datapoint) + + Highlight a specific datapoint in the data series. You can either + specify the actual objects, e.g. if you got them from a + "plotclick" event, or you can specify the indices, e.g. + highlight(1, 3) to highlight the fourth point in the second series + (remember, zero-based indexing). + + - unhighlight(series, datapoint) or unhighlight() + + Remove the highlighting of the point, same parameters as + highlight. + + If you call unhighlight with no parameters, e.g. as + plot.unhighlight(), all current highlights are removed. + + - setData(data) + + You can use this to reset the data used. Note that axis scaling, + ticks, legend etc. will not be recomputed (use setupGrid() to do + that). You'll probably want to call draw() afterwards. + + You can use this function to speed up redrawing a small plot if + you know that the axes won't change. Put in the new data with + setData(newdata), call draw(), and you're good to go. Note that + for large datasets, almost all the time is consumed in draw() + plotting the data so in this case don't bother. + + - setupGrid() + + Recalculate and set axis scaling, ticks, legend etc. + + Note that because of the drawing model of the canvas, this + function will immediately redraw (actually reinsert in the DOM) + the labels and the legend, but not the actual tick lines because + they're drawn on the canvas. You need to call draw() to get the + canvas redrawn. + + - draw() + + Redraws the plot canvas. + + - triggerRedrawOverlay() + + Schedules an update of an overlay canvas used for drawing + interactive things like a selection and point highlights. This + is mostly useful for writing plugins. The redraw doesn't happen + immediately, instead a timer is set to catch multiple successive + redraws (e.g. from a mousemove). You can get to the overlay by + setting up a drawOverlay hook. + + - width()/height() + + Gets the width and height of the plotting area inside the grid. + This is smaller than the canvas or placeholder dimensions as some + extra space is needed (e.g. for labels). + + - offset() + + Returns the offset of the plotting area inside the grid relative + to the document, useful for instance for calculating mouse + positions (event.pageX/Y minus this offset is the pixel position + inside the plot). + + - pointOffset({ x: xpos, y: ypos }) + + Returns the calculated offset of the data point at (x, y) in data + space within the placeholder div. If you are working with multiple + axes, you can specify the x and y axis references, e.g. + + ```js + o = pointOffset({ x: xpos, y: ypos, xaxis: 2, yaxis: 3 }) + // o.left and o.top now contains the offset within the div + ```` + + - resize() + + Tells Flot to resize the drawing canvas to the size of the + placeholder. You need to run setupGrid() and draw() afterwards as + canvas resizing is a destructive operation. This is used + internally by the resize plugin. + + - shutdown() + + Cleans up any event handlers Flot has currently registered. This + is used internally. + +There are also some members that let you peek inside the internal +workings of Flot which is useful in some cases. Note that if you change +something in the objects returned, you're changing the objects used by +Flot to keep track of its state, so be careful. + + - getData() + + Returns an array of the data series currently used in normalized + form with missing settings filled in according to the global + options. So for instance to find out what color Flot has assigned + to the data series, you could do this: + + ```js + var series = plot.getData(); + for (var i = 0; i < series.length; ++i) + alert(series[i].color); + ``` + + A notable other interesting field besides color is datapoints + which has a field "points" with the normalized data points in a + flat array (the field "pointsize" is the increment in the flat + array to get to the next point so for a dataset consisting only of + (x,y) pairs it would be 2). + + - getAxes() + + Gets an object with the axes. The axes are returned as the + attributes of the object, so for instance getAxes().xaxis is the + x axis. + + Various things are stuffed inside an axis object, e.g. you could + use getAxes().xaxis.ticks to find out what the ticks are for the + xaxis. Two other useful attributes are p2c and c2p, functions for + transforming from data point space to the canvas plot space and + back. Both returns values that are offset with the plot offset. + Check the Flot source code for the complete set of attributes (or + output an axis with console.log() and inspect it). + + With multiple axes, the extra axes are returned as x2axis, x3axis, + etc., e.g. getAxes().y2axis is the second y axis. You can check + y2axis.used to see whether the axis is associated with any data + points and y2axis.show to see if it is currently shown. + + - getPlaceholder() + + Returns placeholder that the plot was put into. This can be useful + for plugins for adding DOM elements or firing events. + + - getCanvas() + + Returns the canvas used for drawing in case you need to hack on it + yourself. You'll probably need to get the plot offset too. + + - getPlotOffset() + + Gets the offset that the grid has within the canvas as an object + with distances from the canvas edges as "left", "right", "top", + "bottom". I.e., if you draw a circle on the canvas with the center + placed at (left, top), its center will be at the top-most, left + corner of the grid. + + - getOptions() + + Gets the options for the plot, normalized, with default values + filled in. You get a reference to actual values used by Flot, so + if you modify the values in here, Flot will use the new values. + If you change something, you probably have to call draw() or + setupGrid() or triggerRedrawOverlay() to see the change. + + +## Hooks ## + +In addition to the public methods, the Plot object also has some hooks +that can be used to modify the plotting process. You can install a +callback function at various points in the process, the function then +gets access to the internal data structures in Flot. + +Here's an overview of the phases Flot goes through: + + 1. Plugin initialization, parsing options + + 2. Constructing the canvases used for drawing + + 3. Set data: parsing data specification, calculating colors, + copying raw data points into internal format, + normalizing them, finding max/min for axis auto-scaling + + 4. Grid setup: calculating axis spacing, ticks, inserting tick + labels, the legend + + 5. Draw: drawing the grid, drawing each of the series in turn + + 6. Setting up event handling for interactive features + + 7. Responding to events, if any + + 8. Shutdown: this mostly happens in case a plot is overwritten + +Each hook is simply a function which is put in the appropriate array. +You can add them through the "hooks" option, and they are also available +after the plot is constructed as the "hooks" attribute on the returned +plot object, e.g. + +```js + // define a simple draw hook + function hellohook(plot, canvascontext) { alert("hello!"); }; + + // pass it in, in an array since we might want to specify several + var plot = $.plot(placeholder, data, { hooks: { draw: [hellohook] } }); + + // we can now find it again in plot.hooks.draw[0] unless a plugin + // has added other hooks +``` + +The available hooks are described below. All hook callbacks get the +plot object as first parameter. You can find some examples of defined +hooks in the plugins bundled with Flot. + + - processOptions [phase 1] + + ```function(plot, options)``` + + Called after Flot has parsed and merged options. Useful in the + instance where customizations beyond simple merging of default + values is needed. A plugin might use it to detect that it has been + enabled and then turn on or off other options. + + + - processRawData [phase 3] + + ```function(plot, series, data, datapoints)``` + + Called before Flot copies and normalizes the raw data for the given + series. If the function fills in datapoints.points with normalized + points and sets datapoints.pointsize to the size of the points, + Flot will skip the copying/normalization step for this series. + + In any case, you might be interested in setting datapoints.format, + an array of objects for specifying how a point is normalized and + how it interferes with axis scaling. It accepts the following options: + + ```js + { + x, y: boolean, + number: boolean, + required: boolean, + defaultValue: value, + autoscale: boolean + } + ``` + + "x" and "y" specify whether the value is plotted against the x or y axis, + and is currently used only to calculate axis min-max ranges. The default + format array, for example, looks like this: + + ```js + [ + { x: true, number: true, required: true }, + { y: true, number: true, required: true } + ] + ``` + + This indicates that a point, i.e. [0, 25], consists of two values, with the + first being plotted on the x axis and the second on the y axis. + + If "number" is true, then the value must be numeric, and is set to null if + it cannot be converted to a number. + + "defaultValue" provides a fallback in case the original value is null. This + is for instance handy for bars, where one can omit the third coordinate + (the bottom of the bar), which then defaults to zero. + + If "required" is true, then the value must exist (be non-null) for the + point as a whole to be valid. If no value is provided, then the entire + point is cleared out with nulls, turning it into a gap in the series. + + "autoscale" determines whether the value is considered when calculating an + automatic min-max range for the axes that the value is plotted against. + + - processDatapoints [phase 3] + + ```function(plot, series, datapoints)``` + + Called after normalization of the given series but before finding + min/max of the data points. This hook is useful for implementing data + transformations. "datapoints" contains the normalized data points in + a flat array as datapoints.points with the size of a single point + given in datapoints.pointsize. Here's a simple transform that + multiplies all y coordinates by 2: + + ```js + function multiply(plot, series, datapoints) { + var points = datapoints.points, ps = datapoints.pointsize; + for (var i = 0; i < points.length; i += ps) + points[i + 1] *= 2; + } + ``` + + Note that you must leave datapoints in a good condition as Flot + doesn't check it or do any normalization on it afterwards. + + - processOffset [phase 4] + + ```function(plot, offset)``` + + Called after Flot has initialized the plot's offset, but before it + draws any axes or plot elements. This hook is useful for customizing + the margins between the grid and the edge of the canvas. "offset" is + an object with attributes "top", "bottom", "left" and "right", + corresponding to the margins on the four sides of the plot. + + - drawBackground [phase 5] + + ```function(plot, canvascontext)``` + + Called before all other drawing operations. Used to draw backgrounds + or other custom elements before the plot or axes have been drawn. + + - drawSeries [phase 5] + + ```function(plot, canvascontext, series)``` + + Hook for custom drawing of a single series. Called just before the + standard drawing routine has been called in the loop that draws + each series. + + - draw [phase 5] + + ```function(plot, canvascontext)``` + + Hook for drawing on the canvas. Called after the grid is drawn + (unless it's disabled or grid.aboveData is set) and the series have + been plotted (in case any points, lines or bars have been turned + on). For examples of how to draw things, look at the source code. + + - bindEvents [phase 6] + + ```function(plot, eventHolder)``` + + Called after Flot has setup its event handlers. Should set any + necessary event handlers on eventHolder, a jQuery object with the + canvas, e.g. + + ```js + function (plot, eventHolder) { + eventHolder.mousedown(function (e) { + alert("You pressed the mouse at " + e.pageX + " " + e.pageY); + }); + } + ``` + + Interesting events include click, mousemove, mouseup/down. You can + use all jQuery events. Usually, the event handlers will update the + state by drawing something (add a drawOverlay hook and call + triggerRedrawOverlay) or firing an externally visible event for + user code. See the crosshair plugin for an example. + + Currently, eventHolder actually contains both the static canvas + used for the plot itself and the overlay canvas used for + interactive features because some versions of IE get the stacking + order wrong. The hook only gets one event, though (either for the + overlay or for the static canvas). + + Note that custom plot events generated by Flot are not generated on + eventHolder, but on the div placeholder supplied as the first + argument to the plot call. You can get that with + plot.getPlaceholder() - that's probably also the one you should use + if you need to fire a custom event. + + - drawOverlay [phase 7] + + ```function (plot, canvascontext)``` + + The drawOverlay hook is used for interactive things that need a + canvas to draw on. The model currently used by Flot works the way + that an extra overlay canvas is positioned on top of the static + canvas. This overlay is cleared and then completely redrawn + whenever something interesting happens. This hook is called when + the overlay canvas is to be redrawn. + + "canvascontext" is the 2D context of the overlay canvas. You can + use this to draw things. You'll most likely need some of the + metrics computed by Flot, e.g. plot.width()/plot.height(). See the + crosshair plugin for an example. + + - shutdown [phase 8] + + ```function (plot, eventHolder)``` + + Run when plot.shutdown() is called, which usually only happens in + case a plot is overwritten by a new plot. If you're writing a + plugin that adds extra DOM elements or event handlers, you should + add a callback to clean up after you. Take a look at the section in + the [PLUGINS](PLUGINS.md) document for more info. + + +## Plugins ## + +Plugins extend the functionality of Flot. To use a plugin, simply +include its Javascript file after Flot in the HTML page. + +If you're worried about download size/latency, you can concatenate all +the plugins you use, and Flot itself for that matter, into one big file +(make sure you get the order right), then optionally run it through a +Javascript minifier such as YUI Compressor. + +Here's a brief explanation of how the plugin plumbings work: + +Each plugin registers itself in the global array $.plot.plugins. When +you make a new plot object with $.plot, Flot goes through this array +calling the "init" function of each plugin and merging default options +from the "option" attribute of the plugin. The init function gets a +reference to the plot object created and uses this to register hooks +and add new public methods if needed. + +See the [PLUGINS](PLUGINS.md) document for details on how to write a plugin. As the +above description hints, it's actually pretty easy. + + +## Version number ## + +The version number of Flot is available in ```$.plot.version```. diff --git a/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/index.js b/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/index.js new file mode 100644 index 0000000000000..ff3de33b017a7 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/index.js @@ -0,0 +1,15 @@ +// TODO: This is bad. We aren't loading jQuery again, because Kibana already has, but we aren't really assured of that. +// That could change at any moment. + +//import $ from 'jquery'; +//if (window) window.jQuery = $; +require('./jquery.flot'); +require('./jquery.flot.time'); +require('./jquery.flot.canvas'); +require('./jquery.flot.symbol'); +require('./jquery.flot.crosshair'); +require('./jquery.flot.selection'); +require('./jquery.flot.stack'); +require('./jquery.flot.threshold'); +require('./jquery.flot.fillbetween'); +//module.exports = $; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/jquery.colorhelpers.js b/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/jquery.colorhelpers.js new file mode 100644 index 0000000000000..b2f6dc4e433a3 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/jquery.colorhelpers.js @@ -0,0 +1,180 @@ +/* Plugin for jQuery for working with colors. + * + * Version 1.1. + * + * Inspiration from jQuery color animation plugin by John Resig. + * + * Released under the MIT license by Ole Laursen, October 2009. + * + * Examples: + * + * $.color.parse("#fff").scale('rgb', 0.25).add('a', -0.5).toString() + * var c = $.color.extract($("#mydiv"), 'background-color'); + * console.log(c.r, c.g, c.b, c.a); + * $.color.make(100, 50, 25, 0.4).toString() // returns "rgba(100,50,25,0.4)" + * + * Note that .scale() and .add() return the same modified object + * instead of making a new one. + * + * V. 1.1: Fix error handling so e.g. parsing an empty string does + * produce a color rather than just crashing. + */ + +(function($) { + $.color = {}; + + // construct color object with some convenient chainable helpers + $.color.make = function (r, g, b, a) { + var o = {}; + o.r = r || 0; + o.g = g || 0; + o.b = b || 0; + o.a = a != null ? a : 1; + + o.add = function (c, d) { + for (var i = 0; i < c.length; ++i) + o[c.charAt(i)] += d; + return o.normalize(); + }; + + o.scale = function (c, f) { + for (var i = 0; i < c.length; ++i) + o[c.charAt(i)] *= f; + return o.normalize(); + }; + + o.toString = function () { + if (o.a >= 1.0) { + return "rgb("+[o.r, o.g, o.b].join(",")+")"; + } else { + return "rgba("+[o.r, o.g, o.b, o.a].join(",")+")"; + } + }; + + o.normalize = function () { + function clamp(min, value, max) { + return value < min ? min: (value > max ? max: value); + } + + o.r = clamp(0, parseInt(o.r), 255); + o.g = clamp(0, parseInt(o.g), 255); + o.b = clamp(0, parseInt(o.b), 255); + o.a = clamp(0, o.a, 1); + return o; + }; + + o.clone = function () { + return $.color.make(o.r, o.b, o.g, o.a); + }; + + return o.normalize(); + } + + // extract CSS color property from element, going up in the DOM + // if it's "transparent" + $.color.extract = function (elem, css) { + var c; + + do { + c = elem.css(css).toLowerCase(); + // keep going until we find an element that has color, or + // we hit the body or root (have no parent) + if (c != '' && c != 'transparent') + break; + elem = elem.parent(); + } while (elem.length && !$.nodeName(elem.get(0), "body")); + + // catch Safari's way of signalling transparent + if (c == "rgba(0, 0, 0, 0)") + c = "transparent"; + + return $.color.parse(c); + } + + // parse CSS color string (like "rgb(10, 32, 43)" or "#fff"), + // returns color object, if parsing failed, you get black (0, 0, + // 0) out + $.color.parse = function (str) { + var res, m = $.color.make; + + // Look for rgb(num,num,num) + if (res = /rgb\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*\)/.exec(str)) + return m(parseInt(res[1], 10), parseInt(res[2], 10), parseInt(res[3], 10)); + + // Look for rgba(num,num,num,num) + if (res = /rgba\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]+(?:\.[0-9]+)?)\s*\)/.exec(str)) + return m(parseInt(res[1], 10), parseInt(res[2], 10), parseInt(res[3], 10), parseFloat(res[4])); + + // Look for rgb(num%,num%,num%) + if (res = /rgb\(\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*\)/.exec(str)) + return m(parseFloat(res[1])*2.55, parseFloat(res[2])*2.55, parseFloat(res[3])*2.55); + + // Look for rgba(num%,num%,num%,num) + if (res = /rgba\(\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\s*\)/.exec(str)) + return m(parseFloat(res[1])*2.55, parseFloat(res[2])*2.55, parseFloat(res[3])*2.55, parseFloat(res[4])); + + // Look for #a0b1c2 + if (res = /#([a-fA-F0-9]{2})([a-fA-F0-9]{2})([a-fA-F0-9]{2})/.exec(str)) + return m(parseInt(res[1], 16), parseInt(res[2], 16), parseInt(res[3], 16)); + + // Look for #fff + if (res = /#([a-fA-F0-9])([a-fA-F0-9])([a-fA-F0-9])/.exec(str)) + return m(parseInt(res[1]+res[1], 16), parseInt(res[2]+res[2], 16), parseInt(res[3]+res[3], 16)); + + // Otherwise, we're most likely dealing with a named color + var name = $.trim(str).toLowerCase(); + if (name == "transparent") + return m(255, 255, 255, 0); + else { + // default to black + res = lookupColors[name] || [0, 0, 0]; + return m(res[0], res[1], res[2]); + } + } + + var lookupColors = { + aqua:[0,255,255], + azure:[240,255,255], + beige:[245,245,220], + black:[0,0,0], + blue:[0,0,255], + brown:[165,42,42], + cyan:[0,255,255], + darkblue:[0,0,139], + darkcyan:[0,139,139], + darkgrey:[169,169,169], + darkgreen:[0,100,0], + darkkhaki:[189,183,107], + darkmagenta:[139,0,139], + darkolivegreen:[85,107,47], + darkorange:[255,140,0], + darkorchid:[153,50,204], + darkred:[139,0,0], + darksalmon:[233,150,122], + darkviolet:[148,0,211], + fuchsia:[255,0,255], + gold:[255,215,0], + green:[0,128,0], + indigo:[75,0,130], + khaki:[240,230,140], + lightblue:[173,216,230], + lightcyan:[224,255,255], + lightgreen:[144,238,144], + lightgrey:[211,211,211], + lightpink:[255,182,193], + lightyellow:[255,255,224], + lime:[0,255,0], + magenta:[255,0,255], + maroon:[128,0,0], + navy:[0,0,128], + olive:[128,128,0], + orange:[255,165,0], + pink:[255,192,203], + purple:[128,0,128], + violet:[128,0,128], + red:[255,0,0], + silver:[192,192,192], + white:[255,255,255], + yellow:[255,255,0] + }; +})(jQuery); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/jquery.flot.canvas.js b/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/jquery.flot.canvas.js new file mode 100644 index 0000000000000..29328d5812127 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/jquery.flot.canvas.js @@ -0,0 +1,345 @@ +/* Flot plugin for drawing all elements of a plot on the canvas. + +Copyright (c) 2007-2014 IOLA and Ole Laursen. +Licensed under the MIT license. + +Flot normally produces certain elements, like axis labels and the legend, using +HTML elements. This permits greater interactivity and customization, and often +looks better, due to cross-browser canvas text inconsistencies and limitations. + +It can also be desirable to render the plot entirely in canvas, particularly +if the goal is to save it as an image, or if Flot is being used in a context +where the HTML DOM does not exist, as is the case within Node.js. This plugin +switches out Flot's standard drawing operations for canvas-only replacements. + +Currently the plugin supports only axis labels, but it will eventually allow +every element of the plot to be rendered directly to canvas. + +The plugin supports these options: + +{ + canvas: boolean +} + +The "canvas" option controls whether full canvas drawing is enabled, making it +possible to toggle on and off. This is useful when a plot uses HTML text in the +browser, but needs to redraw with canvas text when exporting as an image. + +*/ + +(function($) { + + var options = { + canvas: true + }; + + var render, getTextInfo, addText; + + // Cache the prototype hasOwnProperty for faster access + + var hasOwnProperty = Object.prototype.hasOwnProperty; + + function init(plot, classes) { + + var Canvas = classes.Canvas; + + // We only want to replace the functions once; the second time around + // we would just get our new function back. This whole replacing of + // prototype functions is a disaster, and needs to be changed ASAP. + + if (render == null) { + getTextInfo = Canvas.prototype.getTextInfo, + addText = Canvas.prototype.addText, + render = Canvas.prototype.render; + } + + // Finishes rendering the canvas, including overlaid text + + Canvas.prototype.render = function() { + + if (!plot.getOptions().canvas) { + return render.call(this); + } + + var context = this.context, + cache = this._textCache; + + // For each text layer, render elements marked as active + + context.save(); + context.textBaseline = "middle"; + + for (var layerKey in cache) { + if (hasOwnProperty.call(cache, layerKey)) { + var layerCache = cache[layerKey]; + for (var styleKey in layerCache) { + if (hasOwnProperty.call(layerCache, styleKey)) { + var styleCache = layerCache[styleKey], + updateStyles = true; + for (var key in styleCache) { + if (hasOwnProperty.call(styleCache, key)) { + + var info = styleCache[key], + positions = info.positions, + lines = info.lines; + + // Since every element at this level of the cache have the + // same font and fill styles, we can just change them once + // using the values from the first element. + + if (updateStyles) { + context.fillStyle = info.font.color; + context.font = info.font.definition; + updateStyles = false; + } + + for (var i = 0, position; position = positions[i]; i++) { + if (position.active) { + for (var j = 0, line; line = position.lines[j]; j++) { + context.fillText(lines[j].text, line[0], line[1]); + } + } else { + positions.splice(i--, 1); + } + } + + if (positions.length == 0) { + delete styleCache[key]; + } + } + } + } + } + } + } + + context.restore(); + }; + + // Creates (if necessary) and returns a text info object. + // + // When the canvas option is set, the object looks like this: + // + // { + // width: Width of the text's bounding box. + // height: Height of the text's bounding box. + // positions: Array of positions at which this text is drawn. + // lines: [{ + // height: Height of this line. + // widths: Width of this line. + // text: Text on this line. + // }], + // font: { + // definition: Canvas font property string. + // color: Color of the text. + // }, + // } + // + // The positions array contains objects that look like this: + // + // { + // active: Flag indicating whether the text should be visible. + // lines: Array of [x, y] coordinates at which to draw the line. + // x: X coordinate at which to draw the text. + // y: Y coordinate at which to draw the text. + // } + + Canvas.prototype.getTextInfo = function(layer, text, font, angle, width) { + + if (!plot.getOptions().canvas) { + return getTextInfo.call(this, layer, text, font, angle, width); + } + + var textStyle, layerCache, styleCache, info; + + // Cast the value to a string, in case we were given a number + + text = "" + text; + + // If the font is a font-spec object, generate a CSS definition + + if (typeof font === "object") { + textStyle = font.style + " " + font.variant + " " + font.weight + " " + font.size + "px " + font.family; + } else { + textStyle = font; + } + + // Retrieve (or create) the cache for the text's layer and styles + + layerCache = this._textCache[layer]; + + if (layerCache == null) { + layerCache = this._textCache[layer] = {}; + } + + styleCache = layerCache[textStyle]; + + if (styleCache == null) { + styleCache = layerCache[textStyle] = {}; + } + + info = styleCache[text]; + + if (info == null) { + + var context = this.context; + + // If the font was provided as CSS, create a div with those + // classes and examine it to generate a canvas font spec. + + if (typeof font !== "object") { + + var element = $("
 
") + .css("position", "absolute") + .addClass(typeof font === "string" ? font : null) + .appendTo(this.getTextLayer(layer)); + + font = { + lineHeight: element.height(), + style: element.css("font-style"), + variant: element.css("font-variant"), + weight: element.css("font-weight"), + family: element.css("font-family"), + color: element.css("color") + }; + + // Setting line-height to 1, without units, sets it equal + // to the font-size, even if the font-size is abstract, + // like 'smaller'. This enables us to read the real size + // via the element's height, working around browsers that + // return the literal 'smaller' value. + + font.size = element.css("line-height", 1).height(); + + element.remove(); + } + + textStyle = font.style + " " + font.variant + " " + font.weight + " " + font.size + "px " + font.family; + + // Create a new info object, initializing the dimensions to + // zero so we can count them up line-by-line. + + info = styleCache[text] = { + width: 0, + height: 0, + positions: [], + lines: [], + font: { + definition: textStyle, + color: font.color + } + }; + + context.save(); + context.font = textStyle; + + // Canvas can't handle multi-line strings; break on various + // newlines, including HTML brs, to build a list of lines. + // Note that we could split directly on regexps, but IE < 9 is + // broken; revisit when we drop IE 7/8 support. + + var lines = (text + "").replace(/
|\r\n|\r/g, "\n").split("\n"); + + for (var i = 0; i < lines.length; ++i) { + + var lineText = lines[i], + measured = context.measureText(lineText); + + info.width = Math.max(measured.width, info.width); + info.height += font.lineHeight; + + info.lines.push({ + text: lineText, + width: measured.width, + height: font.lineHeight + }); + } + + context.restore(); + } + + return info; + }; + + // Adds a text string to the canvas text overlay. + + Canvas.prototype.addText = function(layer, x, y, text, font, angle, width, halign, valign) { + + if (!plot.getOptions().canvas) { + return addText.call(this, layer, x, y, text, font, angle, width, halign, valign); + } + + var info = this.getTextInfo(layer, text, font, angle, width), + positions = info.positions, + lines = info.lines; + + // Text is drawn with baseline 'middle', which we need to account + // for by adding half a line's height to the y position. + + y += info.height / lines.length / 2; + + // Tweak the initial y-position to match vertical alignment + + if (valign == "middle") { + y = Math.round(y - info.height / 2); + } else if (valign == "bottom") { + y = Math.round(y - info.height); + } else { + y = Math.round(y); + } + + // FIXME: LEGACY BROWSER FIX + // AFFECTS: Opera < 12.00 + + // Offset the y coordinate, since Opera is off pretty + // consistently compared to the other browsers. + + if (!!(window.opera && window.opera.version().split(".")[0] < 12)) { + y -= 2; + } + + // Determine whether this text already exists at this position. + // If so, mark it for inclusion in the next render pass. + + for (var i = 0, position; position = positions[i]; i++) { + if (position.x == x && position.y == y) { + position.active = true; + return; + } + } + + // If the text doesn't exist at this position, create a new entry + + position = { + active: true, + lines: [], + x: x, + y: y + }; + + positions.push(position); + + // Fill in the x & y positions of each line, adjusting them + // individually for horizontal alignment. + + for (var i = 0, line; line = lines[i]; i++) { + if (halign == "center") { + position.lines.push([Math.round(x - line.width / 2), y]); + } else if (halign == "right") { + position.lines.push([Math.round(x - line.width), y]); + } else { + position.lines.push([Math.round(x), y]); + } + y += line.height; + } + }; + } + + $.plot.plugins.push({ + init: init, + options: options, + name: "canvas", + version: "1.0" + }); + +})(jQuery); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/jquery.flot.categories.js b/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/jquery.flot.categories.js new file mode 100644 index 0000000000000..2f9b257971499 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/jquery.flot.categories.js @@ -0,0 +1,190 @@ +/* Flot plugin for plotting textual data or categories. + +Copyright (c) 2007-2014 IOLA and Ole Laursen. +Licensed under the MIT license. + +Consider a dataset like [["February", 34], ["March", 20], ...]. This plugin +allows you to plot such a dataset directly. + +To enable it, you must specify mode: "categories" on the axis with the textual +labels, e.g. + + $.plot("#placeholder", data, { xaxis: { mode: "categories" } }); + +By default, the labels are ordered as they are met in the data series. If you +need a different ordering, you can specify "categories" on the axis options +and list the categories there: + + xaxis: { + mode: "categories", + categories: ["February", "March", "April"] + } + +If you need to customize the distances between the categories, you can specify +"categories" as an object mapping labels to values + + xaxis: { + mode: "categories", + categories: { "February": 1, "March": 3, "April": 4 } + } + +If you don't specify all categories, the remaining categories will be numbered +from the max value plus 1 (with a spacing of 1 between each). + +Internally, the plugin works by transforming the input data through an auto- +generated mapping where the first category becomes 0, the second 1, etc. +Hence, a point like ["February", 34] becomes [0, 34] internally in Flot (this +is visible in hover and click events that return numbers rather than the +category labels). The plugin also overrides the tick generator to spit out the +categories as ticks instead of the values. + +If you need to map a value back to its label, the mapping is always accessible +as "categories" on the axis object, e.g. plot.getAxes().xaxis.categories. + +*/ + +(function ($) { + var options = { + xaxis: { + categories: null + }, + yaxis: { + categories: null + } + }; + + function processRawData(plot, series, data, datapoints) { + // if categories are enabled, we need to disable + // auto-transformation to numbers so the strings are intact + // for later processing + + var xCategories = series.xaxis.options.mode == "categories", + yCategories = series.yaxis.options.mode == "categories"; + + if (!(xCategories || yCategories)) + return; + + var format = datapoints.format; + + if (!format) { + // FIXME: auto-detection should really not be defined here + var s = series; + format = []; + format.push({ x: true, number: true, required: true }); + format.push({ y: true, number: true, required: true }); + + if (s.bars.show || (s.lines.show && s.lines.fill)) { + var autoscale = !!((s.bars.show && s.bars.zero) || (s.lines.show && s.lines.zero)); + format.push({ y: true, number: true, required: false, defaultValue: 0, autoscale: autoscale }); + if (s.bars.horizontal) { + delete format[format.length - 1].y; + format[format.length - 1].x = true; + } + } + + datapoints.format = format; + } + + for (var m = 0; m < format.length; ++m) { + if (format[m].x && xCategories) + format[m].number = false; + + if (format[m].y && yCategories) + format[m].number = false; + } + } + + function getNextIndex(categories) { + var index = -1; + + for (var v in categories) + if (categories[v] > index) + index = categories[v]; + + return index + 1; + } + + function categoriesTickGenerator(axis) { + var res = []; + for (var label in axis.categories) { + var v = axis.categories[label]; + if (v >= axis.min && v <= axis.max) + res.push([v, label]); + } + + res.sort(function (a, b) { return a[0] - b[0]; }); + + return res; + } + + function setupCategoriesForAxis(series, axis, datapoints) { + if (series[axis].options.mode != "categories") + return; + + if (!series[axis].categories) { + // parse options + var c = {}, o = series[axis].options.categories || {}; + if ($.isArray(o)) { + for (var i = 0; i < o.length; ++i) + c[o[i]] = i; + } + else { + for (var v in o) + c[v] = o[v]; + } + + series[axis].categories = c; + } + + // fix ticks + if (!series[axis].options.ticks) + series[axis].options.ticks = categoriesTickGenerator; + + transformPointsOnAxis(datapoints, axis, series[axis].categories); + } + + function transformPointsOnAxis(datapoints, axis, categories) { + // go through the points, transforming them + var points = datapoints.points, + ps = datapoints.pointsize, + format = datapoints.format, + formatColumn = axis.charAt(0), + index = getNextIndex(categories); + + for (var i = 0; i < points.length; i += ps) { + if (points[i] == null) + continue; + + for (var m = 0; m < ps; ++m) { + var val = points[i + m]; + + if (val == null || !format[m][formatColumn]) + continue; + + if (!(val in categories)) { + categories[val] = index; + ++index; + } + + points[i + m] = categories[val]; + } + } + } + + function processDatapoints(plot, series, datapoints) { + setupCategoriesForAxis(series, "xaxis", datapoints); + setupCategoriesForAxis(series, "yaxis", datapoints); + } + + function init(plot) { + plot.hooks.processRawData.push(processRawData); + plot.hooks.processDatapoints.push(processDatapoints); + } + + $.plot.plugins.push({ + init: init, + options: options, + name: 'categories', + version: '1.0' + }); +})(jQuery); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/jquery.flot.crosshair.js b/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/jquery.flot.crosshair.js new file mode 100644 index 0000000000000..5111695e3d12c --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/jquery.flot.crosshair.js @@ -0,0 +1,176 @@ +/* Flot plugin for showing crosshairs when the mouse hovers over the plot. + +Copyright (c) 2007-2014 IOLA and Ole Laursen. +Licensed under the MIT license. + +The plugin supports these options: + + crosshair: { + mode: null or "x" or "y" or "xy" + color: color + lineWidth: number + } + +Set the mode to one of "x", "y" or "xy". The "x" mode enables a vertical +crosshair that lets you trace the values on the x axis, "y" enables a +horizontal crosshair and "xy" enables them both. "color" is the color of the +crosshair (default is "rgba(170, 0, 0, 0.80)"), "lineWidth" is the width of +the drawn lines (default is 1). + +The plugin also adds four public methods: + + - setCrosshair( pos ) + + Set the position of the crosshair. Note that this is cleared if the user + moves the mouse. "pos" is in coordinates of the plot and should be on the + form { x: xpos, y: ypos } (you can use x2/x3/... if you're using multiple + axes), which is coincidentally the same format as what you get from a + "plothover" event. If "pos" is null, the crosshair is cleared. + + - clearCrosshair() + + Clear the crosshair. + + - lockCrosshair(pos) + + Cause the crosshair to lock to the current location, no longer updating if + the user moves the mouse. Optionally supply a position (passed on to + setCrosshair()) to move it to. + + Example usage: + + var myFlot = $.plot( $("#graph"), ..., { crosshair: { mode: "x" } } }; + $("#graph").bind( "plothover", function ( evt, position, item ) { + if ( item ) { + // Lock the crosshair to the data point being hovered + myFlot.lockCrosshair({ + x: item.datapoint[ 0 ], + y: item.datapoint[ 1 ] + }); + } else { + // Return normal crosshair operation + myFlot.unlockCrosshair(); + } + }); + + - unlockCrosshair() + + Free the crosshair to move again after locking it. +*/ + +(function ($) { + var options = { + crosshair: { + mode: null, // one of null, "x", "y" or "xy", + color: "rgba(170, 0, 0, 0.80)", + lineWidth: 1 + } + }; + + function init(plot) { + // position of crosshair in pixels + var crosshair = { x: -1, y: -1, locked: false }; + + plot.setCrosshair = function setCrosshair(pos) { + if (!pos) + crosshair.x = -1; + else { + var o = plot.p2c(pos); + crosshair.x = Math.max(0, Math.min(o.left, plot.width())); + crosshair.y = Math.max(0, Math.min(o.top, plot.height())); + } + + plot.triggerRedrawOverlay(); + }; + + plot.clearCrosshair = plot.setCrosshair; // passes null for pos + + plot.lockCrosshair = function lockCrosshair(pos) { + if (pos) + plot.setCrosshair(pos); + crosshair.locked = true; + }; + + plot.unlockCrosshair = function unlockCrosshair() { + crosshair.locked = false; + }; + + function onMouseOut(e) { + if (crosshair.locked) + return; + + if (crosshair.x != -1) { + crosshair.x = -1; + plot.triggerRedrawOverlay(); + } + } + + function onMouseMove(e) { + if (crosshair.locked) + return; + + if (plot.getSelection && plot.getSelection()) { + crosshair.x = -1; // hide the crosshair while selecting + return; + } + + var offset = plot.offset(); + crosshair.x = Math.max(0, Math.min(e.pageX - offset.left, plot.width())); + crosshair.y = Math.max(0, Math.min(e.pageY - offset.top, plot.height())); + plot.triggerRedrawOverlay(); + } + + plot.hooks.bindEvents.push(function (plot, eventHolder) { + if (!plot.getOptions().crosshair.mode) + return; + + eventHolder.mouseout(onMouseOut); + eventHolder.mousemove(onMouseMove); + }); + + plot.hooks.drawOverlay.push(function (plot, ctx) { + var c = plot.getOptions().crosshair; + if (!c.mode) + return; + + var plotOffset = plot.getPlotOffset(); + + ctx.save(); + ctx.translate(plotOffset.left, plotOffset.top); + + if (crosshair.x != -1) { + var adj = plot.getOptions().crosshair.lineWidth % 2 ? 0.5 : 0; + + ctx.strokeStyle = c.color; + ctx.lineWidth = c.lineWidth; + ctx.lineJoin = "round"; + + ctx.beginPath(); + if (c.mode.indexOf("x") != -1) { + var drawX = Math.floor(crosshair.x) + adj; + ctx.moveTo(drawX, 0); + ctx.lineTo(drawX, plot.height()); + } + if (c.mode.indexOf("y") != -1) { + var drawY = Math.floor(crosshair.y) + adj; + ctx.moveTo(0, drawY); + ctx.lineTo(plot.width(), drawY); + } + ctx.stroke(); + } + ctx.restore(); + }); + + plot.hooks.shutdown.push(function (plot, eventHolder) { + eventHolder.unbind("mouseout", onMouseOut); + eventHolder.unbind("mousemove", onMouseMove); + }); + } + + $.plot.plugins.push({ + init: init, + options: options, + name: 'crosshair', + version: '1.0' + }); +})(jQuery); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/jquery.flot.errorbars.js b/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/jquery.flot.errorbars.js new file mode 100644 index 0000000000000..2583d5c20c321 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/jquery.flot.errorbars.js @@ -0,0 +1,353 @@ +/* Flot plugin for plotting error bars. + +Copyright (c) 2007-2014 IOLA and Ole Laursen. +Licensed under the MIT license. + +Error bars are used to show standard deviation and other statistical +properties in a plot. + +* Created by Rui Pereira - rui (dot) pereira (at) gmail (dot) com + +This plugin allows you to plot error-bars over points. Set "errorbars" inside +the points series to the axis name over which there will be error values in +your data array (*even* if you do not intend to plot them later, by setting +"show: null" on xerr/yerr). + +The plugin supports these options: + + series: { + points: { + errorbars: "x" or "y" or "xy", + xerr: { + show: null/false or true, + asymmetric: null/false or true, + upperCap: null or "-" or function, + lowerCap: null or "-" or function, + color: null or color, + radius: null or number + }, + yerr: { same options as xerr } + } + } + +Each data point array is expected to be of the type: + + "x" [ x, y, xerr ] + "y" [ x, y, yerr ] + "xy" [ x, y, xerr, yerr ] + +Where xerr becomes xerr_lower,xerr_upper for the asymmetric error case, and +equivalently for yerr. Eg., a datapoint for the "xy" case with symmetric +error-bars on X and asymmetric on Y would be: + + [ x, y, xerr, yerr_lower, yerr_upper ] + +By default no end caps are drawn. Setting upperCap and/or lowerCap to "-" will +draw a small cap perpendicular to the error bar. They can also be set to a +user-defined drawing function, with (ctx, x, y, radius) as parameters, as eg. + + function drawSemiCircle( ctx, x, y, radius ) { + ctx.beginPath(); + ctx.arc( x, y, radius, 0, Math.PI, false ); + ctx.moveTo( x - radius, y ); + ctx.lineTo( x + radius, y ); + ctx.stroke(); + } + +Color and radius both default to the same ones of the points series if not +set. The independent radius parameter on xerr/yerr is useful for the case when +we may want to add error-bars to a line, without showing the interconnecting +points (with radius: 0), and still showing end caps on the error-bars. +shadowSize and lineWidth are derived as well from the points series. + +*/ + +(function ($) { + var options = { + series: { + points: { + errorbars: null, //should be 'x', 'y' or 'xy' + xerr: { err: 'x', show: null, asymmetric: null, upperCap: null, lowerCap: null, color: null, radius: null}, + yerr: { err: 'y', show: null, asymmetric: null, upperCap: null, lowerCap: null, color: null, radius: null} + } + } + }; + + function processRawData(plot, series, data, datapoints){ + if (!series.points.errorbars) + return; + + // x,y values + var format = [ + { x: true, number: true, required: true }, + { y: true, number: true, required: true } + ]; + + var errors = series.points.errorbars; + // error bars - first X then Y + if (errors == 'x' || errors == 'xy') { + // lower / upper error + if (series.points.xerr.asymmetric) { + format.push({ x: true, number: true, required: true }); + format.push({ x: true, number: true, required: true }); + } else + format.push({ x: true, number: true, required: true }); + } + if (errors == 'y' || errors == 'xy') { + // lower / upper error + if (series.points.yerr.asymmetric) { + format.push({ y: true, number: true, required: true }); + format.push({ y: true, number: true, required: true }); + } else + format.push({ y: true, number: true, required: true }); + } + datapoints.format = format; + } + + function parseErrors(series, i){ + + var points = series.datapoints.points; + + // read errors from points array + var exl = null, + exu = null, + eyl = null, + eyu = null; + var xerr = series.points.xerr, + yerr = series.points.yerr; + + var eb = series.points.errorbars; + // error bars - first X + if (eb == 'x' || eb == 'xy') { + if (xerr.asymmetric) { + exl = points[i + 2]; + exu = points[i + 3]; + if (eb == 'xy') + if (yerr.asymmetric){ + eyl = points[i + 4]; + eyu = points[i + 5]; + } else eyl = points[i + 4]; + } else { + exl = points[i + 2]; + if (eb == 'xy') + if (yerr.asymmetric) { + eyl = points[i + 3]; + eyu = points[i + 4]; + } else eyl = points[i + 3]; + } + // only Y + } else if (eb == 'y') + if (yerr.asymmetric) { + eyl = points[i + 2]; + eyu = points[i + 3]; + } else eyl = points[i + 2]; + + // symmetric errors? + if (exu == null) exu = exl; + if (eyu == null) eyu = eyl; + + var errRanges = [exl, exu, eyl, eyu]; + // nullify if not showing + if (!xerr.show){ + errRanges[0] = null; + errRanges[1] = null; + } + if (!yerr.show){ + errRanges[2] = null; + errRanges[3] = null; + } + return errRanges; + } + + function drawSeriesErrors(plot, ctx, s){ + + var points = s.datapoints.points, + ps = s.datapoints.pointsize, + ax = [s.xaxis, s.yaxis], + radius = s.points.radius, + err = [s.points.xerr, s.points.yerr]; + + //sanity check, in case some inverted axis hack is applied to flot + var invertX = false; + if (ax[0].p2c(ax[0].max) < ax[0].p2c(ax[0].min)) { + invertX = true; + var tmp = err[0].lowerCap; + err[0].lowerCap = err[0].upperCap; + err[0].upperCap = tmp; + } + + var invertY = false; + if (ax[1].p2c(ax[1].min) < ax[1].p2c(ax[1].max)) { + invertY = true; + var tmp = err[1].lowerCap; + err[1].lowerCap = err[1].upperCap; + err[1].upperCap = tmp; + } + + for (var i = 0; i < s.datapoints.points.length; i += ps) { + + //parse + var errRanges = parseErrors(s, i); + + //cycle xerr & yerr + for (var e = 0; e < err.length; e++){ + + var minmax = [ax[e].min, ax[e].max]; + + //draw this error? + if (errRanges[e * err.length]){ + + //data coordinates + var x = points[i], + y = points[i + 1]; + + //errorbar ranges + var upper = [x, y][e] + errRanges[e * err.length + 1], + lower = [x, y][e] - errRanges[e * err.length]; + + //points outside of the canvas + if (err[e].err == 'x') + if (y > ax[1].max || y < ax[1].min || upper < ax[0].min || lower > ax[0].max) + continue; + if (err[e].err == 'y') + if (x > ax[0].max || x < ax[0].min || upper < ax[1].min || lower > ax[1].max) + continue; + + // prevent errorbars getting out of the canvas + var drawUpper = true, + drawLower = true; + + if (upper > minmax[1]) { + drawUpper = false; + upper = minmax[1]; + } + if (lower < minmax[0]) { + drawLower = false; + lower = minmax[0]; + } + + //sanity check, in case some inverted axis hack is applied to flot + if ((err[e].err == 'x' && invertX) || (err[e].err == 'y' && invertY)) { + //swap coordinates + var tmp = lower; + lower = upper; + upper = tmp; + tmp = drawLower; + drawLower = drawUpper; + drawUpper = tmp; + tmp = minmax[0]; + minmax[0] = minmax[1]; + minmax[1] = tmp; + } + + // convert to pixels + x = ax[0].p2c(x), + y = ax[1].p2c(y), + upper = ax[e].p2c(upper); + lower = ax[e].p2c(lower); + minmax[0] = ax[e].p2c(minmax[0]); + minmax[1] = ax[e].p2c(minmax[1]); + + //same style as points by default + var lw = err[e].lineWidth ? err[e].lineWidth : s.points.lineWidth, + sw = s.points.shadowSize != null ? s.points.shadowSize : s.shadowSize; + + //shadow as for points + if (lw > 0 && sw > 0) { + var w = sw / 2; + ctx.lineWidth = w; + ctx.strokeStyle = "rgba(0,0,0,0.1)"; + drawError(ctx, err[e], x, y, upper, lower, drawUpper, drawLower, radius, w + w/2, minmax); + + ctx.strokeStyle = "rgba(0,0,0,0.2)"; + drawError(ctx, err[e], x, y, upper, lower, drawUpper, drawLower, radius, w/2, minmax); + } + + ctx.strokeStyle = err[e].color? err[e].color: s.color; + ctx.lineWidth = lw; + //draw it + drawError(ctx, err[e], x, y, upper, lower, drawUpper, drawLower, radius, 0, minmax); + } + } + } + } + + function drawError(ctx,err,x,y,upper,lower,drawUpper,drawLower,radius,offset,minmax){ + + //shadow offset + y += offset; + upper += offset; + lower += offset; + + // error bar - avoid plotting over circles + if (err.err == 'x'){ + if (upper > x + radius) drawPath(ctx, [[upper,y],[Math.max(x + radius,minmax[0]),y]]); + else drawUpper = false; + if (lower < x - radius) drawPath(ctx, [[Math.min(x - radius,minmax[1]),y],[lower,y]] ); + else drawLower = false; + } + else { + if (upper < y - radius) drawPath(ctx, [[x,upper],[x,Math.min(y - radius,minmax[0])]] ); + else drawUpper = false; + if (lower > y + radius) drawPath(ctx, [[x,Math.max(y + radius,minmax[1])],[x,lower]] ); + else drawLower = false; + } + + //internal radius value in errorbar, allows to plot radius 0 points and still keep proper sized caps + //this is a way to get errorbars on lines without visible connecting dots + radius = err.radius != null? err.radius: radius; + + // upper cap + if (drawUpper) { + if (err.upperCap == '-'){ + if (err.err=='x') drawPath(ctx, [[upper,y - radius],[upper,y + radius]] ); + else drawPath(ctx, [[x - radius,upper],[x + radius,upper]] ); + } else if ($.isFunction(err.upperCap)){ + if (err.err=='x') err.upperCap(ctx, upper, y, radius); + else err.upperCap(ctx, x, upper, radius); + } + } + // lower cap + if (drawLower) { + if (err.lowerCap == '-'){ + if (err.err=='x') drawPath(ctx, [[lower,y - radius],[lower,y + radius]] ); + else drawPath(ctx, [[x - radius,lower],[x + radius,lower]] ); + } else if ($.isFunction(err.lowerCap)){ + if (err.err=='x') err.lowerCap(ctx, lower, y, radius); + else err.lowerCap(ctx, x, lower, radius); + } + } + } + + function drawPath(ctx, pts){ + ctx.beginPath(); + ctx.moveTo(pts[0][0], pts[0][1]); + for (var p=1; p < pts.length; p++) + ctx.lineTo(pts[p][0], pts[p][1]); + ctx.stroke(); + } + + function draw(plot, ctx){ + var plotOffset = plot.getPlotOffset(); + + ctx.save(); + ctx.translate(plotOffset.left, plotOffset.top); + $.each(plot.getData(), function (i, s) { + if (s.points.errorbars && (s.points.xerr.show || s.points.yerr.show)) + drawSeriesErrors(plot, ctx, s); + }); + ctx.restore(); + } + + function init(plot) { + plot.hooks.processRawData.push(processRawData); + plot.hooks.draw.push(draw); + } + + $.plot.plugins.push({ + init: init, + options: options, + name: 'errorbars', + version: '1.0' + }); +})(jQuery); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/jquery.flot.fillbetween.js b/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/jquery.flot.fillbetween.js new file mode 100644 index 0000000000000..18b15d26db8c9 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/jquery.flot.fillbetween.js @@ -0,0 +1,226 @@ +/* Flot plugin for computing bottoms for filled line and bar charts. + +Copyright (c) 2007-2014 IOLA and Ole Laursen. +Licensed under the MIT license. + +The case: you've got two series that you want to fill the area between. In Flot +terms, you need to use one as the fill bottom of the other. You can specify the +bottom of each data point as the third coordinate manually, or you can use this +plugin to compute it for you. + +In order to name the other series, you need to give it an id, like this: + + var dataset = [ + { data: [ ... ], id: "foo" } , // use default bottom + { data: [ ... ], fillBetween: "foo" }, // use first dataset as bottom + ]; + + $.plot($("#placeholder"), dataset, { lines: { show: true, fill: true }}); + +As a convenience, if the id given is a number that doesn't appear as an id in +the series, it is interpreted as the index in the array instead (so fillBetween: +0 can also mean the first series). + +Internally, the plugin modifies the datapoints in each series. For line series, +extra data points might be inserted through interpolation. Note that at points +where the bottom line is not defined (due to a null point or start/end of line), +the current line will show a gap too. The algorithm comes from the +jquery.flot.stack.js plugin, possibly some code could be shared. + +*/ + +(function ( $ ) { + + var options = { + series: { + fillBetween: null // or number + } + }; + + function init( plot ) { + + function findBottomSeries( s, allseries ) { + + var i; + + for ( i = 0; i < allseries.length; ++i ) { + if ( allseries[ i ].id === s.fillBetween ) { + return allseries[ i ]; + } + } + + if ( typeof s.fillBetween === "number" ) { + if ( s.fillBetween < 0 || s.fillBetween >= allseries.length ) { + return null; + } + return allseries[ s.fillBetween ]; + } + + return null; + } + + function computeFillBottoms( plot, s, datapoints ) { + + if ( s.fillBetween == null ) { + return; + } + + var other = findBottomSeries( s, plot.getData() ); + + if ( !other ) { + return; + } + + var ps = datapoints.pointsize, + points = datapoints.points, + otherps = other.datapoints.pointsize, + otherpoints = other.datapoints.points, + newpoints = [], + px, py, intery, qx, qy, bottom, + withlines = s.lines.show, + withbottom = ps > 2 && datapoints.format[2].y, + withsteps = withlines && s.lines.steps, + fromgap = true, + i = 0, + j = 0, + l, m; + + while ( true ) { + + if ( i >= points.length ) { + break; + } + + l = newpoints.length; + + if ( points[ i ] == null ) { + + // copy gaps + + for ( m = 0; m < ps; ++m ) { + newpoints.push( points[ i + m ] ); + } + + i += ps; + + } else if ( j >= otherpoints.length ) { + + // for lines, we can't use the rest of the points + + if ( !withlines ) { + for ( m = 0; m < ps; ++m ) { + newpoints.push( points[ i + m ] ); + } + } + + i += ps; + + } else if ( otherpoints[ j ] == null ) { + + // oops, got a gap + + for ( m = 0; m < ps; ++m ) { + newpoints.push( null ); + } + + fromgap = true; + j += otherps; + + } else { + + // cases where we actually got two points + + px = points[ i ]; + py = points[ i + 1 ]; + qx = otherpoints[ j ]; + qy = otherpoints[ j + 1 ]; + bottom = 0; + + if ( px === qx ) { + + for ( m = 0; m < ps; ++m ) { + newpoints.push( points[ i + m ] ); + } + + //newpoints[ l + 1 ] += qy; + bottom = qy; + + i += ps; + j += otherps; + + } else if ( px > qx ) { + + // we got past point below, might need to + // insert interpolated extra point + + if ( withlines && i > 0 && points[ i - ps ] != null ) { + intery = py + ( points[ i - ps + 1 ] - py ) * ( qx - px ) / ( points[ i - ps ] - px ); + newpoints.push( qx ); + newpoints.push( intery ); + for ( m = 2; m < ps; ++m ) { + newpoints.push( points[ i + m ] ); + } + bottom = qy; + } + + j += otherps; + + } else { // px < qx + + // if we come from a gap, we just skip this point + + if ( fromgap && withlines ) { + i += ps; + continue; + } + + for ( m = 0; m < ps; ++m ) { + newpoints.push( points[ i + m ] ); + } + + // we might be able to interpolate a point below, + // this can give us a better y + + if ( withlines && j > 0 && otherpoints[ j - otherps ] != null ) { + bottom = qy + ( otherpoints[ j - otherps + 1 ] - qy ) * ( px - qx ) / ( otherpoints[ j - otherps ] - qx ); + } + + //newpoints[l + 1] += bottom; + + i += ps; + } + + fromgap = false; + + if ( l !== newpoints.length && withbottom ) { + newpoints[ l + 2 ] = bottom; + } + } + + // maintain the line steps invariant + + if ( withsteps && l !== newpoints.length && l > 0 && + newpoints[ l ] !== null && + newpoints[ l ] !== newpoints[ l - ps ] && + newpoints[ l + 1 ] !== newpoints[ l - ps + 1 ] ) { + for (m = 0; m < ps; ++m) { + newpoints[ l + ps + m ] = newpoints[ l + m ]; + } + newpoints[ l + 1 ] = newpoints[ l - ps + 1 ]; + } + } + + datapoints.points = newpoints; + } + + plot.hooks.processDatapoints.push( computeFillBottoms ); + } + + $.plot.plugins.push({ + init: init, + options: options, + name: "fillbetween", + version: "1.0" + }); + +})(jQuery); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/jquery.flot.image.js b/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/jquery.flot.image.js new file mode 100644 index 0000000000000..625a03571d270 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/jquery.flot.image.js @@ -0,0 +1,241 @@ +/* Flot plugin for plotting images. + +Copyright (c) 2007-2014 IOLA and Ole Laursen. +Licensed under the MIT license. + +The data syntax is [ [ image, x1, y1, x2, y2 ], ... ] where (x1, y1) and +(x2, y2) are where you intend the two opposite corners of the image to end up +in the plot. Image must be a fully loaded Javascript image (you can make one +with new Image()). If the image is not complete, it's skipped when plotting. + +There are two helpers included for retrieving images. The easiest work the way +that you put in URLs instead of images in the data, like this: + + [ "myimage.png", 0, 0, 10, 10 ] + +Then call $.plot.image.loadData( data, options, callback ) where data and +options are the same as you pass in to $.plot. This loads the images, replaces +the URLs in the data with the corresponding images and calls "callback" when +all images are loaded (or failed loading). In the callback, you can then call +$.plot with the data set. See the included example. + +A more low-level helper, $.plot.image.load(urls, callback) is also included. +Given a list of URLs, it calls callback with an object mapping from URL to +Image object when all images are loaded or have failed loading. + +The plugin supports these options: + + series: { + images: { + show: boolean + anchor: "corner" or "center" + alpha: [ 0, 1 ] + } + } + +They can be specified for a specific series: + + $.plot( $("#placeholder"), [{ + data: [ ... ], + images: { ... } + ]) + +Note that because the data format is different from usual data points, you +can't use images with anything else in a specific data series. + +Setting "anchor" to "center" causes the pixels in the image to be anchored at +the corner pixel centers inside of at the pixel corners, effectively letting +half a pixel stick out to each side in the plot. + +A possible future direction could be support for tiling for large images (like +Google Maps). + +*/ + +(function ($) { + var options = { + series: { + images: { + show: false, + alpha: 1, + anchor: "corner" // or "center" + } + } + }; + + $.plot.image = {}; + + $.plot.image.loadDataImages = function (series, options, callback) { + var urls = [], points = []; + + var defaultShow = options.series.images.show; + + $.each(series, function (i, s) { + if (!(defaultShow || s.images.show)) + return; + + if (s.data) + s = s.data; + + $.each(s, function (i, p) { + if (typeof p[0] == "string") { + urls.push(p[0]); + points.push(p); + } + }); + }); + + $.plot.image.load(urls, function (loadedImages) { + $.each(points, function (i, p) { + var url = p[0]; + if (loadedImages[url]) + p[0] = loadedImages[url]; + }); + + callback(); + }); + } + + $.plot.image.load = function (urls, callback) { + var missing = urls.length, loaded = {}; + if (missing == 0) + callback({}); + + $.each(urls, function (i, url) { + var handler = function () { + --missing; + + loaded[url] = this; + + if (missing == 0) + callback(loaded); + }; + + $('').load(handler).error(handler).attr('src', url); + }); + }; + + function drawSeries(plot, ctx, series) { + var plotOffset = plot.getPlotOffset(); + + if (!series.images || !series.images.show) + return; + + var points = series.datapoints.points, + ps = series.datapoints.pointsize; + + for (var i = 0; i < points.length; i += ps) { + var img = points[i], + x1 = points[i + 1], y1 = points[i + 2], + x2 = points[i + 3], y2 = points[i + 4], + xaxis = series.xaxis, yaxis = series.yaxis, + tmp; + + // actually we should check img.complete, but it + // appears to be a somewhat unreliable indicator in + // IE6 (false even after load event) + if (!img || img.width <= 0 || img.height <= 0) + continue; + + if (x1 > x2) { + tmp = x2; + x2 = x1; + x1 = tmp; + } + if (y1 > y2) { + tmp = y2; + y2 = y1; + y1 = tmp; + } + + // if the anchor is at the center of the pixel, expand the + // image by 1/2 pixel in each direction + if (series.images.anchor == "center") { + tmp = 0.5 * (x2-x1) / (img.width - 1); + x1 -= tmp; + x2 += tmp; + tmp = 0.5 * (y2-y1) / (img.height - 1); + y1 -= tmp; + y2 += tmp; + } + + // clip + if (x1 == x2 || y1 == y2 || + x1 >= xaxis.max || x2 <= xaxis.min || + y1 >= yaxis.max || y2 <= yaxis.min) + continue; + + var sx1 = 0, sy1 = 0, sx2 = img.width, sy2 = img.height; + if (x1 < xaxis.min) { + sx1 += (sx2 - sx1) * (xaxis.min - x1) / (x2 - x1); + x1 = xaxis.min; + } + + if (x2 > xaxis.max) { + sx2 += (sx2 - sx1) * (xaxis.max - x2) / (x2 - x1); + x2 = xaxis.max; + } + + if (y1 < yaxis.min) { + sy2 += (sy1 - sy2) * (yaxis.min - y1) / (y2 - y1); + y1 = yaxis.min; + } + + if (y2 > yaxis.max) { + sy1 += (sy1 - sy2) * (yaxis.max - y2) / (y2 - y1); + y2 = yaxis.max; + } + + x1 = xaxis.p2c(x1); + x2 = xaxis.p2c(x2); + y1 = yaxis.p2c(y1); + y2 = yaxis.p2c(y2); + + // the transformation may have swapped us + if (x1 > x2) { + tmp = x2; + x2 = x1; + x1 = tmp; + } + if (y1 > y2) { + tmp = y2; + y2 = y1; + y1 = tmp; + } + + tmp = ctx.globalAlpha; + ctx.globalAlpha *= series.images.alpha; + ctx.drawImage(img, + sx1, sy1, sx2 - sx1, sy2 - sy1, + x1 + plotOffset.left, y1 + plotOffset.top, + x2 - x1, y2 - y1); + ctx.globalAlpha = tmp; + } + } + + function processRawData(plot, series, data, datapoints) { + if (!series.images.show) + return; + + // format is Image, x1, y1, x2, y2 (opposite corners) + datapoints.format = [ + { required: true }, + { x: true, number: true, required: true }, + { y: true, number: true, required: true }, + { x: true, number: true, required: true }, + { y: true, number: true, required: true } + ]; + } + + function init(plot) { + plot.hooks.processRawData.push(processRawData); + plot.hooks.drawSeries.push(drawSeries); + } + + $.plot.plugins.push({ + init: init, + options: options, + name: 'image', + version: '1.1' + }); +})(jQuery); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/jquery.flot.js b/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/jquery.flot.js new file mode 100644 index 0000000000000..39f3e4cf3efe5 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/jquery.flot.js @@ -0,0 +1,3168 @@ +/* Javascript plotting library for jQuery, version 0.8.3. + +Copyright (c) 2007-2014 IOLA and Ole Laursen. +Licensed under the MIT license. + +*/ + +// first an inline dependency, jquery.colorhelpers.js, we inline it here +// for convenience + +/* Plugin for jQuery for working with colors. + * + * Version 1.1. + * + * Inspiration from jQuery color animation plugin by John Resig. + * + * Released under the MIT license by Ole Laursen, October 2009. + * + * Examples: + * + * $.color.parse("#fff").scale('rgb', 0.25).add('a', -0.5).toString() + * var c = $.color.extract($("#mydiv"), 'background-color'); + * console.log(c.r, c.g, c.b, c.a); + * $.color.make(100, 50, 25, 0.4).toString() // returns "rgba(100,50,25,0.4)" + * + * Note that .scale() and .add() return the same modified object + * instead of making a new one. + * + * V. 1.1: Fix error handling so e.g. parsing an empty string does + * produce a color rather than just crashing. + */ +(function($){$.color={};$.color.make=function(r,g,b,a){var o={};o.r=r||0;o.g=g||0;o.b=b||0;o.a=a!=null?a:1;o.add=function(c,d){for(var i=0;i=1){return"rgb("+[o.r,o.g,o.b].join(",")+")"}else{return"rgba("+[o.r,o.g,o.b,o.a].join(",")+")"}};o.normalize=function(){function clamp(min,value,max){return valuemax?max:value}o.r=clamp(0,parseInt(o.r),255);o.g=clamp(0,parseInt(o.g),255);o.b=clamp(0,parseInt(o.b),255);o.a=clamp(0,o.a,1);return o};o.clone=function(){return $.color.make(o.r,o.b,o.g,o.a)};return o.normalize()};$.color.extract=function(elem,css){var c;do{c=elem.css(css).toLowerCase();if(c!=""&&c!="transparent")break;elem=elem.parent()}while(elem.length&&!$.nodeName(elem.get(0),"body"));if(c=="rgba(0, 0, 0, 0)")c="transparent";return $.color.parse(c)};$.color.parse=function(str){var res,m=$.color.make;if(res=/rgb\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*\)/.exec(str))return m(parseInt(res[1],10),parseInt(res[2],10),parseInt(res[3],10));if(res=/rgba\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]+(?:\.[0-9]+)?)\s*\)/.exec(str))return m(parseInt(res[1],10),parseInt(res[2],10),parseInt(res[3],10),parseFloat(res[4]));if(res=/rgb\(\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*\)/.exec(str))return m(parseFloat(res[1])*2.55,parseFloat(res[2])*2.55,parseFloat(res[3])*2.55);if(res=/rgba\(\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\s*\)/.exec(str))return m(parseFloat(res[1])*2.55,parseFloat(res[2])*2.55,parseFloat(res[3])*2.55,parseFloat(res[4]));if(res=/#([a-fA-F0-9]{2})([a-fA-F0-9]{2})([a-fA-F0-9]{2})/.exec(str))return m(parseInt(res[1],16),parseInt(res[2],16),parseInt(res[3],16));if(res=/#([a-fA-F0-9])([a-fA-F0-9])([a-fA-F0-9])/.exec(str))return m(parseInt(res[1]+res[1],16),parseInt(res[2]+res[2],16),parseInt(res[3]+res[3],16));var name=$.trim(str).toLowerCase();if(name=="transparent")return m(255,255,255,0);else{res=lookupColors[name]||[0,0,0];return m(res[0],res[1],res[2])}};var lookupColors={aqua:[0,255,255],azure:[240,255,255],beige:[245,245,220],black:[0,0,0],blue:[0,0,255],brown:[165,42,42],cyan:[0,255,255],darkblue:[0,0,139],darkcyan:[0,139,139],darkgrey:[169,169,169],darkgreen:[0,100,0],darkkhaki:[189,183,107],darkmagenta:[139,0,139],darkolivegreen:[85,107,47],darkorange:[255,140,0],darkorchid:[153,50,204],darkred:[139,0,0],darksalmon:[233,150,122],darkviolet:[148,0,211],fuchsia:[255,0,255],gold:[255,215,0],green:[0,128,0],indigo:[75,0,130],khaki:[240,230,140],lightblue:[173,216,230],lightcyan:[224,255,255],lightgreen:[144,238,144],lightgrey:[211,211,211],lightpink:[255,182,193],lightyellow:[255,255,224],lime:[0,255,0],magenta:[255,0,255],maroon:[128,0,0],navy:[0,0,128],olive:[128,128,0],orange:[255,165,0],pink:[255,192,203],purple:[128,0,128],violet:[128,0,128],red:[255,0,0],silver:[192,192,192],white:[255,255,255],yellow:[255,255,0]}})(jQuery); + +// the actual Flot code +(function($) { + + // Cache the prototype hasOwnProperty for faster access + + var hasOwnProperty = Object.prototype.hasOwnProperty; + + // A shim to provide 'detach' to jQuery versions prior to 1.4. Using a DOM + // operation produces the same effect as detach, i.e. removing the element + // without touching its jQuery data. + + // Do not merge this into Flot 0.9, since it requires jQuery 1.4.4+. + + if (!$.fn.detach) { + $.fn.detach = function() { + return this.each(function() { + if (this.parentNode) { + this.parentNode.removeChild( this ); + } + }); + }; + } + + /////////////////////////////////////////////////////////////////////////// + // The Canvas object is a wrapper around an HTML5 tag. + // + // @constructor + // @param {string} cls List of classes to apply to the canvas. + // @param {element} container Element onto which to append the canvas. + // + // Requiring a container is a little iffy, but unfortunately canvas + // operations don't work unless the canvas is attached to the DOM. + + function Canvas(cls, container) { + + var element = container.children("." + cls)[0]; + + if (element == null) { + + element = document.createElement("canvas"); + element.className = cls; + + $(element).css({ direction: "ltr", position: "absolute", left: 0, top: 0 }) + .appendTo(container); + + // If HTML5 Canvas isn't available, fall back to [Ex|Flash]canvas + + if (!element.getContext) { + if (window.G_vmlCanvasManager) { + element = window.G_vmlCanvasManager.initElement(element); + } else { + throw new Error("Canvas is not available. If you're using IE with a fall-back such as Excanvas, then there's either a mistake in your conditional include, or the page has no DOCTYPE and is rendering in Quirks Mode."); + } + } + } + + this.element = element; + + var context = this.context = element.getContext("2d"); + + // Determine the screen's ratio of physical to device-independent + // pixels. This is the ratio between the canvas width that the browser + // advertises and the number of pixels actually present in that space. + + // The iPhone 4, for example, has a device-independent width of 320px, + // but its screen is actually 640px wide. It therefore has a pixel + // ratio of 2, while most normal devices have a ratio of 1. + + var devicePixelRatio = window.devicePixelRatio || 1, + backingStoreRatio = + context.webkitBackingStorePixelRatio || + context.mozBackingStorePixelRatio || + context.msBackingStorePixelRatio || + context.oBackingStorePixelRatio || + context.backingStorePixelRatio || 1; + + this.pixelRatio = devicePixelRatio / backingStoreRatio; + + // Size the canvas to match the internal dimensions of its container + + this.resize(container.width(), container.height()); + + // Collection of HTML div layers for text overlaid onto the canvas + + this.textContainer = null; + this.text = {}; + + // Cache of text fragments and metrics, so we can avoid expensively + // re-calculating them when the plot is re-rendered in a loop. + + this._textCache = {}; + } + + // Resizes the canvas to the given dimensions. + // + // @param {number} width New width of the canvas, in pixels. + // @param {number} width New height of the canvas, in pixels. + + Canvas.prototype.resize = function(width, height) { + + if (width <= 0 || height <= 0) { + throw new Error("Invalid dimensions for plot, width = " + width + ", height = " + height); + } + + var element = this.element, + context = this.context, + pixelRatio = this.pixelRatio; + + // Resize the canvas, increasing its density based on the display's + // pixel ratio; basically giving it more pixels without increasing the + // size of its element, to take advantage of the fact that retina + // displays have that many more pixels in the same advertised space. + + // Resizing should reset the state (excanvas seems to be buggy though) + + if (this.width != width) { + element.width = width * pixelRatio; + element.style.width = width + "px"; + this.width = width; + } + + if (this.height != height) { + element.height = height * pixelRatio; + element.style.height = height + "px"; + this.height = height; + } + + // Save the context, so we can reset in case we get replotted. The + // restore ensure that we're really back at the initial state, and + // should be safe even if we haven't saved the initial state yet. + + context.restore(); + context.save(); + + // Scale the coordinate space to match the display density; so even though we + // may have twice as many pixels, we still want lines and other drawing to + // appear at the same size; the extra pixels will just make them crisper. + + context.scale(pixelRatio, pixelRatio); + }; + + // Clears the entire canvas area, not including any overlaid HTML text + + Canvas.prototype.clear = function() { + this.context.clearRect(0, 0, this.width, this.height); + }; + + // Finishes rendering the canvas, including managing the text overlay. + + Canvas.prototype.render = function() { + + var cache = this._textCache; + + // For each text layer, add elements marked as active that haven't + // already been rendered, and remove those that are no longer active. + + for (var layerKey in cache) { + if (hasOwnProperty.call(cache, layerKey)) { + + var layer = this.getTextLayer(layerKey), + layerCache = cache[layerKey]; + + layer.hide(); + + for (var styleKey in layerCache) { + if (hasOwnProperty.call(layerCache, styleKey)) { + var styleCache = layerCache[styleKey]; + for (var key in styleCache) { + if (hasOwnProperty.call(styleCache, key)) { + + var positions = styleCache[key].positions; + + for (var i = 0, position; position = positions[i]; i++) { + if (position.active) { + if (!position.rendered) { + layer.append(position.element); + position.rendered = true; + } + } else { + positions.splice(i--, 1); + if (position.rendered) { + position.element.detach(); + } + } + } + + if (positions.length == 0) { + delete styleCache[key]; + } + } + } + } + } + + layer.show(); + } + } + }; + + // Creates (if necessary) and returns the text overlay container. + // + // @param {string} classes String of space-separated CSS classes used to + // uniquely identify the text layer. + // @return {object} The jQuery-wrapped text-layer div. + + Canvas.prototype.getTextLayer = function(classes) { + + var layer = this.text[classes]; + + // Create the text layer if it doesn't exist + + if (layer == null) { + + // Create the text layer container, if it doesn't exist + + if (this.textContainer == null) { + this.textContainer = $("
") + .css({ + position: "absolute", + top: 0, + left: 0, + bottom: 0, + right: 0, + 'font-size': "smaller", + color: "#545454" + }) + .insertAfter(this.element); + } + + layer = this.text[classes] = $("
") + .addClass(classes) + .css({ + position: "absolute", + top: 0, + left: 0, + bottom: 0, + right: 0 + }) + .appendTo(this.textContainer); + } + + return layer; + }; + + // Creates (if necessary) and returns a text info object. + // + // The object looks like this: + // + // { + // width: Width of the text's wrapper div. + // height: Height of the text's wrapper div. + // element: The jQuery-wrapped HTML div containing the text. + // positions: Array of positions at which this text is drawn. + // } + // + // The positions array contains objects that look like this: + // + // { + // active: Flag indicating whether the text should be visible. + // rendered: Flag indicating whether the text is currently visible. + // element: The jQuery-wrapped HTML div containing the text. + // x: X coordinate at which to draw the text. + // y: Y coordinate at which to draw the text. + // } + // + // Each position after the first receives a clone of the original element. + // + // The idea is that that the width, height, and general 'identity' of the + // text is constant no matter where it is placed; the placements are a + // secondary property. + // + // Canvas maintains a cache of recently-used text info objects; getTextInfo + // either returns the cached element or creates a new entry. + // + // @param {string} layer A string of space-separated CSS classes uniquely + // identifying the layer containing this text. + // @param {string} text Text string to retrieve info for. + // @param {(string|object)=} font Either a string of space-separated CSS + // classes or a font-spec object, defining the text's font and style. + // @param {number=} angle Angle at which to rotate the text, in degrees. + // Angle is currently unused, it will be implemented in the future. + // @param {number=} width Maximum width of the text before it wraps. + // @return {object} a text info object. + + Canvas.prototype.getTextInfo = function(layer, text, font, angle, width) { + + var textStyle, layerCache, styleCache, info; + + // Cast the value to a string, in case we were given a number or such + + text = "" + text; + + // If the font is a font-spec object, generate a CSS font definition + + if (typeof font === "object") { + textStyle = font.style + " " + font.variant + " " + font.weight + " " + font.size + "px/" + font.lineHeight + "px " + font.family; + } else { + textStyle = font; + } + + // Retrieve (or create) the cache for the text's layer and styles + + layerCache = this._textCache[layer]; + + if (layerCache == null) { + layerCache = this._textCache[layer] = {}; + } + + styleCache = layerCache[textStyle]; + + if (styleCache == null) { + styleCache = layerCache[textStyle] = {}; + } + + info = styleCache[text]; + + // If we can't find a matching element in our cache, create a new one + + if (info == null) { + + var element = $("
").html(text) + .css({ + position: "absolute", + 'max-width': width, + top: -9999 + }) + .appendTo(this.getTextLayer(layer)); + + if (typeof font === "object") { + element.css({ + font: textStyle, + color: font.color + }); + } else if (typeof font === "string") { + element.addClass(font); + } + + info = styleCache[text] = { + width: element.outerWidth(true), + height: element.outerHeight(true), + element: element, + positions: [] + }; + + element.detach(); + } + + return info; + }; + + // Adds a text string to the canvas text overlay. + // + // The text isn't drawn immediately; it is marked as rendering, which will + // result in its addition to the canvas on the next render pass. + // + // @param {string} layer A string of space-separated CSS classes uniquely + // identifying the layer containing this text. + // @param {number} x X coordinate at which to draw the text. + // @param {number} y Y coordinate at which to draw the text. + // @param {string} text Text string to draw. + // @param {(string|object)=} font Either a string of space-separated CSS + // classes or a font-spec object, defining the text's font and style. + // @param {number=} angle Angle at which to rotate the text, in degrees. + // Angle is currently unused, it will be implemented in the future. + // @param {number=} width Maximum width of the text before it wraps. + // @param {string=} halign Horizontal alignment of the text; either "left", + // "center" or "right". + // @param {string=} valign Vertical alignment of the text; either "top", + // "middle" or "bottom". + + Canvas.prototype.addText = function(layer, x, y, text, font, angle, width, halign, valign) { + + var info = this.getTextInfo(layer, text, font, angle, width), + positions = info.positions; + + // Tweak the div's position to match the text's alignment + + if (halign == "center") { + x -= info.width / 2; + } else if (halign == "right") { + x -= info.width; + } + + if (valign == "middle") { + y -= info.height / 2; + } else if (valign == "bottom") { + y -= info.height; + } + + // Determine whether this text already exists at this position. + // If so, mark it for inclusion in the next render pass. + + for (var i = 0, position; position = positions[i]; i++) { + if (position.x == x && position.y == y) { + position.active = true; + return; + } + } + + // If the text doesn't exist at this position, create a new entry + + // For the very first position we'll re-use the original element, + // while for subsequent ones we'll clone it. + + position = { + active: true, + rendered: false, + element: positions.length ? info.element.clone() : info.element, + x: x, + y: y + }; + + positions.push(position); + + // Move the element to its final position within the container + + position.element.css({ + top: Math.round(y), + left: Math.round(x), + 'text-align': halign // In case the text wraps + }); + }; + + // Removes one or more text strings from the canvas text overlay. + // + // If no parameters are given, all text within the layer is removed. + // + // Note that the text is not immediately removed; it is simply marked as + // inactive, which will result in its removal on the next render pass. + // This avoids the performance penalty for 'clear and redraw' behavior, + // where we potentially get rid of all text on a layer, but will likely + // add back most or all of it later, as when redrawing axes, for example. + // + // @param {string} layer A string of space-separated CSS classes uniquely + // identifying the layer containing this text. + // @param {number=} x X coordinate of the text. + // @param {number=} y Y coordinate of the text. + // @param {string=} text Text string to remove. + // @param {(string|object)=} font Either a string of space-separated CSS + // classes or a font-spec object, defining the text's font and style. + // @param {number=} angle Angle at which the text is rotated, in degrees. + // Angle is currently unused, it will be implemented in the future. + + Canvas.prototype.removeText = function(layer, x, y, text, font, angle) { + if (text == null) { + var layerCache = this._textCache[layer]; + if (layerCache != null) { + for (var styleKey in layerCache) { + if (hasOwnProperty.call(layerCache, styleKey)) { + var styleCache = layerCache[styleKey]; + for (var key in styleCache) { + if (hasOwnProperty.call(styleCache, key)) { + var positions = styleCache[key].positions; + for (var i = 0, position; position = positions[i]; i++) { + position.active = false; + } + } + } + } + } + } + } else { + var positions = this.getTextInfo(layer, text, font, angle).positions; + for (var i = 0, position; position = positions[i]; i++) { + if (position.x == x && position.y == y) { + position.active = false; + } + } + } + }; + + /////////////////////////////////////////////////////////////////////////// + // The top-level container for the entire plot. + + function Plot(placeholder, data_, options_, plugins) { + // data is on the form: + // [ series1, series2 ... ] + // where series is either just the data as [ [x1, y1], [x2, y2], ... ] + // or { data: [ [x1, y1], [x2, y2], ... ], label: "some label", ... } + + var series = [], + options = { + // the color theme used for graphs + colors: ["#edc240", "#afd8f8", "#cb4b4b", "#4da74d", "#9440ed"], + legend: { + show: true, + noColumns: 1, // number of colums in legend table + labelFormatter: null, // fn: string -> string + labelBoxBorderColor: "#ccc", // border color for the little label boxes + container: null, // container (as jQuery object) to put legend in, null means default on top of graph + position: "ne", // position of default legend container within plot + margin: 5, // distance from grid edge to default legend container within plot + backgroundColor: null, // null means auto-detect + backgroundOpacity: 0.85, // set to 0 to avoid background + sorted: null // default to no legend sorting + }, + xaxis: { + show: null, // null = auto-detect, true = always, false = never + position: "bottom", // or "top" + mode: null, // null or "time" + font: null, // null (derived from CSS in placeholder) or object like { size: 11, lineHeight: 13, style: "italic", weight: "bold", family: "sans-serif", variant: "small-caps" } + color: null, // base color, labels, ticks + tickColor: null, // possibly different color of ticks, e.g. "rgba(0,0,0,0.15)" + transform: null, // null or f: number -> number to transform axis + inverseTransform: null, // if transform is set, this should be the inverse function + min: null, // min. value to show, null means set automatically + max: null, // max. value to show, null means set automatically + autoscaleMargin: null, // margin in % to add if auto-setting min/max + ticks: null, // either [1, 3] or [[1, "a"], 3] or (fn: axis info -> ticks) or app. number of ticks for auto-ticks + tickFormatter: null, // fn: number -> string + labelWidth: null, // size of tick labels in pixels + labelHeight: null, + reserveSpace: null, // whether to reserve space even if axis isn't shown + tickLength: null, // size in pixels of ticks, or "full" for whole line + alignTicksWithAxis: null, // axis number or null for no sync + tickDecimals: null, // no. of decimals, null means auto + tickSize: null, // number or [number, "unit"] + minTickSize: null // number or [number, "unit"] + }, + yaxis: { + autoscaleMargin: 0.02, + position: "left" // or "right" + }, + xaxes: [], + yaxes: [], + series: { + points: { + show: false, + radius: 3, + lineWidth: 2, // in pixels + fill: true, + fillColor: "#ffffff", + symbol: "circle" // or callback + }, + lines: { + // we don't put in show: false so we can see + // whether lines were actively disabled + lineWidth: 2, // in pixels + fill: false, + fillColor: null, + steps: false + // Omit 'zero', so we can later default its value to + // match that of the 'fill' option. + }, + bars: { + show: false, + lineWidth: 2, // in pixels + barWidth: 1, // in units of the x axis + fill: true, + fillColor: null, + align: "left", // "left", "right", or "center" + horizontal: false, + zero: true + }, + shadowSize: 3, + highlightColor: null + }, + grid: { + show: true, + aboveData: false, + color: "#545454", // primary color used for outline and labels + backgroundColor: null, // null for transparent, else color + borderColor: null, // set if different from the grid color + tickColor: null, // color for the ticks, e.g. "rgba(0,0,0,0.15)" + margin: 0, // distance from the canvas edge to the grid + labelMargin: 5, // in pixels + axisMargin: 8, // in pixels + borderWidth: 2, // in pixels + minBorderMargin: null, // in pixels, null means taken from points radius + markings: null, // array of ranges or fn: axes -> array of ranges + markingsColor: "#f4f4f4", + markingsLineWidth: 2, + // interactive stuff + clickable: false, + hoverable: false, + autoHighlight: true, // highlight in case mouse is near + mouseActiveRadius: 10 // how far the mouse can be away to activate an item + }, + interaction: { + redrawOverlayInterval: 1000/60 // time between updates, -1 means in same flow + }, + hooks: {} + }, + surface = null, // the canvas for the plot itself + overlay = null, // canvas for interactive stuff on top of plot + eventHolder = null, // jQuery object that events should be bound to + ctx = null, octx = null, + xaxes = [], yaxes = [], + plotOffset = { left: 0, right: 0, top: 0, bottom: 0}, + plotWidth = 0, plotHeight = 0, + hooks = { + processOptions: [], + processRawData: [], + processDatapoints: [], + processOffset: [], + drawBackground: [], + drawSeries: [], + draw: [], + bindEvents: [], + drawOverlay: [], + shutdown: [] + }, + plot = this; + + // public functions + plot.setData = setData; + plot.setupGrid = setupGrid; + plot.draw = draw; + plot.getPlaceholder = function() { return placeholder; }; + plot.getCanvas = function() { return surface.element; }; + plot.getPlotOffset = function() { return plotOffset; }; + plot.width = function () { return plotWidth; }; + plot.height = function () { return plotHeight; }; + plot.offset = function () { + var o = eventHolder.offset(); + o.left += plotOffset.left; + o.top += plotOffset.top; + return o; + }; + plot.getData = function () { return series; }; + plot.getAxes = function () { + var res = {}, i; + $.each(xaxes.concat(yaxes), function (_, axis) { + if (axis) + res[axis.direction + (axis.n != 1 ? axis.n : "") + "axis"] = axis; + }); + return res; + }; + plot.getXAxes = function () { return xaxes; }; + plot.getYAxes = function () { return yaxes; }; + plot.c2p = canvasToAxisCoords; + plot.p2c = axisToCanvasCoords; + plot.getOptions = function () { return options; }; + plot.highlight = highlight; + plot.unhighlight = unhighlight; + plot.triggerRedrawOverlay = triggerRedrawOverlay; + plot.pointOffset = function(point) { + return { + left: parseInt(xaxes[axisNumber(point, "x") - 1].p2c(+point.x) + plotOffset.left, 10), + top: parseInt(yaxes[axisNumber(point, "y") - 1].p2c(+point.y) + plotOffset.top, 10) + }; + }; + plot.shutdown = shutdown; + plot.destroy = function () { + shutdown(); + placeholder.removeData("plot").empty(); + + series = []; + options = null; + surface = null; + overlay = null; + eventHolder = null; + ctx = null; + octx = null; + xaxes = []; + yaxes = []; + hooks = null; + highlights = []; + plot = null; + }; + plot.resize = function () { + var width = placeholder.width(), + height = placeholder.height(); + surface.resize(width, height); + overlay.resize(width, height); + }; + + // public attributes + plot.hooks = hooks; + + // initialize + initPlugins(plot); + parseOptions(options_); + setupCanvases(); + setData(data_); + setupGrid(); + draw(); + bindEvents(); + + + function executeHooks(hook, args) { + args = [plot].concat(args); + for (var i = 0; i < hook.length; ++i) + hook[i].apply(this, args); + } + + function initPlugins() { + + // References to key classes, allowing plugins to modify them + + var classes = { + Canvas: Canvas + }; + + for (var i = 0; i < plugins.length; ++i) { + var p = plugins[i]; + p.init(plot, classes); + if (p.options) + $.extend(true, options, p.options); + } + } + + function parseOptions(opts) { + + $.extend(true, options, opts); + + // $.extend merges arrays, rather than replacing them. When less + // colors are provided than the size of the default palette, we + // end up with those colors plus the remaining defaults, which is + // not expected behavior; avoid it by replacing them here. + + if (opts && opts.colors) { + options.colors = opts.colors; + } + + if (options.xaxis.color == null) + options.xaxis.color = $.color.parse(options.grid.color).scale('a', 0.22).toString(); + if (options.yaxis.color == null) + options.yaxis.color = $.color.parse(options.grid.color).scale('a', 0.22).toString(); + + if (options.xaxis.tickColor == null) // grid.tickColor for back-compatibility + options.xaxis.tickColor = options.grid.tickColor || options.xaxis.color; + if (options.yaxis.tickColor == null) // grid.tickColor for back-compatibility + options.yaxis.tickColor = options.grid.tickColor || options.yaxis.color; + + if (options.grid.borderColor == null) + options.grid.borderColor = options.grid.color; + if (options.grid.tickColor == null) + options.grid.tickColor = $.color.parse(options.grid.color).scale('a', 0.22).toString(); + + // Fill in defaults for axis options, including any unspecified + // font-spec fields, if a font-spec was provided. + + // If no x/y axis options were provided, create one of each anyway, + // since the rest of the code assumes that they exist. + + var i, axisOptions, axisCount, + fontSize = placeholder.css("font-size"), + fontSizeDefault = fontSize ? +fontSize.replace("px", "") : 13, + fontDefaults = { + style: placeholder.css("font-style"), + size: Math.round(0.8 * fontSizeDefault), + variant: placeholder.css("font-variant"), + weight: placeholder.css("font-weight"), + family: placeholder.css("font-family") + }; + + axisCount = options.xaxes.length || 1; + for (i = 0; i < axisCount; ++i) { + + axisOptions = options.xaxes[i]; + if (axisOptions && !axisOptions.tickColor) { + axisOptions.tickColor = axisOptions.color; + } + + axisOptions = $.extend(true, {}, options.xaxis, axisOptions); + options.xaxes[i] = axisOptions; + + if (axisOptions.font) { + axisOptions.font = $.extend({}, fontDefaults, axisOptions.font); + if (!axisOptions.font.color) { + axisOptions.font.color = axisOptions.color; + } + if (!axisOptions.font.lineHeight) { + axisOptions.font.lineHeight = Math.round(axisOptions.font.size * 1.15); + } + } + } + + axisCount = options.yaxes.length || 1; + for (i = 0; i < axisCount; ++i) { + + axisOptions = options.yaxes[i]; + if (axisOptions && !axisOptions.tickColor) { + axisOptions.tickColor = axisOptions.color; + } + + axisOptions = $.extend(true, {}, options.yaxis, axisOptions); + options.yaxes[i] = axisOptions; + + if (axisOptions.font) { + axisOptions.font = $.extend({}, fontDefaults, axisOptions.font); + if (!axisOptions.font.color) { + axisOptions.font.color = axisOptions.color; + } + if (!axisOptions.font.lineHeight) { + axisOptions.font.lineHeight = Math.round(axisOptions.font.size * 1.15); + } + } + } + + // backwards compatibility, to be removed in future + if (options.xaxis.noTicks && options.xaxis.ticks == null) + options.xaxis.ticks = options.xaxis.noTicks; + if (options.yaxis.noTicks && options.yaxis.ticks == null) + options.yaxis.ticks = options.yaxis.noTicks; + if (options.x2axis) { + options.xaxes[1] = $.extend(true, {}, options.xaxis, options.x2axis); + options.xaxes[1].position = "top"; + // Override the inherit to allow the axis to auto-scale + if (options.x2axis.min == null) { + options.xaxes[1].min = null; + } + if (options.x2axis.max == null) { + options.xaxes[1].max = null; + } + } + if (options.y2axis) { + options.yaxes[1] = $.extend(true, {}, options.yaxis, options.y2axis); + options.yaxes[1].position = "right"; + // Override the inherit to allow the axis to auto-scale + if (options.y2axis.min == null) { + options.yaxes[1].min = null; + } + if (options.y2axis.max == null) { + options.yaxes[1].max = null; + } + } + if (options.grid.coloredAreas) + options.grid.markings = options.grid.coloredAreas; + if (options.grid.coloredAreasColor) + options.grid.markingsColor = options.grid.coloredAreasColor; + if (options.lines) + $.extend(true, options.series.lines, options.lines); + if (options.points) + $.extend(true, options.series.points, options.points); + if (options.bars) + $.extend(true, options.series.bars, options.bars); + if (options.shadowSize != null) + options.series.shadowSize = options.shadowSize; + if (options.highlightColor != null) + options.series.highlightColor = options.highlightColor; + + // save options on axes for future reference + for (i = 0; i < options.xaxes.length; ++i) + getOrCreateAxis(xaxes, i + 1).options = options.xaxes[i]; + for (i = 0; i < options.yaxes.length; ++i) + getOrCreateAxis(yaxes, i + 1).options = options.yaxes[i]; + + // add hooks from options + for (var n in hooks) + if (options.hooks[n] && options.hooks[n].length) + hooks[n] = hooks[n].concat(options.hooks[n]); + + executeHooks(hooks.processOptions, [options]); + } + + function setData(d) { + series = parseData(d); + fillInSeriesOptions(); + processData(); + } + + function parseData(d) { + var res = []; + for (var i = 0; i < d.length; ++i) { + var s = $.extend(true, {}, options.series); + + if (d[i].data != null) { + s.data = d[i].data; // move the data instead of deep-copy + delete d[i].data; + + $.extend(true, s, d[i]); + + d[i].data = s.data; + } + else + s.data = d[i]; + res.push(s); + } + + return res; + } + + function axisNumber(obj, coord) { + var a = obj[coord + "axis"]; + if (typeof a == "object") // if we got a real axis, extract number + a = a.n; + if (typeof a != "number") + a = 1; // default to first axis + return a; + } + + function allAxes() { + // return flat array without annoying null entries + return $.grep(xaxes.concat(yaxes), function (a) { return a; }); + } + + function canvasToAxisCoords(pos) { + // return an object with x/y corresponding to all used axes + var res = {}, i, axis; + for (i = 0; i < xaxes.length; ++i) { + axis = xaxes[i]; + if (axis && axis.used) + res["x" + axis.n] = axis.c2p(pos.left); + } + + for (i = 0; i < yaxes.length; ++i) { + axis = yaxes[i]; + if (axis && axis.used) + res["y" + axis.n] = axis.c2p(pos.top); + } + + if (res.x1 !== undefined) + res.x = res.x1; + if (res.y1 !== undefined) + res.y = res.y1; + + return res; + } + + function axisToCanvasCoords(pos) { + // get canvas coords from the first pair of x/y found in pos + var res = {}, i, axis, key; + + for (i = 0; i < xaxes.length; ++i) { + axis = xaxes[i]; + if (axis && axis.used) { + key = "x" + axis.n; + if (pos[key] == null && axis.n == 1) + key = "x"; + + if (pos[key] != null) { + res.left = axis.p2c(pos[key]); + break; + } + } + } + + for (i = 0; i < yaxes.length; ++i) { + axis = yaxes[i]; + if (axis && axis.used) { + key = "y" + axis.n; + if (pos[key] == null && axis.n == 1) + key = "y"; + + if (pos[key] != null) { + res.top = axis.p2c(pos[key]); + break; + } + } + } + + return res; + } + + function getOrCreateAxis(axes, number) { + if (!axes[number - 1]) + axes[number - 1] = { + n: number, // save the number for future reference + direction: axes == xaxes ? "x" : "y", + options: $.extend(true, {}, axes == xaxes ? options.xaxis : options.yaxis) + }; + + return axes[number - 1]; + } + + function fillInSeriesOptions() { + + var neededColors = series.length, maxIndex = -1, i; + + // Subtract the number of series that already have fixed colors or + // color indexes from the number that we still need to generate. + + for (i = 0; i < series.length; ++i) { + var sc = series[i].color; + if (sc != null) { + neededColors--; + if (typeof sc == "number" && sc > maxIndex) { + maxIndex = sc; + } + } + } + + // If any of the series have fixed color indexes, then we need to + // generate at least as many colors as the highest index. + + if (neededColors <= maxIndex) { + neededColors = maxIndex + 1; + } + + // Generate all the colors, using first the option colors and then + // variations on those colors once they're exhausted. + + var c, colors = [], colorPool = options.colors, + colorPoolSize = colorPool.length, variation = 0; + + for (i = 0; i < neededColors; i++) { + + c = $.color.parse(colorPool[i % colorPoolSize] || "#666"); + + // Each time we exhaust the colors in the pool we adjust + // a scaling factor used to produce more variations on + // those colors. The factor alternates negative/positive + // to produce lighter/darker colors. + + // Reset the variation after every few cycles, or else + // it will end up producing only white or black colors. + + if (i % colorPoolSize == 0 && i) { + if (variation >= 0) { + if (variation < 0.5) { + variation = -variation - 0.2; + } else variation = 0; + } else variation = -variation; + } + + colors[i] = c.scale('rgb', 1 + variation); + } + + // Finalize the series options, filling in their colors + + var colori = 0, s; + for (i = 0; i < series.length; ++i) { + s = series[i]; + + // assign colors + if (s.color == null) { + s.color = colors[colori].toString(); + ++colori; + } + else if (typeof s.color == "number") + s.color = colors[s.color].toString(); + + // turn on lines automatically in case nothing is set + if (s.lines.show == null) { + var v, show = true; + for (v in s) + if (s[v] && s[v].show) { + show = false; + break; + } + if (show) + s.lines.show = true; + } + + // If nothing was provided for lines.zero, default it to match + // lines.fill, since areas by default should extend to zero. + + if (s.lines.zero == null) { + s.lines.zero = !!s.lines.fill; + } + + // setup axes + s.xaxis = getOrCreateAxis(xaxes, axisNumber(s, "x")); + s.yaxis = getOrCreateAxis(yaxes, axisNumber(s, "y")); + } + } + + function processData() { + var topSentry = Number.POSITIVE_INFINITY, + bottomSentry = Number.NEGATIVE_INFINITY, + fakeInfinity = Number.MAX_VALUE, + i, j, k, m, length, + s, points, ps, x, y, axis, val, f, p, + data, format; + + function updateAxis(axis, min, max) { + if (min < axis.datamin && min != -fakeInfinity) + axis.datamin = min; + if (max > axis.datamax && max != fakeInfinity) + axis.datamax = max; + } + + $.each(allAxes(), function (_, axis) { + // init axis + axis.datamin = topSentry; + axis.datamax = bottomSentry; + axis.used = false; + }); + + for (i = 0; i < series.length; ++i) { + s = series[i]; + s.datapoints = { points: [] }; + + executeHooks(hooks.processRawData, [ s, s.data, s.datapoints ]); + } + + // first pass: clean and copy data + for (i = 0; i < series.length; ++i) { + s = series[i]; + + data = s.data; + format = s.datapoints.format; + + if (!format) { + format = []; + // find out how to copy + format.push({ x: true, number: true, required: true }); + format.push({ y: true, number: true, required: true }); + + if (s.bars.show || (s.lines.show && s.lines.fill)) { + var autoscale = !!((s.bars.show && s.bars.zero) || (s.lines.show && s.lines.zero)); + format.push({ y: true, number: true, required: false, defaultValue: 0, autoscale: autoscale }); + if (s.bars.horizontal) { + delete format[format.length - 1].y; + format[format.length - 1].x = true; + } + } + + s.datapoints.format = format; + } + + if (s.datapoints.pointsize != null) + continue; // already filled in + + s.datapoints.pointsize = format.length; + + ps = s.datapoints.pointsize; + points = s.datapoints.points; + + var insertSteps = s.lines.show && s.lines.steps; + s.xaxis.used = s.yaxis.used = true; + + for (j = k = 0; j < data.length; ++j, k += ps) { + p = data[j]; + + var nullify = p == null; + if (!nullify) { + for (m = 0; m < ps; ++m) { + val = p[m]; + f = format[m]; + + if (f) { + if (f.number && val != null) { + val = +val; // convert to number + if (isNaN(val)) + val = null; + else if (val == Infinity) + val = fakeInfinity; + else if (val == -Infinity) + val = -fakeInfinity; + } + + if (val == null) { + if (f.required) + nullify = true; + + if (f.defaultValue != null) + val = f.defaultValue; + } + } + + points[k + m] = val; + } + } + + if (nullify) { + for (m = 0; m < ps; ++m) { + val = points[k + m]; + if (val != null) { + f = format[m]; + // extract min/max info + if (f.autoscale !== false) { + if (f.x) { + updateAxis(s.xaxis, val, val); + } + if (f.y) { + updateAxis(s.yaxis, val, val); + } + } + } + points[k + m] = null; + } + } + else { + // a little bit of line specific stuff that + // perhaps shouldn't be here, but lacking + // better means... + if (insertSteps && k > 0 + && points[k - ps] != null + && points[k - ps] != points[k] + && points[k - ps + 1] != points[k + 1]) { + // copy the point to make room for a middle point + for (m = 0; m < ps; ++m) + points[k + ps + m] = points[k + m]; + + // middle point has same y + points[k + 1] = points[k - ps + 1]; + + // we've added a point, better reflect that + k += ps; + } + } + } + } + + // give the hooks a chance to run + for (i = 0; i < series.length; ++i) { + s = series[i]; + + executeHooks(hooks.processDatapoints, [ s, s.datapoints]); + } + + // second pass: find datamax/datamin for auto-scaling + for (i = 0; i < series.length; ++i) { + s = series[i]; + points = s.datapoints.points; + ps = s.datapoints.pointsize; + format = s.datapoints.format; + + var xmin = topSentry, ymin = topSentry, + xmax = bottomSentry, ymax = bottomSentry; + + for (j = 0; j < points.length; j += ps) { + if (points[j] == null) + continue; + + for (m = 0; m < ps; ++m) { + val = points[j + m]; + f = format[m]; + if (!f || f.autoscale === false || val == fakeInfinity || val == -fakeInfinity) + continue; + + if (f.x) { + if (val < xmin) + xmin = val; + if (val > xmax) + xmax = val; + } + if (f.y) { + if (val < ymin) + ymin = val; + if (val > ymax) + ymax = val; + } + } + } + + if (s.bars.show) { + // make sure we got room for the bar on the dancing floor + var delta; + + switch (s.bars.align) { + case "left": + delta = 0; + break; + case "right": + delta = -s.bars.barWidth; + break; + default: + delta = -s.bars.barWidth / 2; + } + + if (s.bars.horizontal) { + ymin += delta; + ymax += delta + s.bars.barWidth; + } + else { + xmin += delta; + xmax += delta + s.bars.barWidth; + } + } + + updateAxis(s.xaxis, xmin, xmax); + updateAxis(s.yaxis, ymin, ymax); + } + + $.each(allAxes(), function (_, axis) { + if (axis.datamin == topSentry) + axis.datamin = null; + if (axis.datamax == bottomSentry) + axis.datamax = null; + }); + } + + function setupCanvases() { + + // Make sure the placeholder is clear of everything except canvases + // from a previous plot in this container that we'll try to re-use. + + placeholder.css("padding", 0) // padding messes up the positioning + .children().filter(function(){ + return !$(this).hasClass("flot-overlay") && !$(this).hasClass('flot-base'); + }).remove(); + + if (placeholder.css("position") == 'static') + placeholder.css("position", "relative"); // for positioning labels and overlay + + surface = new Canvas("flot-base", placeholder); + overlay = new Canvas("flot-overlay", placeholder); // overlay canvas for interactive features + + ctx = surface.context; + octx = overlay.context; + + // define which element we're listening for events on + eventHolder = $(overlay.element).unbind(); + + // If we're re-using a plot object, shut down the old one + + var existing = placeholder.data("plot"); + + if (existing) { + existing.shutdown(); + overlay.clear(); + } + + // save in case we get replotted + placeholder.data("plot", plot); + } + + function bindEvents() { + // bind events + if (options.grid.hoverable) { + eventHolder.mousemove(onMouseMove); + + // Use bind, rather than .mouseleave, because we officially + // still support jQuery 1.2.6, which doesn't define a shortcut + // for mouseenter or mouseleave. This was a bug/oversight that + // was fixed somewhere around 1.3.x. We can return to using + // .mouseleave when we drop support for 1.2.6. + + eventHolder.bind("mouseleave", onMouseLeave); + } + + if (options.grid.clickable) + eventHolder.click(onClick); + + executeHooks(hooks.bindEvents, [eventHolder]); + } + + function shutdown() { + if (redrawTimeout) + clearTimeout(redrawTimeout); + + eventHolder.unbind("mousemove", onMouseMove); + eventHolder.unbind("mouseleave", onMouseLeave); + eventHolder.unbind("click", onClick); + + executeHooks(hooks.shutdown, [eventHolder]); + } + + function setTransformationHelpers(axis) { + // set helper functions on the axis, assumes plot area + // has been computed already + + function identity(x) { return x; } + + var s, m, t = axis.options.transform || identity, + it = axis.options.inverseTransform; + + // precompute how much the axis is scaling a point + // in canvas space + if (axis.direction == "x") { + s = axis.scale = plotWidth / Math.abs(t(axis.max) - t(axis.min)); + m = Math.min(t(axis.max), t(axis.min)); + } + else { + s = axis.scale = plotHeight / Math.abs(t(axis.max) - t(axis.min)); + s = -s; + m = Math.max(t(axis.max), t(axis.min)); + } + + // data point to canvas coordinate + if (t == identity) // slight optimization + axis.p2c = function (p) { return (p - m) * s; }; + else + axis.p2c = function (p) { return (t(p) - m) * s; }; + // canvas coordinate to data point + if (!it) + axis.c2p = function (c) { return m + c / s; }; + else + axis.c2p = function (c) { return it(m + c / s); }; + } + + function measureTickLabels(axis) { + + var opts = axis.options, + ticks = axis.ticks || [], + labelWidth = opts.labelWidth || 0, + labelHeight = opts.labelHeight || 0, + maxWidth = labelWidth || (axis.direction == "x" ? Math.floor(surface.width / (ticks.length || 1)) : null), + legacyStyles = axis.direction + "Axis " + axis.direction + axis.n + "Axis", + layer = "flot-" + axis.direction + "-axis flot-" + axis.direction + axis.n + "-axis " + legacyStyles, + font = opts.font || "flot-tick-label tickLabel"; + + for (var i = 0; i < ticks.length; ++i) { + + var t = ticks[i]; + + if (!t.label) + continue; + + var info = surface.getTextInfo(layer, t.label, font, null, maxWidth); + + labelWidth = Math.max(labelWidth, info.width); + labelHeight = Math.max(labelHeight, info.height); + } + + axis.labelWidth = opts.labelWidth || labelWidth; + axis.labelHeight = opts.labelHeight || labelHeight; + } + + function allocateAxisBoxFirstPhase(axis) { + // find the bounding box of the axis by looking at label + // widths/heights and ticks, make room by diminishing the + // plotOffset; this first phase only looks at one + // dimension per axis, the other dimension depends on the + // other axes so will have to wait + + var lw = axis.labelWidth, + lh = axis.labelHeight, + pos = axis.options.position, + isXAxis = axis.direction === "x", + tickLength = axis.options.tickLength, + axisMargin = options.grid.axisMargin, + padding = options.grid.labelMargin, + innermost = true, + outermost = true, + first = true, + found = false; + + // Determine the axis's position in its direction and on its side + + $.each(isXAxis ? xaxes : yaxes, function(i, a) { + if (a && (a.show || a.reserveSpace)) { + if (a === axis) { + found = true; + } else if (a.options.position === pos) { + if (found) { + outermost = false; + } else { + innermost = false; + } + } + if (!found) { + first = false; + } + } + }); + + // The outermost axis on each side has no margin + + if (outermost) { + axisMargin = 0; + } + + // The ticks for the first axis in each direction stretch across + + if (tickLength == null) { + tickLength = first ? "full" : 5; + } + + if (!isNaN(+tickLength)) + padding += +tickLength; + + if (isXAxis) { + lh += padding; + + if (pos == "bottom") { + plotOffset.bottom += lh + axisMargin; + axis.box = { top: surface.height - plotOffset.bottom, height: lh }; + } + else { + axis.box = { top: plotOffset.top + axisMargin, height: lh }; + plotOffset.top += lh + axisMargin; + } + } + else { + lw += padding; + + if (pos == "left") { + axis.box = { left: plotOffset.left + axisMargin, width: lw }; + plotOffset.left += lw + axisMargin; + } + else { + plotOffset.right += lw + axisMargin; + axis.box = { left: surface.width - plotOffset.right, width: lw }; + } + } + + // save for future reference + axis.position = pos; + axis.tickLength = tickLength; + axis.box.padding = padding; + axis.innermost = innermost; + } + + function allocateAxisBoxSecondPhase(axis) { + // now that all axis boxes have been placed in one + // dimension, we can set the remaining dimension coordinates + if (axis.direction == "x") { + axis.box.left = plotOffset.left - axis.labelWidth / 2; + axis.box.width = surface.width - plotOffset.left - plotOffset.right + axis.labelWidth; + } + else { + axis.box.top = plotOffset.top - axis.labelHeight / 2; + axis.box.height = surface.height - plotOffset.bottom - plotOffset.top + axis.labelHeight; + } + } + + function adjustLayoutForThingsStickingOut() { + // possibly adjust plot offset to ensure everything stays + // inside the canvas and isn't clipped off + + var minMargin = options.grid.minBorderMargin, + axis, i; + + // check stuff from the plot (FIXME: this should just read + // a value from the series, otherwise it's impossible to + // customize) + if (minMargin == null) { + minMargin = 0; + for (i = 0; i < series.length; ++i) + minMargin = Math.max(minMargin, 2 * (series[i].points.radius + series[i].points.lineWidth/2)); + } + + var margins = { + left: minMargin, + right: minMargin, + top: minMargin, + bottom: minMargin + }; + + // check axis labels, note we don't check the actual + // labels but instead use the overall width/height to not + // jump as much around with replots + $.each(allAxes(), function (_, axis) { + if (axis.reserveSpace && axis.ticks && axis.ticks.length) { + if (axis.direction === "x") { + margins.left = Math.max(margins.left, axis.labelWidth / 2); + margins.right = Math.max(margins.right, axis.labelWidth / 2); + } else { + margins.bottom = Math.max(margins.bottom, axis.labelHeight / 2); + margins.top = Math.max(margins.top, axis.labelHeight / 2); + } + } + }); + + plotOffset.left = Math.ceil(Math.max(margins.left, plotOffset.left)); + plotOffset.right = Math.ceil(Math.max(margins.right, plotOffset.right)); + plotOffset.top = Math.ceil(Math.max(margins.top, plotOffset.top)); + plotOffset.bottom = Math.ceil(Math.max(margins.bottom, plotOffset.bottom)); + } + + function setupGrid() { + var i, axes = allAxes(), showGrid = options.grid.show; + + // Initialize the plot's offset from the edge of the canvas + + for (var a in plotOffset) { + var margin = options.grid.margin || 0; + plotOffset[a] = typeof margin == "number" ? margin : margin[a] || 0; + } + + executeHooks(hooks.processOffset, [plotOffset]); + + // If the grid is visible, add its border width to the offset + + for (var a in plotOffset) { + if(typeof(options.grid.borderWidth) == "object") { + plotOffset[a] += showGrid ? options.grid.borderWidth[a] : 0; + } + else { + plotOffset[a] += showGrid ? options.grid.borderWidth : 0; + } + } + + $.each(axes, function (_, axis) { + var axisOpts = axis.options; + axis.show = axisOpts.show == null ? axis.used : axisOpts.show; + axis.reserveSpace = axisOpts.reserveSpace == null ? axis.show : axisOpts.reserveSpace; + setRange(axis); + }); + + if (showGrid) { + + var allocatedAxes = $.grep(axes, function (axis) { + return axis.show || axis.reserveSpace; + }); + + $.each(allocatedAxes, function (_, axis) { + // make the ticks + setupTickGeneration(axis); + setTicks(axis); + snapRangeToTicks(axis, axis.ticks); + // find labelWidth/Height for axis + measureTickLabels(axis); + }); + + // with all dimensions calculated, we can compute the + // axis bounding boxes, start from the outside + // (reverse order) + for (i = allocatedAxes.length - 1; i >= 0; --i) + allocateAxisBoxFirstPhase(allocatedAxes[i]); + + // make sure we've got enough space for things that + // might stick out + adjustLayoutForThingsStickingOut(); + + $.each(allocatedAxes, function (_, axis) { + allocateAxisBoxSecondPhase(axis); + }); + } + + plotWidth = surface.width - plotOffset.left - plotOffset.right; + plotHeight = surface.height - plotOffset.bottom - plotOffset.top; + + // now we got the proper plot dimensions, we can compute the scaling + $.each(axes, function (_, axis) { + setTransformationHelpers(axis); + }); + + if (showGrid) { + drawAxisLabels(); + } + + insertLegend(); + } + + function setRange(axis) { + var opts = axis.options, + min = +(opts.min != null ? opts.min : axis.datamin), + max = +(opts.max != null ? opts.max : axis.datamax), + delta = max - min; + + if (delta == 0.0) { + // degenerate case + var widen = max == 0 ? 1 : 0.01; + + if (opts.min == null) + min -= widen; + // always widen max if we couldn't widen min to ensure we + // don't fall into min == max which doesn't work + if (opts.max == null || opts.min != null) + max += widen; + } + else { + // consider autoscaling + var margin = opts.autoscaleMargin; + if (margin != null) { + if (opts.min == null) { + min -= delta * margin; + // make sure we don't go below zero if all values + // are positive + if (min < 0 && axis.datamin != null && axis.datamin >= 0) + min = 0; + } + if (opts.max == null) { + max += delta * margin; + if (max > 0 && axis.datamax != null && axis.datamax <= 0) + max = 0; + } + } + } + axis.min = min; + axis.max = max; + } + + function setupTickGeneration(axis) { + var opts = axis.options; + + // estimate number of ticks + var noTicks; + if (typeof opts.ticks == "number" && opts.ticks > 0) + noTicks = opts.ticks; + else + // heuristic based on the model a*sqrt(x) fitted to + // some data points that seemed reasonable + noTicks = 0.3 * Math.sqrt(axis.direction == "x" ? surface.width : surface.height); + + var delta = (axis.max - axis.min) / noTicks, + dec = -Math.floor(Math.log(delta) / Math.LN10), + maxDec = opts.tickDecimals; + + if (maxDec != null && dec > maxDec) { + dec = maxDec; + } + + var magn = Math.pow(10, -dec), + norm = delta / magn, // norm is between 1.0 and 10.0 + size; + + if (norm < 1.5) { + size = 1; + } else if (norm < 3) { + size = 2; + // special case for 2.5, requires an extra decimal + if (norm > 2.25 && (maxDec == null || dec + 1 <= maxDec)) { + size = 2.5; + ++dec; + } + } else if (norm < 7.5) { + size = 5; + } else { + size = 10; + } + + size *= magn; + + if (opts.minTickSize != null && size < opts.minTickSize) { + size = opts.minTickSize; + } + + axis.delta = delta; + axis.tickDecimals = Math.max(0, maxDec != null ? maxDec : dec); + axis.tickSize = opts.tickSize || size; + + // Time mode was moved to a plug-in in 0.8, and since so many people use it + // we'll add an especially friendly reminder to make sure they included it. + + if (opts.mode == "time" && !axis.tickGenerator) { + throw new Error("Time mode requires the flot.time plugin."); + } + + // Flot supports base-10 axes; any other mode else is handled by a plug-in, + // like flot.time.js. + + if (!axis.tickGenerator) { + + axis.tickGenerator = function (axis) { + + var ticks = [], + start = floorInBase(axis.min, axis.tickSize), + i = 0, + v = Number.NaN, + prev; + + do { + prev = v; + v = start + i * axis.tickSize; + ticks.push(v); + ++i; + } while (v < axis.max && v != prev); + return ticks; + }; + + axis.tickFormatter = function (value, axis) { + + var factor = axis.tickDecimals ? Math.pow(10, axis.tickDecimals) : 1; + var formatted = "" + Math.round(value * factor) / factor; + + // If tickDecimals was specified, ensure that we have exactly that + // much precision; otherwise default to the value's own precision. + + if (axis.tickDecimals != null) { + var decimal = formatted.indexOf("."); + var precision = decimal == -1 ? 0 : formatted.length - decimal - 1; + if (precision < axis.tickDecimals) { + return (precision ? formatted : formatted + ".") + ("" + factor).substr(1, axis.tickDecimals - precision); + } + } + + return formatted; + }; + } + + if ($.isFunction(opts.tickFormatter)) + axis.tickFormatter = function (v, axis) { return "" + opts.tickFormatter(v, axis); }; + + if (opts.alignTicksWithAxis != null) { + var otherAxis = (axis.direction == "x" ? xaxes : yaxes)[opts.alignTicksWithAxis - 1]; + if (otherAxis && otherAxis.used && otherAxis != axis) { + // consider snapping min/max to outermost nice ticks + var niceTicks = axis.tickGenerator(axis); + if (niceTicks.length > 0) { + if (opts.min == null) + axis.min = Math.min(axis.min, niceTicks[0]); + if (opts.max == null && niceTicks.length > 1) + axis.max = Math.max(axis.max, niceTicks[niceTicks.length - 1]); + } + + axis.tickGenerator = function (axis) { + // copy ticks, scaled to this axis + var ticks = [], v, i; + for (i = 0; i < otherAxis.ticks.length; ++i) { + v = (otherAxis.ticks[i].v - otherAxis.min) / (otherAxis.max - otherAxis.min); + v = axis.min + v * (axis.max - axis.min); + ticks.push(v); + } + return ticks; + }; + + // we might need an extra decimal since forced + // ticks don't necessarily fit naturally + if (!axis.mode && opts.tickDecimals == null) { + var extraDec = Math.max(0, -Math.floor(Math.log(axis.delta) / Math.LN10) + 1), + ts = axis.tickGenerator(axis); + + // only proceed if the tick interval rounded + // with an extra decimal doesn't give us a + // zero at end + if (!(ts.length > 1 && /\..*0$/.test((ts[1] - ts[0]).toFixed(extraDec)))) + axis.tickDecimals = extraDec; + } + } + } + } + + function setTicks(axis) { + var oticks = axis.options.ticks, ticks = []; + if (oticks == null || (typeof oticks == "number" && oticks > 0)) + ticks = axis.tickGenerator(axis); + else if (oticks) { + if ($.isFunction(oticks)) + // generate the ticks + ticks = oticks(axis); + else + ticks = oticks; + } + + // clean up/labelify the supplied ticks, copy them over + var i, v; + axis.ticks = []; + for (i = 0; i < ticks.length; ++i) { + var label = null; + var t = ticks[i]; + if (typeof t == "object") { + v = +t[0]; + if (t.length > 1) + label = t[1]; + } + else + v = +t; + if (label == null) + label = axis.tickFormatter(v, axis); + if (!isNaN(v)) + axis.ticks.push({ v: v, label: label }); + } + } + + function snapRangeToTicks(axis, ticks) { + if (axis.options.autoscaleMargin && ticks.length > 0) { + // snap to ticks + if (axis.options.min == null) + axis.min = Math.min(axis.min, ticks[0].v); + if (axis.options.max == null && ticks.length > 1) + axis.max = Math.max(axis.max, ticks[ticks.length - 1].v); + } + } + + function draw() { + + surface.clear(); + + executeHooks(hooks.drawBackground, [ctx]); + + var grid = options.grid; + + // draw background, if any + if (grid.show && grid.backgroundColor) + drawBackground(); + + if (grid.show && !grid.aboveData) { + drawGrid(); + } + + for (var i = 0; i < series.length; ++i) { + executeHooks(hooks.drawSeries, [ctx, series[i]]); + drawSeries(series[i]); + } + + executeHooks(hooks.draw, [ctx]); + + if (grid.show && grid.aboveData) { + drawGrid(); + } + + surface.render(); + + // A draw implies that either the axes or data have changed, so we + // should probably update the overlay highlights as well. + + triggerRedrawOverlay(); + } + + function extractRange(ranges, coord) { + var axis, from, to, key, axes = allAxes(); + + for (var i = 0; i < axes.length; ++i) { + axis = axes[i]; + if (axis.direction == coord) { + key = coord + axis.n + "axis"; + if (!ranges[key] && axis.n == 1) + key = coord + "axis"; // support x1axis as xaxis + if (ranges[key]) { + from = ranges[key].from; + to = ranges[key].to; + break; + } + } + } + + // backwards-compat stuff - to be removed in future + if (!ranges[key]) { + axis = coord == "x" ? xaxes[0] : yaxes[0]; + from = ranges[coord + "1"]; + to = ranges[coord + "2"]; + } + + // auto-reverse as an added bonus + if (from != null && to != null && from > to) { + var tmp = from; + from = to; + to = tmp; + } + + return { from: from, to: to, axis: axis }; + } + + function drawBackground() { + ctx.save(); + ctx.translate(plotOffset.left, plotOffset.top); + + ctx.fillStyle = getColorOrGradient(options.grid.backgroundColor, plotHeight, 0, "rgba(255, 255, 255, 0)"); + ctx.fillRect(0, 0, plotWidth, plotHeight); + ctx.restore(); + } + + function drawGrid() { + var i, axes, bw, bc; + + ctx.save(); + ctx.translate(plotOffset.left, plotOffset.top); + + // draw markings + var markings = options.grid.markings; + if (markings) { + if ($.isFunction(markings)) { + axes = plot.getAxes(); + // xmin etc. is backwards compatibility, to be + // removed in the future + axes.xmin = axes.xaxis.min; + axes.xmax = axes.xaxis.max; + axes.ymin = axes.yaxis.min; + axes.ymax = axes.yaxis.max; + + markings = markings(axes); + } + + for (i = 0; i < markings.length; ++i) { + var m = markings[i], + xrange = extractRange(m, "x"), + yrange = extractRange(m, "y"); + + // fill in missing + if (xrange.from == null) + xrange.from = xrange.axis.min; + if (xrange.to == null) + xrange.to = xrange.axis.max; + if (yrange.from == null) + yrange.from = yrange.axis.min; + if (yrange.to == null) + yrange.to = yrange.axis.max; + + // clip + if (xrange.to < xrange.axis.min || xrange.from > xrange.axis.max || + yrange.to < yrange.axis.min || yrange.from > yrange.axis.max) + continue; + + xrange.from = Math.max(xrange.from, xrange.axis.min); + xrange.to = Math.min(xrange.to, xrange.axis.max); + yrange.from = Math.max(yrange.from, yrange.axis.min); + yrange.to = Math.min(yrange.to, yrange.axis.max); + + var xequal = xrange.from === xrange.to, + yequal = yrange.from === yrange.to; + + if (xequal && yequal) { + continue; + } + + // then draw + xrange.from = Math.floor(xrange.axis.p2c(xrange.from)); + xrange.to = Math.floor(xrange.axis.p2c(xrange.to)); + yrange.from = Math.floor(yrange.axis.p2c(yrange.from)); + yrange.to = Math.floor(yrange.axis.p2c(yrange.to)); + + if (xequal || yequal) { + var lineWidth = m.lineWidth || options.grid.markingsLineWidth, + subPixel = lineWidth % 2 ? 0.5 : 0; + ctx.beginPath(); + ctx.strokeStyle = m.color || options.grid.markingsColor; + ctx.lineWidth = lineWidth; + if (xequal) { + ctx.moveTo(xrange.to + subPixel, yrange.from); + ctx.lineTo(xrange.to + subPixel, yrange.to); + } else { + ctx.moveTo(xrange.from, yrange.to + subPixel); + ctx.lineTo(xrange.to, yrange.to + subPixel); + } + ctx.stroke(); + } else { + ctx.fillStyle = m.color || options.grid.markingsColor; + ctx.fillRect(xrange.from, yrange.to, + xrange.to - xrange.from, + yrange.from - yrange.to); + } + } + } + + // draw the ticks + axes = allAxes(); + bw = options.grid.borderWidth; + + for (var j = 0; j < axes.length; ++j) { + var axis = axes[j], box = axis.box, + t = axis.tickLength, x, y, xoff, yoff; + if (!axis.show || axis.ticks.length == 0) + continue; + + ctx.lineWidth = 1; + + // find the edges + if (axis.direction == "x") { + x = 0; + if (t == "full") + y = (axis.position == "top" ? 0 : plotHeight); + else + y = box.top - plotOffset.top + (axis.position == "top" ? box.height : 0); + } + else { + y = 0; + if (t == "full") + x = (axis.position == "left" ? 0 : plotWidth); + else + x = box.left - plotOffset.left + (axis.position == "left" ? box.width : 0); + } + + // draw tick bar + if (!axis.innermost) { + ctx.strokeStyle = axis.options.color; + ctx.beginPath(); + xoff = yoff = 0; + if (axis.direction == "x") + xoff = plotWidth + 1; + else + yoff = plotHeight + 1; + + if (ctx.lineWidth == 1) { + if (axis.direction == "x") { + y = Math.floor(y) + 0.5; + } else { + x = Math.floor(x) + 0.5; + } + } + + ctx.moveTo(x, y); + ctx.lineTo(x + xoff, y + yoff); + ctx.stroke(); + } + + // draw ticks + + ctx.strokeStyle = axis.options.tickColor; + + ctx.beginPath(); + for (i = 0; i < axis.ticks.length; ++i) { + var v = axis.ticks[i].v; + + xoff = yoff = 0; + + if (isNaN(v) || v < axis.min || v > axis.max + // skip those lying on the axes if we got a border + || (t == "full" + && ((typeof bw == "object" && bw[axis.position] > 0) || bw > 0) + && (v == axis.min || v == axis.max))) + continue; + + if (axis.direction == "x") { + x = axis.p2c(v); + yoff = t == "full" ? -plotHeight : t; + + if (axis.position == "top") + yoff = -yoff; + } + else { + y = axis.p2c(v); + xoff = t == "full" ? -plotWidth : t; + + if (axis.position == "left") + xoff = -xoff; + } + + if (ctx.lineWidth == 1) { + if (axis.direction == "x") + x = Math.floor(x) + 0.5; + else + y = Math.floor(y) + 0.5; + } + + ctx.moveTo(x, y); + ctx.lineTo(x + xoff, y + yoff); + } + + ctx.stroke(); + } + + + // draw border + if (bw) { + // If either borderWidth or borderColor is an object, then draw the border + // line by line instead of as one rectangle + bc = options.grid.borderColor; + if(typeof bw == "object" || typeof bc == "object") { + if (typeof bw !== "object") { + bw = {top: bw, right: bw, bottom: bw, left: bw}; + } + if (typeof bc !== "object") { + bc = {top: bc, right: bc, bottom: bc, left: bc}; + } + + if (bw.top > 0) { + ctx.strokeStyle = bc.top; + ctx.lineWidth = bw.top; + ctx.beginPath(); + ctx.moveTo(0 - bw.left, 0 - bw.top/2); + ctx.lineTo(plotWidth, 0 - bw.top/2); + ctx.stroke(); + } + + if (bw.right > 0) { + ctx.strokeStyle = bc.right; + ctx.lineWidth = bw.right; + ctx.beginPath(); + ctx.moveTo(plotWidth + bw.right / 2, 0 - bw.top); + ctx.lineTo(plotWidth + bw.right / 2, plotHeight); + ctx.stroke(); + } + + if (bw.bottom > 0) { + ctx.strokeStyle = bc.bottom; + ctx.lineWidth = bw.bottom; + ctx.beginPath(); + ctx.moveTo(plotWidth + bw.right, plotHeight + bw.bottom / 2); + ctx.lineTo(0, plotHeight + bw.bottom / 2); + ctx.stroke(); + } + + if (bw.left > 0) { + ctx.strokeStyle = bc.left; + ctx.lineWidth = bw.left; + ctx.beginPath(); + ctx.moveTo(0 - bw.left/2, plotHeight + bw.bottom); + ctx.lineTo(0- bw.left/2, 0); + ctx.stroke(); + } + } + else { + ctx.lineWidth = bw; + ctx.strokeStyle = options.grid.borderColor; + ctx.strokeRect(-bw/2, -bw/2, plotWidth + bw, plotHeight + bw); + } + } + + ctx.restore(); + } + + function drawAxisLabels() { + + $.each(allAxes(), function (_, axis) { + var box = axis.box, + legacyStyles = axis.direction + "Axis " + axis.direction + axis.n + "Axis", + layer = "flot-" + axis.direction + "-axis flot-" + axis.direction + axis.n + "-axis " + legacyStyles, + font = axis.options.font || "flot-tick-label tickLabel", + tick, x, y, halign, valign; + + // Remove text before checking for axis.show and ticks.length; + // otherwise plugins, like flot-tickrotor, that draw their own + // tick labels will end up with both theirs and the defaults. + + surface.removeText(layer); + + if (!axis.show || axis.ticks.length == 0) + return; + + for (var i = 0; i < axis.ticks.length; ++i) { + + tick = axis.ticks[i]; + if (!tick.label || tick.v < axis.min || tick.v > axis.max) + continue; + + if (axis.direction == "x") { + halign = "center"; + x = plotOffset.left + axis.p2c(tick.v); + if (axis.position == "bottom") { + y = box.top + box.padding; + } else { + y = box.top + box.height - box.padding; + valign = "bottom"; + } + } else { + valign = "middle"; + y = plotOffset.top + axis.p2c(tick.v); + if (axis.position == "left") { + x = box.left + box.width - box.padding; + halign = "right"; + } else { + x = box.left + box.padding; + } + } + + surface.addText(layer, x, y, tick.label, font, null, null, halign, valign); + } + }); + } + + function drawSeries(series) { + if (series.lines.show) + drawSeriesLines(series); + if (series.bars.show) + drawSeriesBars(series); + if (series.points.show) + drawSeriesPoints(series); + } + + function drawSeriesLines(series) { + function plotLine(datapoints, xoffset, yoffset, axisx, axisy) { + var points = datapoints.points, + ps = datapoints.pointsize, + prevx = null, prevy = null; + + ctx.beginPath(); + for (var i = ps; i < points.length; i += ps) { + var x1 = points[i - ps], y1 = points[i - ps + 1], + x2 = points[i], y2 = points[i + 1]; + + if (x1 == null || x2 == null) + continue; + + // clip with ymin + if (y1 <= y2 && y1 < axisy.min) { + if (y2 < axisy.min) + continue; // line segment is outside + // compute new intersection point + x1 = (axisy.min - y1) / (y2 - y1) * (x2 - x1) + x1; + y1 = axisy.min; + } + else if (y2 <= y1 && y2 < axisy.min) { + if (y1 < axisy.min) + continue; + x2 = (axisy.min - y1) / (y2 - y1) * (x2 - x1) + x1; + y2 = axisy.min; + } + + // clip with ymax + if (y1 >= y2 && y1 > axisy.max) { + if (y2 > axisy.max) + continue; + x1 = (axisy.max - y1) / (y2 - y1) * (x2 - x1) + x1; + y1 = axisy.max; + } + else if (y2 >= y1 && y2 > axisy.max) { + if (y1 > axisy.max) + continue; + x2 = (axisy.max - y1) / (y2 - y1) * (x2 - x1) + x1; + y2 = axisy.max; + } + + // clip with xmin + if (x1 <= x2 && x1 < axisx.min) { + if (x2 < axisx.min) + continue; + y1 = (axisx.min - x1) / (x2 - x1) * (y2 - y1) + y1; + x1 = axisx.min; + } + else if (x2 <= x1 && x2 < axisx.min) { + if (x1 < axisx.min) + continue; + y2 = (axisx.min - x1) / (x2 - x1) * (y2 - y1) + y1; + x2 = axisx.min; + } + + // clip with xmax + if (x1 >= x2 && x1 > axisx.max) { + if (x2 > axisx.max) + continue; + y1 = (axisx.max - x1) / (x2 - x1) * (y2 - y1) + y1; + x1 = axisx.max; + } + else if (x2 >= x1 && x2 > axisx.max) { + if (x1 > axisx.max) + continue; + y2 = (axisx.max - x1) / (x2 - x1) * (y2 - y1) + y1; + x2 = axisx.max; + } + + if (x1 != prevx || y1 != prevy) + ctx.moveTo(axisx.p2c(x1) + xoffset, axisy.p2c(y1) + yoffset); + + prevx = x2; + prevy = y2; + ctx.lineTo(axisx.p2c(x2) + xoffset, axisy.p2c(y2) + yoffset); + } + ctx.stroke(); + } + + function plotLineArea(datapoints, axisx, axisy) { + var points = datapoints.points, + ps = datapoints.pointsize, + bottom = Math.min(Math.max(0, axisy.min), axisy.max), + i = 0, top, areaOpen = false, + ypos = 1, segmentStart = 0, segmentEnd = 0; + + // we process each segment in two turns, first forward + // direction to sketch out top, then once we hit the + // end we go backwards to sketch the bottom + while (true) { + if (ps > 0 && i > points.length + ps) + break; + + i += ps; // ps is negative if going backwards + + var x1 = points[i - ps], + y1 = points[i - ps + ypos], + x2 = points[i], y2 = points[i + ypos]; + + if (areaOpen) { + if (ps > 0 && x1 != null && x2 == null) { + // at turning point + segmentEnd = i; + ps = -ps; + ypos = 2; + continue; + } + + if (ps < 0 && i == segmentStart + ps) { + // done with the reverse sweep + ctx.fill(); + areaOpen = false; + ps = -ps; + ypos = 1; + i = segmentStart = segmentEnd + ps; + continue; + } + } + + if (x1 == null || x2 == null) + continue; + + // clip x values + + // clip with xmin + if (x1 <= x2 && x1 < axisx.min) { + if (x2 < axisx.min) + continue; + y1 = (axisx.min - x1) / (x2 - x1) * (y2 - y1) + y1; + x1 = axisx.min; + } + else if (x2 <= x1 && x2 < axisx.min) { + if (x1 < axisx.min) + continue; + y2 = (axisx.min - x1) / (x2 - x1) * (y2 - y1) + y1; + x2 = axisx.min; + } + + // clip with xmax + if (x1 >= x2 && x1 > axisx.max) { + if (x2 > axisx.max) + continue; + y1 = (axisx.max - x1) / (x2 - x1) * (y2 - y1) + y1; + x1 = axisx.max; + } + else if (x2 >= x1 && x2 > axisx.max) { + if (x1 > axisx.max) + continue; + y2 = (axisx.max - x1) / (x2 - x1) * (y2 - y1) + y1; + x2 = axisx.max; + } + + if (!areaOpen) { + // open area + ctx.beginPath(); + ctx.moveTo(axisx.p2c(x1), axisy.p2c(bottom)); + areaOpen = true; + } + + // now first check the case where both is outside + if (y1 >= axisy.max && y2 >= axisy.max) { + ctx.lineTo(axisx.p2c(x1), axisy.p2c(axisy.max)); + ctx.lineTo(axisx.p2c(x2), axisy.p2c(axisy.max)); + continue; + } + else if (y1 <= axisy.min && y2 <= axisy.min) { + ctx.lineTo(axisx.p2c(x1), axisy.p2c(axisy.min)); + ctx.lineTo(axisx.p2c(x2), axisy.p2c(axisy.min)); + continue; + } + + // else it's a bit more complicated, there might + // be a flat maxed out rectangle first, then a + // triangular cutout or reverse; to find these + // keep track of the current x values + var x1old = x1, x2old = x2; + + // clip the y values, without shortcutting, we + // go through all cases in turn + + // clip with ymin + if (y1 <= y2 && y1 < axisy.min && y2 >= axisy.min) { + x1 = (axisy.min - y1) / (y2 - y1) * (x2 - x1) + x1; + y1 = axisy.min; + } + else if (y2 <= y1 && y2 < axisy.min && y1 >= axisy.min) { + x2 = (axisy.min - y1) / (y2 - y1) * (x2 - x1) + x1; + y2 = axisy.min; + } + + // clip with ymax + if (y1 >= y2 && y1 > axisy.max && y2 <= axisy.max) { + x1 = (axisy.max - y1) / (y2 - y1) * (x2 - x1) + x1; + y1 = axisy.max; + } + else if (y2 >= y1 && y2 > axisy.max && y1 <= axisy.max) { + x2 = (axisy.max - y1) / (y2 - y1) * (x2 - x1) + x1; + y2 = axisy.max; + } + + // if the x value was changed we got a rectangle + // to fill + if (x1 != x1old) { + ctx.lineTo(axisx.p2c(x1old), axisy.p2c(y1)); + // it goes to (x1, y1), but we fill that below + } + + // fill triangular section, this sometimes result + // in redundant points if (x1, y1) hasn't changed + // from previous line to, but we just ignore that + ctx.lineTo(axisx.p2c(x1), axisy.p2c(y1)); + ctx.lineTo(axisx.p2c(x2), axisy.p2c(y2)); + + // fill the other rectangle if it's there + if (x2 != x2old) { + ctx.lineTo(axisx.p2c(x2), axisy.p2c(y2)); + ctx.lineTo(axisx.p2c(x2old), axisy.p2c(y2)); + } + } + } + + ctx.save(); + ctx.translate(plotOffset.left, plotOffset.top); + ctx.lineJoin = "round"; + + var lw = series.lines.lineWidth, + sw = series.shadowSize; + // FIXME: consider another form of shadow when filling is turned on + if (lw > 0 && sw > 0) { + // draw shadow as a thick and thin line with transparency + ctx.lineWidth = sw; + ctx.strokeStyle = "rgba(0,0,0,0.1)"; + // position shadow at angle from the mid of line + var angle = Math.PI/18; + plotLine(series.datapoints, Math.sin(angle) * (lw/2 + sw/2), Math.cos(angle) * (lw/2 + sw/2), series.xaxis, series.yaxis); + ctx.lineWidth = sw/2; + plotLine(series.datapoints, Math.sin(angle) * (lw/2 + sw/4), Math.cos(angle) * (lw/2 + sw/4), series.xaxis, series.yaxis); + } + + ctx.lineWidth = lw; + ctx.strokeStyle = series.color; + var fillStyle = getFillStyle(series.lines, series.color, 0, plotHeight); + if (fillStyle) { + ctx.fillStyle = fillStyle; + plotLineArea(series.datapoints, series.xaxis, series.yaxis); + } + + if (lw > 0) + plotLine(series.datapoints, 0, 0, series.xaxis, series.yaxis); + ctx.restore(); + } + + function drawSeriesPoints(series) { + function plotPoints(datapoints, radius, fillStyle, offset, shadow, axisx, axisy, symbol) { + var points = datapoints.points, ps = datapoints.pointsize; + + for (var i = 0; i < points.length; i += ps) { + var x = points[i], y = points[i + 1]; + if (x == null || x < axisx.min || x > axisx.max || y < axisy.min || y > axisy.max) + continue; + + ctx.beginPath(); + x = axisx.p2c(x); + y = axisy.p2c(y) + offset; + if (symbol == "circle") + ctx.arc(x, y, radius, 0, shadow ? Math.PI : Math.PI * 2, false); + else + symbol(ctx, x, y, radius, shadow); + ctx.closePath(); + + if (fillStyle) { + ctx.fillStyle = fillStyle; + ctx.fill(); + } + ctx.stroke(); + } + } + + ctx.save(); + ctx.translate(plotOffset.left, plotOffset.top); + + var lw = series.points.lineWidth, + sw = series.shadowSize, + radius = series.points.radius, + symbol = series.points.symbol; + + // If the user sets the line width to 0, we change it to a very + // small value. A line width of 0 seems to force the default of 1. + // Doing the conditional here allows the shadow setting to still be + // optional even with a lineWidth of 0. + + if( lw == 0 ) + lw = 0.0001; + + if (lw > 0 && sw > 0) { + // draw shadow in two steps + var w = sw / 2; + ctx.lineWidth = w; + ctx.strokeStyle = "rgba(0,0,0,0.1)"; + plotPoints(series.datapoints, radius, null, w + w/2, true, + series.xaxis, series.yaxis, symbol); + + ctx.strokeStyle = "rgba(0,0,0,0.2)"; + plotPoints(series.datapoints, radius, null, w/2, true, + series.xaxis, series.yaxis, symbol); + } + + ctx.lineWidth = lw; + ctx.strokeStyle = series.color; + plotPoints(series.datapoints, radius, + getFillStyle(series.points, series.color), 0, false, + series.xaxis, series.yaxis, symbol); + ctx.restore(); + } + + function drawBar(x, y, b, barLeft, barRight, fillStyleCallback, axisx, axisy, c, horizontal, lineWidth) { + var left, right, bottom, top, + drawLeft, drawRight, drawTop, drawBottom, + tmp; + + // in horizontal mode, we start the bar from the left + // instead of from the bottom so it appears to be + // horizontal rather than vertical + if (horizontal) { + drawBottom = drawRight = drawTop = true; + drawLeft = false; + left = b; + right = x; + top = y + barLeft; + bottom = y + barRight; + + // account for negative bars + if (right < left) { + tmp = right; + right = left; + left = tmp; + drawLeft = true; + drawRight = false; + } + } + else { + drawLeft = drawRight = drawTop = true; + drawBottom = false; + left = x + barLeft; + right = x + barRight; + bottom = b; + top = y; + + // account for negative bars + if (top < bottom) { + tmp = top; + top = bottom; + bottom = tmp; + drawBottom = true; + drawTop = false; + } + } + + // clip + if (right < axisx.min || left > axisx.max || + top < axisy.min || bottom > axisy.max) + return; + + if (left < axisx.min) { + left = axisx.min; + drawLeft = false; + } + + if (right > axisx.max) { + right = axisx.max; + drawRight = false; + } + + if (bottom < axisy.min) { + bottom = axisy.min; + drawBottom = false; + } + + if (top > axisy.max) { + top = axisy.max; + drawTop = false; + } + + left = axisx.p2c(left); + bottom = axisy.p2c(bottom); + right = axisx.p2c(right); + top = axisy.p2c(top); + + // fill the bar + if (fillStyleCallback) { + c.fillStyle = fillStyleCallback(bottom, top); + c.fillRect(left, top, right - left, bottom - top) + } + + // draw outline + if (lineWidth > 0 && (drawLeft || drawRight || drawTop || drawBottom)) { + c.beginPath(); + + // FIXME: inline moveTo is buggy with excanvas + c.moveTo(left, bottom); + if (drawLeft) + c.lineTo(left, top); + else + c.moveTo(left, top); + if (drawTop) + c.lineTo(right, top); + else + c.moveTo(right, top); + if (drawRight) + c.lineTo(right, bottom); + else + c.moveTo(right, bottom); + if (drawBottom) + c.lineTo(left, bottom); + else + c.moveTo(left, bottom); + c.stroke(); + } + } + + function drawSeriesBars(series) { + function plotBars(datapoints, barLeft, barRight, fillStyleCallback, axisx, axisy) { + var points = datapoints.points, ps = datapoints.pointsize; + + for (var i = 0; i < points.length; i += ps) { + if (points[i] == null) + continue; + drawBar(points[i], points[i + 1], points[i + 2], barLeft, barRight, fillStyleCallback, axisx, axisy, ctx, series.bars.horizontal, series.bars.lineWidth); + } + } + + ctx.save(); + ctx.translate(plotOffset.left, plotOffset.top); + + // FIXME: figure out a way to add shadows (for instance along the right edge) + ctx.lineWidth = series.bars.lineWidth; + ctx.strokeStyle = series.color; + + var barLeft; + + switch (series.bars.align) { + case "left": + barLeft = 0; + break; + case "right": + barLeft = -series.bars.barWidth; + break; + default: + barLeft = -series.bars.barWidth / 2; + } + + var fillStyleCallback = series.bars.fill ? function (bottom, top) { return getFillStyle(series.bars, series.color, bottom, top); } : null; + plotBars(series.datapoints, barLeft, barLeft + series.bars.barWidth, fillStyleCallback, series.xaxis, series.yaxis); + ctx.restore(); + } + + function getFillStyle(filloptions, seriesColor, bottom, top) { + var fill = filloptions.fill; + if (!fill) + return null; + + if (filloptions.fillColor) + return getColorOrGradient(filloptions.fillColor, bottom, top, seriesColor); + + var c = $.color.parse(seriesColor); + c.a = typeof fill == "number" ? fill : 0.4; + c.normalize(); + return c.toString(); + } + + function insertLegend() { + + if (options.legend.container != null) { + $(options.legend.container).html(""); + } else { + placeholder.find(".legend").remove(); + } + + if (!options.legend.show) { + return; + } + + var fragments = [], entries = [], rowStarted = false, + lf = options.legend.labelFormatter, s, label; + + // Build a list of legend entries, with each having a label and a color + + for (var i = 0; i < series.length; ++i) { + s = series[i]; + if (s.label) { + label = lf ? lf(s.label, s) : s.label; + if (label) { + entries.push({ + label: label, + color: s.color + }); + } + } + } + + // Sort the legend using either the default or a custom comparator + + if (options.legend.sorted) { + if ($.isFunction(options.legend.sorted)) { + entries.sort(options.legend.sorted); + } else if (options.legend.sorted == "reverse") { + entries.reverse(); + } else { + var ascending = options.legend.sorted != "descending"; + entries.sort(function(a, b) { + return a.label == b.label ? 0 : ( + (a.label < b.label) != ascending ? 1 : -1 // Logical XOR + ); + }); + } + } + + // Generate markup for the list of entries, in their final order + + for (var i = 0; i < entries.length; ++i) { + + var entry = entries[i]; + + if (i % options.legend.noColumns == 0) { + if (rowStarted) + fragments.push(''); + fragments.push(''); + rowStarted = true; + } + + fragments.push( + '
' + + '' + entry.label + '' + ); + } + + if (rowStarted) + fragments.push(''); + + if (fragments.length == 0) + return; + + var table = '' + fragments.join("") + '
'; + if (options.legend.container != null) + $(options.legend.container).html(table); + else { + var pos = "", + p = options.legend.position, + m = options.legend.margin; + if (m[0] == null) + m = [m, m]; + if (p.charAt(0) == "n") + pos += 'top:' + (m[1] + plotOffset.top) + 'px;'; + else if (p.charAt(0) == "s") + pos += 'bottom:' + (m[1] + plotOffset.bottom) + 'px;'; + if (p.charAt(1) == "e") + pos += 'right:' + (m[0] + plotOffset.right) + 'px;'; + else if (p.charAt(1) == "w") + pos += 'left:' + (m[0] + plotOffset.left) + 'px;'; + var legend = $('
' + table.replace('style="', 'style="position:absolute;' + pos +';') + '
').appendTo(placeholder); + if (options.legend.backgroundOpacity != 0.0) { + // put in the transparent background + // separately to avoid blended labels and + // label boxes + var c = options.legend.backgroundColor; + if (c == null) { + c = options.grid.backgroundColor; + if (c && typeof c == "string") + c = $.color.parse(c); + else + c = $.color.extract(legend, 'background-color'); + c.a = 1; + c = c.toString(); + } + var div = legend.children(); + $('
').prependTo(legend).css('opacity', options.legend.backgroundOpacity); + } + } + } + + + // interactive features + + var highlights = [], + redrawTimeout = null; + + // returns the data item the mouse is over, or null if none is found + function findNearbyItem(mouseX, mouseY, seriesFilter) { + var maxDistance = options.grid.mouseActiveRadius, + smallestDistance = maxDistance * maxDistance + 1, + item = null, foundPoint = false, i, j, ps; + + for (i = series.length - 1; i >= 0; --i) { + if (!seriesFilter(series[i])) + continue; + + var s = series[i], + axisx = s.xaxis, + axisy = s.yaxis, + points = s.datapoints.points, + mx = axisx.c2p(mouseX), // precompute some stuff to make the loop faster + my = axisy.c2p(mouseY), + maxx = maxDistance / axisx.scale, + maxy = maxDistance / axisy.scale; + + ps = s.datapoints.pointsize; + // with inverse transforms, we can't use the maxx/maxy + // optimization, sadly + if (axisx.options.inverseTransform) + maxx = Number.MAX_VALUE; + if (axisy.options.inverseTransform) + maxy = Number.MAX_VALUE; + + if (s.lines.show || s.points.show) { + for (j = 0; j < points.length; j += ps) { + var x = points[j], y = points[j + 1]; + if (x == null) + continue; + + // For points and lines, the cursor must be within a + // certain distance to the data point + if (x - mx > maxx || x - mx < -maxx || + y - my > maxy || y - my < -maxy) + continue; + + // We have to calculate distances in pixels, not in + // data units, because the scales of the axes may be different + var dx = Math.abs(axisx.p2c(x) - mouseX), + dy = Math.abs(axisy.p2c(y) - mouseY), + dist = dx * dx + dy * dy; // we save the sqrt + + // use <= to ensure last point takes precedence + // (last generally means on top of) + if (dist < smallestDistance) { + smallestDistance = dist; + item = [i, j / ps]; + } + } + } + + if (s.bars.show && !item) { // no other point can be nearby + + var barLeft, barRight; + + switch (s.bars.align) { + case "left": + barLeft = 0; + break; + case "right": + barLeft = -s.bars.barWidth; + break; + default: + barLeft = -s.bars.barWidth / 2; + } + + barRight = barLeft + s.bars.barWidth; + + for (j = 0; j < points.length; j += ps) { + var x = points[j], y = points[j + 1], b = points[j + 2]; + if (x == null) + continue; + + // for a bar graph, the cursor must be inside the bar + if (series[i].bars.horizontal ? + (mx <= Math.max(b, x) && mx >= Math.min(b, x) && + my >= y + barLeft && my <= y + barRight) : + (mx >= x + barLeft && mx <= x + barRight && + my >= Math.min(b, y) && my <= Math.max(b, y))) + item = [i, j / ps]; + } + } + } + + if (item) { + i = item[0]; + j = item[1]; + ps = series[i].datapoints.pointsize; + + return { datapoint: series[i].datapoints.points.slice(j * ps, (j + 1) * ps), + dataIndex: j, + series: series[i], + seriesIndex: i }; + } + + return null; + } + + function onMouseMove(e) { + if (options.grid.hoverable) + triggerClickHoverEvent("plothover", e, + function (s) { return s["hoverable"] != false; }); + } + + function onMouseLeave(e) { + if (options.grid.hoverable) + triggerClickHoverEvent("plothover", e, + function (s) { return false; }); + } + + function onClick(e) { + triggerClickHoverEvent("plotclick", e, + function (s) { return s["clickable"] != false; }); + } + + // trigger click or hover event (they send the same parameters + // so we share their code) + function triggerClickHoverEvent(eventname, event, seriesFilter) { + var offset = eventHolder.offset(), + canvasX = event.pageX - offset.left - plotOffset.left, + canvasY = event.pageY - offset.top - plotOffset.top, + pos = canvasToAxisCoords({ left: canvasX, top: canvasY }); + + pos.pageX = event.pageX; + pos.pageY = event.pageY; + + var item = findNearbyItem(canvasX, canvasY, seriesFilter); + + if (item) { + // fill in mouse pos for any listeners out there + item.pageX = parseInt(item.series.xaxis.p2c(item.datapoint[0]) + offset.left + plotOffset.left, 10); + item.pageY = parseInt(item.series.yaxis.p2c(item.datapoint[1]) + offset.top + plotOffset.top, 10); + } + + if (options.grid.autoHighlight) { + // clear auto-highlights + for (var i = 0; i < highlights.length; ++i) { + var h = highlights[i]; + if (h.auto == eventname && + !(item && h.series == item.series && + h.point[0] == item.datapoint[0] && + h.point[1] == item.datapoint[1])) + unhighlight(h.series, h.point); + } + + if (item) + highlight(item.series, item.datapoint, eventname); + } + + placeholder.trigger(eventname, [ pos, item ]); + } + + function triggerRedrawOverlay() { + var t = options.interaction.redrawOverlayInterval; + if (t == -1) { // skip event queue + drawOverlay(); + return; + } + + if (!redrawTimeout) + redrawTimeout = setTimeout(drawOverlay, t); + } + + function drawOverlay() { + redrawTimeout = null; + + // draw highlights + octx.save(); + overlay.clear(); + octx.translate(plotOffset.left, plotOffset.top); + + var i, hi; + for (i = 0; i < highlights.length; ++i) { + hi = highlights[i]; + + if (hi.series.bars.show) + drawBarHighlight(hi.series, hi.point); + else + drawPointHighlight(hi.series, hi.point); + } + octx.restore(); + + executeHooks(hooks.drawOverlay, [octx]); + } + + function highlight(s, point, auto) { + if (typeof s == "number") + s = series[s]; + + if (typeof point == "number") { + var ps = s.datapoints.pointsize; + point = s.datapoints.points.slice(ps * point, ps * (point + 1)); + } + + var i = indexOfHighlight(s, point); + if (i == -1) { + highlights.push({ series: s, point: point, auto: auto }); + + triggerRedrawOverlay(); + } + else if (!auto) + highlights[i].auto = false; + } + + function unhighlight(s, point) { + if (s == null && point == null) { + highlights = []; + triggerRedrawOverlay(); + return; + } + + if (typeof s == "number") + s = series[s]; + + if (typeof point == "number") { + var ps = s.datapoints.pointsize; + point = s.datapoints.points.slice(ps * point, ps * (point + 1)); + } + + var i = indexOfHighlight(s, point); + if (i != -1) { + highlights.splice(i, 1); + + triggerRedrawOverlay(); + } + } + + function indexOfHighlight(s, p) { + for (var i = 0; i < highlights.length; ++i) { + var h = highlights[i]; + if (h.series == s && h.point[0] == p[0] + && h.point[1] == p[1]) + return i; + } + return -1; + } + + function drawPointHighlight(series, point) { + var x = point[0], y = point[1], + axisx = series.xaxis, axisy = series.yaxis, + highlightColor = (typeof series.highlightColor === "string") ? series.highlightColor : $.color.parse(series.color).scale('a', 0.5).toString(); + + if (x < axisx.min || x > axisx.max || y < axisy.min || y > axisy.max) + return; + + var pointRadius = series.points.radius + series.points.lineWidth / 2; + octx.lineWidth = pointRadius; + octx.strokeStyle = highlightColor; + var radius = 1.5 * pointRadius; + x = axisx.p2c(x); + y = axisy.p2c(y); + + octx.beginPath(); + if (series.points.symbol == "circle") + octx.arc(x, y, radius, 0, 2 * Math.PI, false); + else + series.points.symbol(octx, x, y, radius, false); + octx.closePath(); + octx.stroke(); + } + + function drawBarHighlight(series, point) { + var highlightColor = (typeof series.highlightColor === "string") ? series.highlightColor : $.color.parse(series.color).scale('a', 0.5).toString(), + fillStyle = highlightColor, + barLeft; + + switch (series.bars.align) { + case "left": + barLeft = 0; + break; + case "right": + barLeft = -series.bars.barWidth; + break; + default: + barLeft = -series.bars.barWidth / 2; + } + + octx.lineWidth = series.bars.lineWidth; + octx.strokeStyle = highlightColor; + + drawBar(point[0], point[1], point[2] || 0, barLeft, barLeft + series.bars.barWidth, + function () { return fillStyle; }, series.xaxis, series.yaxis, octx, series.bars.horizontal, series.bars.lineWidth); + } + + function getColorOrGradient(spec, bottom, top, defaultColor) { + if (typeof spec == "string") + return spec; + else { + // assume this is a gradient spec; IE currently only + // supports a simple vertical gradient properly, so that's + // what we support too + var gradient = ctx.createLinearGradient(0, top, 0, bottom); + + for (var i = 0, l = spec.colors.length; i < l; ++i) { + var c = spec.colors[i]; + if (typeof c != "string") { + var co = $.color.parse(defaultColor); + if (c.brightness != null) + co = co.scale('rgb', c.brightness); + if (c.opacity != null) + co.a *= c.opacity; + c = co.toString(); + } + gradient.addColorStop(i / (l - 1), c); + } + + return gradient; + } + } + } + + // Add the plot function to the top level of the jQuery object + + $.plot = function(placeholder, data, options) { + //var t0 = new Date(); + var plot = new Plot($(placeholder), data, options, $.plot.plugins); + //(window.console ? console.log : alert)("time used (msecs): " + ((new Date()).getTime() - t0.getTime())); + return plot; + }; + + $.plot.version = "0.8.3"; + + $.plot.plugins = []; + + // Also add the plot function as a chainable property + + $.fn.plot = function(data, options) { + return this.each(function() { + $.plot(this, data, options); + }); + }; + + // round to nearby lower multiple of base + function floorInBase(n, base) { + return base * Math.floor(n / base); + } + +})(jQuery); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/jquery.flot.navigate.js b/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/jquery.flot.navigate.js new file mode 100644 index 0000000000000..13fb7f17d04b2 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/jquery.flot.navigate.js @@ -0,0 +1,346 @@ +/* Flot plugin for adding the ability to pan and zoom the plot. + +Copyright (c) 2007-2014 IOLA and Ole Laursen. +Licensed under the MIT license. + +The default behaviour is double click and scrollwheel up/down to zoom in, drag +to pan. The plugin defines plot.zoom({ center }), plot.zoomOut() and +plot.pan( offset ) so you easily can add custom controls. It also fires +"plotpan" and "plotzoom" events, useful for synchronizing plots. + +The plugin supports these options: + + zoom: { + interactive: false + trigger: "dblclick" // or "click" for single click + amount: 1.5 // 2 = 200% (zoom in), 0.5 = 50% (zoom out) + } + + pan: { + interactive: false + cursor: "move" // CSS mouse cursor value used when dragging, e.g. "pointer" + frameRate: 20 + } + + xaxis, yaxis, x2axis, y2axis: { + zoomRange: null // or [ number, number ] (min range, max range) or false + panRange: null // or [ number, number ] (min, max) or false + } + +"interactive" enables the built-in drag/click behaviour. If you enable +interactive for pan, then you'll have a basic plot that supports moving +around; the same for zoom. + +"amount" specifies the default amount to zoom in (so 1.5 = 150%) relative to +the current viewport. + +"cursor" is a standard CSS mouse cursor string used for visual feedback to the +user when dragging. + +"frameRate" specifies the maximum number of times per second the plot will +update itself while the user is panning around on it (set to null to disable +intermediate pans, the plot will then not update until the mouse button is +released). + +"zoomRange" is the interval in which zooming can happen, e.g. with zoomRange: +[1, 100] the zoom will never scale the axis so that the difference between min +and max is smaller than 1 or larger than 100. You can set either end to null +to ignore, e.g. [1, null]. If you set zoomRange to false, zooming on that axis +will be disabled. + +"panRange" confines the panning to stay within a range, e.g. with panRange: +[-10, 20] panning stops at -10 in one end and at 20 in the other. Either can +be null, e.g. [-10, null]. If you set panRange to false, panning on that axis +will be disabled. + +Example API usage: + + plot = $.plot(...); + + // zoom default amount in on the pixel ( 10, 20 ) + plot.zoom({ center: { left: 10, top: 20 } }); + + // zoom out again + plot.zoomOut({ center: { left: 10, top: 20 } }); + + // zoom 200% in on the pixel (10, 20) + plot.zoom({ amount: 2, center: { left: 10, top: 20 } }); + + // pan 100 pixels to the left and 20 down + plot.pan({ left: -100, top: 20 }) + +Here, "center" specifies where the center of the zooming should happen. Note +that this is defined in pixel space, not the space of the data points (you can +use the p2c helpers on the axes in Flot to help you convert between these). + +"amount" is the amount to zoom the viewport relative to the current range, so +1 is 100% (i.e. no change), 1.5 is 150% (zoom in), 0.7 is 70% (zoom out). You +can set the default in the options. + +*/ + +// First two dependencies, jquery.event.drag.js and +// jquery.mousewheel.js, we put them inline here to save people the +// effort of downloading them. + +/* +jquery.event.drag.js ~ v1.5 ~ Copyright (c) 2008, Three Dub Media (http://threedubmedia.com) +Licensed under the MIT License ~ http://threedubmedia.googlecode.com/files/MIT-LICENSE.txt +*/ +(function(a){function e(h){var k,j=this,l=h.data||{};if(l.elem)j=h.dragTarget=l.elem,h.dragProxy=d.proxy||j,h.cursorOffsetX=l.pageX-l.left,h.cursorOffsetY=l.pageY-l.top,h.offsetX=h.pageX-h.cursorOffsetX,h.offsetY=h.pageY-h.cursorOffsetY;else if(d.dragging||l.which>0&&h.which!=l.which||a(h.target).is(l.not))return;switch(h.type){case"mousedown":return a.extend(l,a(j).offset(),{elem:j,target:h.target,pageX:h.pageX,pageY:h.pageY}),b.add(document,"mousemove mouseup",e,l),i(j,!1),d.dragging=null,!1;case!d.dragging&&"mousemove":if(g(h.pageX-l.pageX)+g(h.pageY-l.pageY) max) { + // make sure min < max + var tmp = min; + min = max; + max = tmp; + } + + //Check that we are in panRange + if (pr) { + if (pr[0] != null && min < pr[0]) { + min = pr[0]; + } + if (pr[1] != null && max > pr[1]) { + max = pr[1]; + } + } + + var range = max - min; + if (zr && + ((zr[0] != null && range < zr[0] && amount >1) || + (zr[1] != null && range > zr[1] && amount <1))) + return; + + opts.min = min; + opts.max = max; + }); + + plot.setupGrid(); + plot.draw(); + + if (!args.preventEvent) + plot.getPlaceholder().trigger("plotzoom", [ plot, args ]); + }; + + plot.pan = function (args) { + var delta = { + x: +args.left, + y: +args.top + }; + + if (isNaN(delta.x)) + delta.x = 0; + if (isNaN(delta.y)) + delta.y = 0; + + $.each(plot.getAxes(), function (_, axis) { + var opts = axis.options, + min, max, d = delta[axis.direction]; + + min = axis.c2p(axis.p2c(axis.min) + d), + max = axis.c2p(axis.p2c(axis.max) + d); + + var pr = opts.panRange; + if (pr === false) // no panning on this axis + return; + + if (pr) { + // check whether we hit the wall + if (pr[0] != null && pr[0] > min) { + d = pr[0] - min; + min += d; + max += d; + } + + if (pr[1] != null && pr[1] < max) { + d = pr[1] - max; + min += d; + max += d; + } + } + + opts.min = min; + opts.max = max; + }); + + plot.setupGrid(); + plot.draw(); + + if (!args.preventEvent) + plot.getPlaceholder().trigger("plotpan", [ plot, args ]); + }; + + function shutdown(plot, eventHolder) { + eventHolder.unbind(plot.getOptions().zoom.trigger, onZoomClick); + eventHolder.unbind("mousewheel", onMouseWheel); + eventHolder.unbind("dragstart", onDragStart); + eventHolder.unbind("drag", onDrag); + eventHolder.unbind("dragend", onDragEnd); + if (panTimeout) + clearTimeout(panTimeout); + } + + plot.hooks.bindEvents.push(bindEvents); + plot.hooks.shutdown.push(shutdown); + } + + $.plot.plugins.push({ + init: init, + options: options, + name: 'navigate', + version: '1.3' + }); +})(jQuery); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/jquery.flot.resize.js b/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/jquery.flot.resize.js new file mode 100644 index 0000000000000..8a626dda0addb --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/jquery.flot.resize.js @@ -0,0 +1,59 @@ +/* Flot plugin for automatically redrawing plots as the placeholder resizes. + +Copyright (c) 2007-2014 IOLA and Ole Laursen. +Licensed under the MIT license. + +It works by listening for changes on the placeholder div (through the jQuery +resize event plugin) - if the size changes, it will redraw the plot. + +There are no options. If you need to disable the plugin for some plots, you +can just fix the size of their placeholders. + +*/ + +/* Inline dependency: + * jQuery resize event - v1.1 - 3/14/2010 + * http://benalman.com/projects/jquery-resize-plugin/ + * + * Copyright (c) 2010 "Cowboy" Ben Alman + * Dual licensed under the MIT and GPL licenses. + * http://benalman.com/about/license/ + */ +(function($,e,t){"$:nomunge";var i=[],n=$.resize=$.extend($.resize,{}),a,r=false,s="setTimeout",u="resize",m=u+"-special-event",o="pendingDelay",l="activeDelay",f="throttleWindow";n[o]=200;n[l]=20;n[f]=true;$.event.special[u]={setup:function(){if(!n[f]&&this[s]){return false}var e=$(this);i.push(this);e.data(m,{w:e.width(),h:e.height()});if(i.length===1){a=t;h()}},teardown:function(){if(!n[f]&&this[s]){return false}var e=$(this);for(var t=i.length-1;t>=0;t--){if(i[t]==this){i.splice(t,1);break}}e.removeData(m);if(!i.length){if(r){cancelAnimationFrame(a)}else{clearTimeout(a)}a=null}},add:function(e){if(!n[f]&&this[s]){return false}var i;function a(e,n,a){var r=$(this),s=r.data(m)||{};s.w=n!==t?n:r.width();s.h=a!==t?a:r.height();i.apply(this,arguments)}if($.isFunction(e)){i=e;return a}else{i=e.handler;e.handler=a}}};function h(t){if(r===true){r=t||1}for(var s=i.length-1;s>=0;s--){var l=$(i[s]);if(l[0]==e||l.is(":visible")){var f=l.width(),c=l.height(),d=l.data(m);if(d&&(f!==d.w||c!==d.h)){l.trigger(u,[d.w=f,d.h=c]);r=t||true}}else{d=l.data(m);d.w=0;d.h=0}}if(a!==null){if(r&&(t==null||t-r<1e3)){a=e.requestAnimationFrame(h)}else{a=setTimeout(h,n[o]);r=false}}}if(!e.requestAnimationFrame){e.requestAnimationFrame=function(){return e.webkitRequestAnimationFrame||e.mozRequestAnimationFrame||e.oRequestAnimationFrame||e.msRequestAnimationFrame||function(t,i){return e.setTimeout(function(){t((new Date).getTime())},n[l])}}()}if(!e.cancelAnimationFrame){e.cancelAnimationFrame=function(){return e.webkitCancelRequestAnimationFrame||e.mozCancelRequestAnimationFrame||e.oCancelRequestAnimationFrame||e.msCancelRequestAnimationFrame||clearTimeout}()}})(jQuery,this); + +(function ($) { + var options = { }; // no options + + function init(plot) { + function onResize() { + var placeholder = plot.getPlaceholder(); + + // somebody might have hidden us and we can't plot + // when we don't have the dimensions + if (placeholder.width() == 0 || placeholder.height() == 0) + return; + + plot.resize(); + plot.setupGrid(); + plot.draw(); + } + + function bindEvents(plot, eventHolder) { + plot.getPlaceholder().resize(onResize); + } + + function shutdown(plot, eventHolder) { + plot.getPlaceholder().unbind("resize", onResize); + } + + plot.hooks.bindEvents.push(bindEvents); + plot.hooks.shutdown.push(shutdown); + } + + $.plot.plugins.push({ + init: init, + options: options, + name: 'resize', + version: '1.0' + }); +})(jQuery); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/jquery.flot.selection.js b/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/jquery.flot.selection.js new file mode 100644 index 0000000000000..d3c20fa4e12f2 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/jquery.flot.selection.js @@ -0,0 +1,360 @@ +/* Flot plugin for selecting regions of a plot. + +Copyright (c) 2007-2014 IOLA and Ole Laursen. +Licensed under the MIT license. + +The plugin supports these options: + +selection: { + mode: null or "x" or "y" or "xy", + color: color, + shape: "round" or "miter" or "bevel", + minSize: number of pixels +} + +Selection support is enabled by setting the mode to one of "x", "y" or "xy". +In "x" mode, the user will only be able to specify the x range, similarly for +"y" mode. For "xy", the selection becomes a rectangle where both ranges can be +specified. "color" is color of the selection (if you need to change the color +later on, you can get to it with plot.getOptions().selection.color). "shape" +is the shape of the corners of the selection. + +"minSize" is the minimum size a selection can be in pixels. This value can +be customized to determine the smallest size a selection can be and still +have the selection rectangle be displayed. When customizing this value, the +fact that it refers to pixels, not axis units must be taken into account. +Thus, for example, if there is a bar graph in time mode with BarWidth set to 1 +minute, setting "minSize" to 1 will not make the minimum selection size 1 +minute, but rather 1 pixel. Note also that setting "minSize" to 0 will prevent +"plotunselected" events from being fired when the user clicks the mouse without +dragging. + +When selection support is enabled, a "plotselected" event will be emitted on +the DOM element you passed into the plot function. The event handler gets a +parameter with the ranges selected on the axes, like this: + + placeholder.bind( "plotselected", function( event, ranges ) { + alert("You selected " + ranges.xaxis.from + " to " + ranges.xaxis.to) + // similar for yaxis - with multiple axes, the extra ones are in + // x2axis, x3axis, ... + }); + +The "plotselected" event is only fired when the user has finished making the +selection. A "plotselecting" event is fired during the process with the same +parameters as the "plotselected" event, in case you want to know what's +happening while it's happening, + +A "plotunselected" event with no arguments is emitted when the user clicks the +mouse to remove the selection. As stated above, setting "minSize" to 0 will +destroy this behavior. + +The plugin allso adds the following methods to the plot object: + +- setSelection( ranges, preventEvent ) + + Set the selection rectangle. The passed in ranges is on the same form as + returned in the "plotselected" event. If the selection mode is "x", you + should put in either an xaxis range, if the mode is "y" you need to put in + an yaxis range and both xaxis and yaxis if the selection mode is "xy", like + this: + + setSelection({ xaxis: { from: 0, to: 10 }, yaxis: { from: 40, to: 60 } }); + + setSelection will trigger the "plotselected" event when called. If you don't + want that to happen, e.g. if you're inside a "plotselected" handler, pass + true as the second parameter. If you are using multiple axes, you can + specify the ranges on any of those, e.g. as x2axis/x3axis/... instead of + xaxis, the plugin picks the first one it sees. + +- clearSelection( preventEvent ) + + Clear the selection rectangle. Pass in true to avoid getting a + "plotunselected" event. + +- getSelection() + + Returns the current selection in the same format as the "plotselected" + event. If there's currently no selection, the function returns null. + +*/ + +(function ($) { + function init(plot) { + var selection = { + first: { x: -1, y: -1}, second: { x: -1, y: -1}, + show: false, + active: false + }; + + // FIXME: The drag handling implemented here should be + // abstracted out, there's some similar code from a library in + // the navigation plugin, this should be massaged a bit to fit + // the Flot cases here better and reused. Doing this would + // make this plugin much slimmer. + var savedhandlers = {}; + + var mouseUpHandler = null; + + function onMouseMove(e) { + if (selection.active) { + updateSelection(e); + + plot.getPlaceholder().trigger("plotselecting", [ getSelection() ]); + } + } + + function onMouseDown(e) { + if (e.which != 1) // only accept left-click + return; + + // cancel out any text selections + document.body.focus(); + + // prevent text selection and drag in old-school browsers + if (document.onselectstart !== undefined && savedhandlers.onselectstart == null) { + savedhandlers.onselectstart = document.onselectstart; + document.onselectstart = function () { return false; }; + } + if (document.ondrag !== undefined && savedhandlers.ondrag == null) { + savedhandlers.ondrag = document.ondrag; + document.ondrag = function () { return false; }; + } + + setSelectionPos(selection.first, e); + + selection.active = true; + + // this is a bit silly, but we have to use a closure to be + // able to whack the same handler again + mouseUpHandler = function (e) { onMouseUp(e); }; + + $(document).one("mouseup", mouseUpHandler); + } + + function onMouseUp(e) { + mouseUpHandler = null; + + // revert drag stuff for old-school browsers + if (document.onselectstart !== undefined) + document.onselectstart = savedhandlers.onselectstart; + if (document.ondrag !== undefined) + document.ondrag = savedhandlers.ondrag; + + // no more dragging + selection.active = false; + updateSelection(e); + + if (selectionIsSane()) + triggerSelectedEvent(); + else { + // this counts as a clear + plot.getPlaceholder().trigger("plotunselected", [ ]); + plot.getPlaceholder().trigger("plotselecting", [ null ]); + } + + return false; + } + + function getSelection() { + if (!selectionIsSane()) + return null; + + if (!selection.show) return null; + + var r = {}, c1 = selection.first, c2 = selection.second; + $.each(plot.getAxes(), function (name, axis) { + if (axis.used) { + var p1 = axis.c2p(c1[axis.direction]), p2 = axis.c2p(c2[axis.direction]); + r[name] = { from: Math.min(p1, p2), to: Math.max(p1, p2) }; + } + }); + return r; + } + + function triggerSelectedEvent() { + var r = getSelection(); + + plot.getPlaceholder().trigger("plotselected", [ r ]); + + // backwards-compat stuff, to be removed in future + if (r.xaxis && r.yaxis) + plot.getPlaceholder().trigger("selected", [ { x1: r.xaxis.from, y1: r.yaxis.from, x2: r.xaxis.to, y2: r.yaxis.to } ]); + } + + function clamp(min, value, max) { + return value < min ? min: (value > max ? max: value); + } + + function setSelectionPos(pos, e) { + var o = plot.getOptions(); + var offset = plot.getPlaceholder().offset(); + var plotOffset = plot.getPlotOffset(); + pos.x = clamp(0, e.pageX - offset.left - plotOffset.left, plot.width()); + pos.y = clamp(0, e.pageY - offset.top - plotOffset.top, plot.height()); + + if (o.selection.mode == "y") + pos.x = pos == selection.first ? 0 : plot.width(); + + if (o.selection.mode == "x") + pos.y = pos == selection.first ? 0 : plot.height(); + } + + function updateSelection(pos) { + if (pos.pageX == null) + return; + + setSelectionPos(selection.second, pos); + if (selectionIsSane()) { + selection.show = true; + plot.triggerRedrawOverlay(); + } + else + clearSelection(true); + } + + function clearSelection(preventEvent) { + if (selection.show) { + selection.show = false; + plot.triggerRedrawOverlay(); + if (!preventEvent) + plot.getPlaceholder().trigger("plotunselected", [ ]); + } + } + + // function taken from markings support in Flot + function extractRange(ranges, coord) { + var axis, from, to, key, axes = plot.getAxes(); + + for (var k in axes) { + axis = axes[k]; + if (axis.direction == coord) { + key = coord + axis.n + "axis"; + if (!ranges[key] && axis.n == 1) + key = coord + "axis"; // support x1axis as xaxis + if (ranges[key]) { + from = ranges[key].from; + to = ranges[key].to; + break; + } + } + } + + // backwards-compat stuff - to be removed in future + if (!ranges[key]) { + axis = coord == "x" ? plot.getXAxes()[0] : plot.getYAxes()[0]; + from = ranges[coord + "1"]; + to = ranges[coord + "2"]; + } + + // auto-reverse as an added bonus + if (from != null && to != null && from > to) { + var tmp = from; + from = to; + to = tmp; + } + + return { from: from, to: to, axis: axis }; + } + + function setSelection(ranges, preventEvent) { + var axis, range, o = plot.getOptions(); + + if (o.selection.mode == "y") { + selection.first.x = 0; + selection.second.x = plot.width(); + } + else { + range = extractRange(ranges, "x"); + + selection.first.x = range.axis.p2c(range.from); + selection.second.x = range.axis.p2c(range.to); + } + + if (o.selection.mode == "x") { + selection.first.y = 0; + selection.second.y = plot.height(); + } + else { + range = extractRange(ranges, "y"); + + selection.first.y = range.axis.p2c(range.from); + selection.second.y = range.axis.p2c(range.to); + } + + selection.show = true; + plot.triggerRedrawOverlay(); + if (!preventEvent && selectionIsSane()) + triggerSelectedEvent(); + } + + function selectionIsSane() { + var minSize = plot.getOptions().selection.minSize; + return Math.abs(selection.second.x - selection.first.x) >= minSize && + Math.abs(selection.second.y - selection.first.y) >= minSize; + } + + plot.clearSelection = clearSelection; + plot.setSelection = setSelection; + plot.getSelection = getSelection; + + plot.hooks.bindEvents.push(function(plot, eventHolder) { + var o = plot.getOptions(); + if (o.selection.mode != null) { + eventHolder.mousemove(onMouseMove); + eventHolder.mousedown(onMouseDown); + } + }); + + + plot.hooks.drawOverlay.push(function (plot, ctx) { + // draw selection + if (selection.show && selectionIsSane()) { + var plotOffset = plot.getPlotOffset(); + var o = plot.getOptions(); + + ctx.save(); + ctx.translate(plotOffset.left, plotOffset.top); + + var c = $.color.parse(o.selection.color); + + ctx.strokeStyle = c.scale('a', 0.8).toString(); + ctx.lineWidth = 1; + ctx.lineJoin = o.selection.shape; + ctx.fillStyle = c.scale('a', 0.4).toString(); + + var x = Math.min(selection.first.x, selection.second.x) + 0.5, + y = Math.min(selection.first.y, selection.second.y) + 0.5, + w = Math.abs(selection.second.x - selection.first.x) - 1, + h = Math.abs(selection.second.y - selection.first.y) - 1; + + ctx.fillRect(x, y, w, h); + ctx.strokeRect(x, y, w, h); + + ctx.restore(); + } + }); + + plot.hooks.shutdown.push(function (plot, eventHolder) { + eventHolder.unbind("mousemove", onMouseMove); + eventHolder.unbind("mousedown", onMouseDown); + + if (mouseUpHandler) + $(document).unbind("mouseup", mouseUpHandler); + }); + + } + + $.plot.plugins.push({ + init: init, + options: { + selection: { + mode: null, // one of null, "x", "y" or "xy" + color: "#e8cfac", + shape: "round", // one of "round", "miter", or "bevel" + minSize: 5 // minimum number of pixels + } + }, + name: 'selection', + version: '1.1' + }); +})(jQuery); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/jquery.flot.stack.js b/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/jquery.flot.stack.js new file mode 100644 index 0000000000000..e75a7dfc07434 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/jquery.flot.stack.js @@ -0,0 +1,188 @@ +/* Flot plugin for stacking data sets rather than overlyaing them. + +Copyright (c) 2007-2014 IOLA and Ole Laursen. +Licensed under the MIT license. + +The plugin assumes the data is sorted on x (or y if stacking horizontally). +For line charts, it is assumed that if a line has an undefined gap (from a +null point), then the line above it should have the same gap - insert zeros +instead of "null" if you want another behaviour. This also holds for the start +and end of the chart. Note that stacking a mix of positive and negative values +in most instances doesn't make sense (so it looks weird). + +Two or more series are stacked when their "stack" attribute is set to the same +key (which can be any number or string or just "true"). To specify the default +stack, you can set the stack option like this: + + series: { + stack: null/false, true, or a key (number/string) + } + +You can also specify it for a single series, like this: + + $.plot( $("#placeholder"), [{ + data: [ ... ], + stack: true + }]) + +The stacking order is determined by the order of the data series in the array +(later series end up on top of the previous). + +Internally, the plugin modifies the datapoints in each series, adding an +offset to the y value. For line series, extra data points are inserted through +interpolation. If there's a second y value, it's also adjusted (e.g for bar +charts or filled areas). + +*/ + +(function ($) { + var options = { + series: { stack: null } // or number/string + }; + + function init(plot) { + function findMatchingSeries(s, allseries) { + var res = null; + for (var i = 0; i < allseries.length; ++i) { + if (s == allseries[i]) + break; + + if (allseries[i].stack == s.stack) + res = allseries[i]; + } + + return res; + } + + function stackData(plot, s, datapoints) { + if (s.stack == null || s.stack === false) + return; + + var other = findMatchingSeries(s, plot.getData()); + if (!other) + return; + + var ps = datapoints.pointsize, + points = datapoints.points, + otherps = other.datapoints.pointsize, + otherpoints = other.datapoints.points, + newpoints = [], + px, py, intery, qx, qy, bottom, + withlines = s.lines.show, + horizontal = s.bars.horizontal, + withbottom = ps > 2 && (horizontal ? datapoints.format[2].x : datapoints.format[2].y), + withsteps = withlines && s.lines.steps, + fromgap = true, + keyOffset = horizontal ? 1 : 0, + accumulateOffset = horizontal ? 0 : 1, + i = 0, j = 0, l, m; + + while (true) { + if (i >= points.length) + break; + + l = newpoints.length; + + if (points[i] == null) { + // copy gaps + for (m = 0; m < ps; ++m) + newpoints.push(points[i + m]); + i += ps; + } + else if (j >= otherpoints.length) { + // for lines, we can't use the rest of the points + if (!withlines) { + for (m = 0; m < ps; ++m) + newpoints.push(points[i + m]); + } + i += ps; + } + else if (otherpoints[j] == null) { + // oops, got a gap + for (m = 0; m < ps; ++m) + newpoints.push(null); + fromgap = true; + j += otherps; + } + else { + // cases where we actually got two points + px = points[i + keyOffset]; + py = points[i + accumulateOffset]; + qx = otherpoints[j + keyOffset]; + qy = otherpoints[j + accumulateOffset]; + bottom = 0; + + if (px == qx) { + for (m = 0; m < ps; ++m) + newpoints.push(points[i + m]); + + newpoints[l + accumulateOffset] += qy; + bottom = qy; + + i += ps; + j += otherps; + } + else if (px > qx) { + // we got past point below, might need to + // insert interpolated extra point + if (withlines && i > 0 && points[i - ps] != null) { + intery = py + (points[i - ps + accumulateOffset] - py) * (qx - px) / (points[i - ps + keyOffset] - px); + newpoints.push(qx); + newpoints.push(intery + qy); + for (m = 2; m < ps; ++m) + newpoints.push(points[i + m]); + bottom = qy; + } + + j += otherps; + } + else { // px < qx + if (fromgap && withlines) { + // if we come from a gap, we just skip this point + i += ps; + continue; + } + + for (m = 0; m < ps; ++m) + newpoints.push(points[i + m]); + + // we might be able to interpolate a point below, + // this can give us a better y + if (withlines && j > 0 && otherpoints[j - otherps] != null) + bottom = qy + (otherpoints[j - otherps + accumulateOffset] - qy) * (px - qx) / (otherpoints[j - otherps + keyOffset] - qx); + + newpoints[l + accumulateOffset] += bottom; + + i += ps; + } + + fromgap = false; + + if (l != newpoints.length && withbottom) + newpoints[l + 2] += bottom; + } + + // maintain the line steps invariant + if (withsteps && l != newpoints.length && l > 0 + && newpoints[l] != null + && newpoints[l] != newpoints[l - ps] + && newpoints[l + 1] != newpoints[l - ps + 1]) { + for (m = 0; m < ps; ++m) + newpoints[l + ps + m] = newpoints[l + m]; + newpoints[l + 1] = newpoints[l - ps + 1]; + } + } + + datapoints.points = newpoints; + } + + plot.hooks.processDatapoints.push(stackData); + } + + $.plot.plugins.push({ + init: init, + options: options, + name: 'stack', + version: '1.2' + }); +})(jQuery); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/jquery.flot.symbol.js b/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/jquery.flot.symbol.js new file mode 100644 index 0000000000000..79f634971b6fa --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/jquery.flot.symbol.js @@ -0,0 +1,71 @@ +/* Flot plugin that adds some extra symbols for plotting points. + +Copyright (c) 2007-2014 IOLA and Ole Laursen. +Licensed under the MIT license. + +The symbols are accessed as strings through the standard symbol options: + + series: { + points: { + symbol: "square" // or "diamond", "triangle", "cross" + } + } + +*/ + +(function ($) { + function processRawData(plot, series, datapoints) { + // we normalize the area of each symbol so it is approximately the + // same as a circle of the given radius + + var handlers = { + square: function (ctx, x, y, radius, shadow) { + // pi * r^2 = (2s)^2 => s = r * sqrt(pi)/2 + var size = radius * Math.sqrt(Math.PI) / 2; + ctx.rect(x - size, y - size, size + size, size + size); + }, + diamond: function (ctx, x, y, radius, shadow) { + // pi * r^2 = 2s^2 => s = r * sqrt(pi/2) + var size = radius * Math.sqrt(Math.PI / 2); + ctx.moveTo(x - size, y); + ctx.lineTo(x, y - size); + ctx.lineTo(x + size, y); + ctx.lineTo(x, y + size); + ctx.lineTo(x - size, y); + }, + triangle: function (ctx, x, y, radius, shadow) { + // pi * r^2 = 1/2 * s^2 * sin (pi / 3) => s = r * sqrt(2 * pi / sin(pi / 3)) + var size = radius * Math.sqrt(2 * Math.PI / Math.sin(Math.PI / 3)); + var height = size * Math.sin(Math.PI / 3); + ctx.moveTo(x - size/2, y + height/2); + ctx.lineTo(x + size/2, y + height/2); + if (!shadow) { + ctx.lineTo(x, y - height/2); + ctx.lineTo(x - size/2, y + height/2); + } + }, + cross: function (ctx, x, y, radius, shadow) { + // pi * r^2 = (2s)^2 => s = r * sqrt(pi)/2 + var size = radius * Math.sqrt(Math.PI) / 2; + ctx.moveTo(x - size, y - size); + ctx.lineTo(x + size, y + size); + ctx.moveTo(x - size, y + size); + ctx.lineTo(x + size, y - size); + } + }; + + var s = series.points.symbol; + if (handlers[s]) + series.points.symbol = handlers[s]; + } + + function init(plot) { + plot.hooks.processDatapoints.push(processRawData); + } + + $.plot.plugins.push({ + init: init, + name: 'symbols', + version: '1.0' + }); +})(jQuery); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/jquery.flot.threshold.js b/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/jquery.flot.threshold.js new file mode 100644 index 0000000000000..8c99c401d87e5 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/jquery.flot.threshold.js @@ -0,0 +1,142 @@ +/* Flot plugin for thresholding data. + +Copyright (c) 2007-2014 IOLA and Ole Laursen. +Licensed under the MIT license. + +The plugin supports these options: + + series: { + threshold: { + below: number + color: colorspec + } + } + +It can also be applied to a single series, like this: + + $.plot( $("#placeholder"), [{ + data: [ ... ], + threshold: { ... } + }]) + +An array can be passed for multiple thresholding, like this: + + threshold: [{ + below: number1 + color: color1 + },{ + below: number2 + color: color2 + }] + +These multiple threshold objects can be passed in any order since they are +sorted by the processing function. + +The data points below "below" are drawn with the specified color. This makes +it easy to mark points below 0, e.g. for budget data. + +Internally, the plugin works by splitting the data into two series, above and +below the threshold. The extra series below the threshold will have its label +cleared and the special "originSeries" attribute set to the original series. +You may need to check for this in hover events. + +*/ + +(function ($) { + var options = { + series: { threshold: null } // or { below: number, color: color spec} + }; + + function init(plot) { + function thresholdData(plot, s, datapoints, below, color) { + var ps = datapoints.pointsize, i, x, y, p, prevp, + thresholded = $.extend({}, s); // note: shallow copy + + thresholded.datapoints = { points: [], pointsize: ps, format: datapoints.format }; + thresholded.label = null; + thresholded.color = color; + thresholded.threshold = null; + thresholded.originSeries = s; + thresholded.data = []; + + var origpoints = datapoints.points, + addCrossingPoints = s.lines.show; + + var threspoints = []; + var newpoints = []; + var m; + + for (i = 0; i < origpoints.length; i += ps) { + x = origpoints[i]; + y = origpoints[i + 1]; + + prevp = p; + if (y < below) + p = threspoints; + else + p = newpoints; + + if (addCrossingPoints && prevp != p && x != null + && i > 0 && origpoints[i - ps] != null) { + var interx = x + (below - y) * (x - origpoints[i - ps]) / (y - origpoints[i - ps + 1]); + prevp.push(interx); + prevp.push(below); + for (m = 2; m < ps; ++m) + prevp.push(origpoints[i + m]); + + p.push(null); // start new segment + p.push(null); + for (m = 2; m < ps; ++m) + p.push(origpoints[i + m]); + p.push(interx); + p.push(below); + for (m = 2; m < ps; ++m) + p.push(origpoints[i + m]); + } + + p.push(x); + p.push(y); + for (m = 2; m < ps; ++m) + p.push(origpoints[i + m]); + } + + datapoints.points = newpoints; + thresholded.datapoints.points = threspoints; + + if (thresholded.datapoints.points.length > 0) { + var origIndex = $.inArray(s, plot.getData()); + // Insert newly-generated series right after original one (to prevent it from becoming top-most) + plot.getData().splice(origIndex + 1, 0, thresholded); + } + + // FIXME: there are probably some edge cases left in bars + } + + function processThresholds(plot, s, datapoints) { + if (!s.threshold) + return; + + if (s.threshold instanceof Array) { + s.threshold.sort(function(a, b) { + return a.below - b.below; + }); + + $(s.threshold).each(function(i, th) { + thresholdData(plot, s, datapoints, th.below, th.color); + }); + } + else { + thresholdData(plot, s, datapoints, s.threshold.below, s.threshold.color); + } + } + + plot.hooks.processDatapoints.push(processThresholds); + } + + $.plot.plugins.push({ + init: init, + options: options, + name: 'threshold', + version: '1.2' + }); +})(jQuery); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/jquery.flot.time.js b/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/jquery.flot.time.js new file mode 100644 index 0000000000000..34c1d121259a2 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/lib/flot-charts/jquery.flot.time.js @@ -0,0 +1,432 @@ +/* Pretty handling of time axes. + +Copyright (c) 2007-2014 IOLA and Ole Laursen. +Licensed under the MIT license. + +Set axis.mode to "time" to enable. See the section "Time series data" in +API.txt for details. + +*/ + +(function($) { + + var options = { + xaxis: { + timezone: null, // "browser" for local to the client or timezone for timezone-js + timeformat: null, // format string to use + twelveHourClock: false, // 12 or 24 time in time mode + monthNames: null // list of names of months + } + }; + + // round to nearby lower multiple of base + + function floorInBase(n, base) { + return base * Math.floor(n / base); + } + + // Returns a string with the date d formatted according to fmt. + // A subset of the Open Group's strftime format is supported. + + function formatDate(d, fmt, monthNames, dayNames) { + + if (typeof d.strftime == "function") { + return d.strftime(fmt); + } + + var leftPad = function(n, pad) { + n = "" + n; + pad = "" + (pad == null ? "0" : pad); + return n.length == 1 ? pad + n : n; + }; + + var r = []; + var escape = false; + var hours = d.getHours(); + var isAM = hours < 12; + + if (monthNames == null) { + monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + } + + if (dayNames == null) { + dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + } + + var hours12; + + if (hours > 12) { + hours12 = hours - 12; + } else if (hours == 0) { + hours12 = 12; + } else { + hours12 = hours; + } + + for (var i = 0; i < fmt.length; ++i) { + + var c = fmt.charAt(i); + + if (escape) { + switch (c) { + case 'a': c = "" + dayNames[d.getDay()]; break; + case 'b': c = "" + monthNames[d.getMonth()]; break; + case 'd': c = leftPad(d.getDate()); break; + case 'e': c = leftPad(d.getDate(), " "); break; + case 'h': // For back-compat with 0.7; remove in 1.0 + case 'H': c = leftPad(hours); break; + case 'I': c = leftPad(hours12); break; + case 'l': c = leftPad(hours12, " "); break; + case 'm': c = leftPad(d.getMonth() + 1); break; + case 'M': c = leftPad(d.getMinutes()); break; + // quarters not in Open Group's strftime specification + case 'q': + c = "" + (Math.floor(d.getMonth() / 3) + 1); break; + case 'S': c = leftPad(d.getSeconds()); break; + case 'y': c = leftPad(d.getFullYear() % 100); break; + case 'Y': c = "" + d.getFullYear(); break; + case 'p': c = (isAM) ? ("" + "am") : ("" + "pm"); break; + case 'P': c = (isAM) ? ("" + "AM") : ("" + "PM"); break; + case 'w': c = "" + d.getDay(); break; + } + r.push(c); + escape = false; + } else { + if (c == "%") { + escape = true; + } else { + r.push(c); + } + } + } + + return r.join(""); + } + + // To have a consistent view of time-based data independent of which time + // zone the client happens to be in we need a date-like object independent + // of time zones. This is done through a wrapper that only calls the UTC + // versions of the accessor methods. + + function makeUtcWrapper(d) { + + function addProxyMethod(sourceObj, sourceMethod, targetObj, targetMethod) { + sourceObj[sourceMethod] = function() { + return targetObj[targetMethod].apply(targetObj, arguments); + }; + }; + + var utc = { + date: d + }; + + // support strftime, if found + + if (d.strftime != undefined) { + addProxyMethod(utc, "strftime", d, "strftime"); + } + + addProxyMethod(utc, "getTime", d, "getTime"); + addProxyMethod(utc, "setTime", d, "setTime"); + + var props = ["Date", "Day", "FullYear", "Hours", "Milliseconds", "Minutes", "Month", "Seconds"]; + + for (var p = 0; p < props.length; p++) { + addProxyMethod(utc, "get" + props[p], d, "getUTC" + props[p]); + addProxyMethod(utc, "set" + props[p], d, "setUTC" + props[p]); + } + + return utc; + }; + + // select time zone strategy. This returns a date-like object tied to the + // desired timezone + + function dateGenerator(ts, opts) { + if (opts.timezone == "browser") { + return new Date(ts); + } else if (!opts.timezone || opts.timezone == "utc") { + return makeUtcWrapper(new Date(ts)); + } else if (typeof timezoneJS != "undefined" && typeof timezoneJS.Date != "undefined") { + var d = new timezoneJS.Date(); + // timezone-js is fickle, so be sure to set the time zone before + // setting the time. + d.setTimezone(opts.timezone); + d.setTime(ts); + return d; + } else { + return makeUtcWrapper(new Date(ts)); + } + } + + // map of app. size of time units in milliseconds + + var timeUnitSize = { + "second": 1000, + "minute": 60 * 1000, + "hour": 60 * 60 * 1000, + "day": 24 * 60 * 60 * 1000, + "month": 30 * 24 * 60 * 60 * 1000, + "quarter": 3 * 30 * 24 * 60 * 60 * 1000, + "year": 365.2425 * 24 * 60 * 60 * 1000 + }; + + // the allowed tick sizes, after 1 year we use + // an integer algorithm + + var baseSpec = [ + [1, "second"], [2, "second"], [5, "second"], [10, "second"], + [30, "second"], + [1, "minute"], [2, "minute"], [5, "minute"], [10, "minute"], + [30, "minute"], + [1, "hour"], [2, "hour"], [4, "hour"], + [8, "hour"], [12, "hour"], + [1, "day"], [2, "day"], [3, "day"], + [0.25, "month"], [0.5, "month"], [1, "month"], + [2, "month"] + ]; + + // we don't know which variant(s) we'll need yet, but generating both is + // cheap + + var specMonths = baseSpec.concat([[3, "month"], [6, "month"], + [1, "year"]]); + var specQuarters = baseSpec.concat([[1, "quarter"], [2, "quarter"], + [1, "year"]]); + + function init(plot) { + plot.hooks.processOptions.push(function (plot, options) { + $.each(plot.getAxes(), function(axisName, axis) { + + var opts = axis.options; + + if (opts.mode == "time") { + axis.tickGenerator = function(axis) { + + var ticks = []; + var d = dateGenerator(axis.min, opts); + var minSize = 0; + + // make quarter use a possibility if quarters are + // mentioned in either of these options + + var spec = (opts.tickSize && opts.tickSize[1] === + "quarter") || + (opts.minTickSize && opts.minTickSize[1] === + "quarter") ? specQuarters : specMonths; + + if (opts.minTickSize != null) { + if (typeof opts.tickSize == "number") { + minSize = opts.tickSize; + } else { + minSize = opts.minTickSize[0] * timeUnitSize[opts.minTickSize[1]]; + } + } + + for (var i = 0; i < spec.length - 1; ++i) { + if (axis.delta < (spec[i][0] * timeUnitSize[spec[i][1]] + + spec[i + 1][0] * timeUnitSize[spec[i + 1][1]]) / 2 + && spec[i][0] * timeUnitSize[spec[i][1]] >= minSize) { + break; + } + } + + var size = spec[i][0]; + var unit = spec[i][1]; + + // special-case the possibility of several years + + if (unit == "year") { + + // if given a minTickSize in years, just use it, + // ensuring that it's an integer + + if (opts.minTickSize != null && opts.minTickSize[1] == "year") { + size = Math.floor(opts.minTickSize[0]); + } else { + + var magn = Math.pow(10, Math.floor(Math.log(axis.delta / timeUnitSize.year) / Math.LN10)); + var norm = (axis.delta / timeUnitSize.year) / magn; + + if (norm < 1.5) { + size = 1; + } else if (norm < 3) { + size = 2; + } else if (norm < 7.5) { + size = 5; + } else { + size = 10; + } + + size *= magn; + } + + // minimum size for years is 1 + + if (size < 1) { + size = 1; + } + } + + axis.tickSize = opts.tickSize || [size, unit]; + var tickSize = axis.tickSize[0]; + unit = axis.tickSize[1]; + + var step = tickSize * timeUnitSize[unit]; + + if (unit == "second") { + d.setSeconds(floorInBase(d.getSeconds(), tickSize)); + } else if (unit == "minute") { + d.setMinutes(floorInBase(d.getMinutes(), tickSize)); + } else if (unit == "hour") { + d.setHours(floorInBase(d.getHours(), tickSize)); + } else if (unit == "month") { + d.setMonth(floorInBase(d.getMonth(), tickSize)); + } else if (unit == "quarter") { + d.setMonth(3 * floorInBase(d.getMonth() / 3, + tickSize)); + } else if (unit == "year") { + d.setFullYear(floorInBase(d.getFullYear(), tickSize)); + } + + // reset smaller components + + d.setMilliseconds(0); + + if (step >= timeUnitSize.minute) { + d.setSeconds(0); + } + if (step >= timeUnitSize.hour) { + d.setMinutes(0); + } + if (step >= timeUnitSize.day) { + d.setHours(0); + } + if (step >= timeUnitSize.day * 4) { + d.setDate(1); + } + if (step >= timeUnitSize.month * 2) { + d.setMonth(floorInBase(d.getMonth(), 3)); + } + if (step >= timeUnitSize.quarter * 2) { + d.setMonth(floorInBase(d.getMonth(), 6)); + } + if (step >= timeUnitSize.year) { + d.setMonth(0); + } + + var carry = 0; + var v = Number.NaN; + var prev; + + do { + + prev = v; + v = d.getTime(); + ticks.push(v); + + if (unit == "month" || unit == "quarter") { + if (tickSize < 1) { + + // a bit complicated - we'll divide the + // month/quarter up but we need to take + // care of fractions so we don't end up in + // the middle of a day + + d.setDate(1); + var start = d.getTime(); + d.setMonth(d.getMonth() + + (unit == "quarter" ? 3 : 1)); + var end = d.getTime(); + d.setTime(v + carry * timeUnitSize.hour + (end - start) * tickSize); + carry = d.getHours(); + d.setHours(0); + } else { + d.setMonth(d.getMonth() + + tickSize * (unit == "quarter" ? 3 : 1)); + } + } else if (unit == "year") { + d.setFullYear(d.getFullYear() + tickSize); + } else { + d.setTime(v + step); + } + } while (v < axis.max && v != prev); + + return ticks; + }; + + axis.tickFormatter = function (v, axis) { + + var d = dateGenerator(v, axis.options); + + // first check global format + + if (opts.timeformat != null) { + return formatDate(d, opts.timeformat, opts.monthNames, opts.dayNames); + } + + // possibly use quarters if quarters are mentioned in + // any of these places + + var useQuarters = (axis.options.tickSize && + axis.options.tickSize[1] == "quarter") || + (axis.options.minTickSize && + axis.options.minTickSize[1] == "quarter"); + + var t = axis.tickSize[0] * timeUnitSize[axis.tickSize[1]]; + var span = axis.max - axis.min; + var suffix = (opts.twelveHourClock) ? " %p" : ""; + var hourCode = (opts.twelveHourClock) ? "%I" : "%H"; + var fmt; + + if (t < timeUnitSize.minute) { + fmt = hourCode + ":%M:%S" + suffix; + } else if (t < timeUnitSize.day) { + if (span < 2 * timeUnitSize.day) { + fmt = hourCode + ":%M" + suffix; + } else { + fmt = "%b %d " + hourCode + ":%M" + suffix; + } + } else if (t < timeUnitSize.month) { + fmt = "%b %d"; + } else if ((useQuarters && t < timeUnitSize.quarter) || + (!useQuarters && t < timeUnitSize.year)) { + if (span < timeUnitSize.year) { + fmt = "%b"; + } else { + fmt = "%b %Y"; + } + } else if (useQuarters && t < timeUnitSize.year) { + if (span < timeUnitSize.year) { + fmt = "Q%q"; + } else { + fmt = "Q%q %Y"; + } + } else { + fmt = "%Y"; + } + + var rt = formatDate(d, fmt, opts.monthNames, opts.dayNames); + + return rt; + }; + } + }); + }); + } + + $.plot.plugins.push({ + init: init, + options: options, + name: 'time', + version: '1.0' + }); + + // Time-axis support used to be in Flot core, which exposed the + // formatDate function on the plot object. Various plugins depend + // on the function, so we need to re-expose it here. + + $.plot.formatDate = formatDate; + $.plot.dateGenerator = dateGenerator; + +})(jQuery); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/advanced_filter/component/advanced_filter.js b/x-pack/plugins/canvas/canvas_plugin_src/renderers/advanced_filter/component/advanced_filter.js new file mode 100644 index 0000000000000..6fc3076db6c32 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/advanced_filter/component/advanced_filter.js @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import './advanced_filter.scss'; + +export const AdvancedFilter = ({ value, onChange, commit }) => ( +
{ + e.preventDefault(); + commit(value); + }} + className="canvasAdvancedFilter" + > + + + onChange(e.target.value)} + /> + + + + + +
+); + +AdvancedFilter.propTypes = { + onChange: PropTypes.func, + value: PropTypes.string, + commit: PropTypes.func, +}; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/advanced_filter/component/advanced_filter.scss b/x-pack/plugins/canvas/canvas_plugin_src/renderers/advanced_filter/component/advanced_filter.scss new file mode 100644 index 0000000000000..c5a0d5c891c77 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/advanced_filter/component/advanced_filter.scss @@ -0,0 +1,34 @@ +@import '../../../lib/eui.scss'; + +.canvasAdvancedFilter { + width: 100%; + font-size: inherit; + + .canvasAdvancedFilter__input { + background-color: $euiColorEmptyShade; + width: 100%; + padding: $euiSizeXS $euiSize; + border: $euiBorderThin; + border-radius: $euiBorderRadius; + font-size: inherit; + line-height: 19px; + font-family: monospace; + + &:focus { + box-shadow: none; + } + } + + .canvasAdvancedFilter__button { + width: 100%; + padding: $euiSizeXS $euiSizeS; + border: $euiBorderThin; + border-radius: $euiBorderRadius; + background-color: $euiColorEmptyShade; + font-size: inherit; + + &:hover { + background-color: $euiColorLightestShade; + } + } +} diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/advanced_filter/component/index.js b/x-pack/plugins/canvas/canvas_plugin_src/renderers/advanced_filter/component/index.js new file mode 100644 index 0000000000000..abe69863c32cf --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/advanced_filter/component/index.js @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { compose, withState } from 'recompose'; +import { AdvancedFilter as Component } from './advanced_filter'; + +export const AdvancedFilter = compose(withState('value', 'onChange', ({ filter }) => filter || ''))( + Component +); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/advanced_filter/index.js b/x-pack/plugins/canvas/canvas_plugin_src/renderers/advanced_filter/index.js new file mode 100644 index 0000000000000..1b44045f44a70 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/advanced_filter/index.js @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import ReactDOM from 'react-dom'; +import React from 'react'; +import { AdvancedFilter } from './component'; + +export const advancedFilter = () => ({ + name: 'advanced_filter', + displayName: 'Advanced Filter', + help: 'Render a Canvas filter expression', + reuseDomNode: true, + height: 50, + render(domNode, config, handlers) { + ReactDOM.render( + , + domNode, + () => handlers.done() + ); + + handlers.onDestroy(() => { + handlers.setFilter(''); + ReactDOM.unmountComponentAtNode(domNode); + }); + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/debug.js b/x-pack/plugins/canvas/canvas_plugin_src/renderers/debug.js new file mode 100644 index 0000000000000..6c4b23d04ea88 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/debug.js @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import ReactDOM from 'react-dom'; +import React from 'react'; +import { Debug } from '../../public/components/debug'; + +export const debug = () => ({ + name: 'debug', + displayName: 'Debug', + help: 'Render debug output as formatted JSON', + reuseDomNode: true, + render(domNode, config, handlers) { + const renderDebug = () => ( +
+ +
+ ); + + ReactDOM.render(renderDebug(), domNode, () => handlers.done()); + + handlers.onResize(() => { + ReactDOM.render(renderDebug(), domNode, () => handlers.done()); + }); + + handlers.onDestroy(() => ReactDOM.unmountComponentAtNode(domNode)); + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/dropdown_filter/component/dropdown_filter.js b/x-pack/plugins/canvas/canvas_plugin_src/renderers/dropdown_filter/component/dropdown_filter.js new file mode 100644 index 0000000000000..d6efe27cada39 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/dropdown_filter/component/dropdown_filter.js @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { EuiIcon } from '@elastic/eui'; +import './dropdown_filter.scss'; + +export const DropdownFilter = ({ value, onChange, commit, choices }) => { + const options = [{ value: '%%CANVAS_MATCH_ALL%%', text: '-- ANY --' }]; + + choices.forEach(value => options.push({ value: value, text: value })); + + return ( +
+ + +
+ ); +}; + +DropdownFilter.propTypes = { + onChange: PropTypes.func, + value: PropTypes.string, + commit: PropTypes.func, + choices: PropTypes.array, +}; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/dropdown_filter/component/dropdown_filter.scss b/x-pack/plugins/canvas/canvas_plugin_src/renderers/dropdown_filter/component/dropdown_filter.scss new file mode 100644 index 0000000000000..485fc4b0f2199 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/dropdown_filter/component/dropdown_filter.scss @@ -0,0 +1,33 @@ +@import '../../../lib/eui.scss'; + +.canvasDropdownFilter { + width: 100%; + font-size: inherit; + + .canvasDropdownFilter__select { + background-color: $euiColorEmptyShade; + width: 100%; + padding: $euiSizeXS $euiSize; + border: $euiBorderThin; + border-radius: $euiBorderRadius; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + font-size: inherit; + + &:after { + display: none; + } + + &:focus { + box-shadow: none; + } + } + + .canvasDropdownFilter__icon { + position: absolute; + right: $euiSizeS; + top: $euiSizeS; + pointer-events: none; + } +} diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/dropdown_filter/component/index.js b/x-pack/plugins/canvas/canvas_plugin_src/renderers/dropdown_filter/component/index.js new file mode 100644 index 0000000000000..3149816692666 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/dropdown_filter/component/index.js @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { compose, withState } from 'recompose'; +import { DropdownFilter as Component } from './dropdown_filter'; + +export const DropdownFilter = compose(withState('value', 'onChange', ({ value }) => value || ''))( + Component +); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/dropdown_filter/index.js b/x-pack/plugins/canvas/canvas_plugin_src/renderers/dropdown_filter/index.js new file mode 100644 index 0000000000000..caef38781bdb6 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/dropdown_filter/index.js @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import ReactDOM from 'react-dom'; +import React from 'react'; +import { get } from 'lodash'; +import { fromExpression, toExpression } from '../../../common/lib/ast'; +import { DropdownFilter } from './component'; + +export const dropdownFilter = () => ({ + name: 'dropdown_filter', + displayName: 'Dropdown Filter', + help: 'A dropdown from which you can select values for an "exactly" filter', + reuseDomNode: true, + height: 50, + render(domNode, config, handlers) { + let value = '%%CANVAS_MATCH_ALL%%'; + if (handlers.getFilter() !== '') { + const filterAST = fromExpression(handlers.getFilter()); + value = get(filterAST, 'chain[0].arguments.value[0]'); + } + + const commit = value => { + if (value === '%%CANVAS_MATCH_ALL%%') { + handlers.setFilter(''); + } else { + const newFilterAST = { + type: 'expression', + chain: [ + { + type: 'function', + function: 'exactly', + arguments: { + value: [value], + column: [config.column], + }, + }, + ], + }; + + const filter = toExpression(newFilterAST); + handlers.setFilter(filter); + } + }; + + // Get choices + const choices = config.choices; + + ReactDOM.render( + , + domNode, + () => handlers.done() + ); + + handlers.onDestroy(() => { + handlers.setFilter(''); + ReactDOM.unmountComponentAtNode(domNode); + }); + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/error/error.scss b/x-pack/plugins/canvas/canvas_plugin_src/renderers/error/error.scss new file mode 100644 index 0000000000000..8be0dc208e6bb --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/error/error.scss @@ -0,0 +1,16 @@ +.canvasRenderError { + display: flex; + justify-content: center; + align-items: center; + + .canvasRenderError__icon { + opacity: 0.4; + stroke: white; + stroke-width: 0.2px; + + &:hover { + opacity: 0.6; + cursor: pointer; + } + } +} diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/error/index.js b/x-pack/plugins/canvas/canvas_plugin_src/renderers/error/index.js new file mode 100644 index 0000000000000..23d4b28b89811 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/error/index.js @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import ReactDOM from 'react-dom'; +import React from 'react'; +import { EuiIcon } from '@elastic/eui'; +import { Error } from '../../../public/components/error'; +import { Popover } from '../../../public/components/popover'; + +export const error = () => ({ + name: 'error', + displayName: 'Error Information', + help: 'Render error data in a way that is helpful to users', + reuseDomNode: true, + render(domNode, config, handlers) { + const draw = () => { + const buttonSize = Math.min(domNode.clientHeight, domNode.clientWidth); + const button = handleClick => ( + + ); + + ReactDOM.render( +
+ {() => } +
, + + domNode, + () => handlers.done() + ); + }; + + draw(); + + handlers.onResize(draw); + + handlers.onDestroy(() => ReactDOM.unmountComponentAtNode(domNode)); + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/image.js b/x-pack/plugins/canvas/canvas_plugin_src/renderers/image.js new file mode 100644 index 0000000000000..c4a6578c2edfd --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/image.js @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import ReactDOM from 'react-dom'; +import React from 'react'; +import { elasticLogo } from '../lib/elastic_logo'; +import { isValid } from '../../common/lib/url'; + +export const image = () => ({ + name: 'image', + displayName: 'Image', + help: 'Render an image', + reuseDomNode: true, + render(domNode, config, handlers) { + const dataurl = isValid(config.dataurl) ? config.dataurl : elasticLogo; + + const style = { + height: '100%', + backgroundImage: `url(${dataurl})`, + backgroundRepeat: 'no-repeat', + backgroundPosition: 'center center', + backgroundSize: config.mode, + }; + + ReactDOM.render(
, domNode, () => handlers.done()); + + handlers.onDestroy(() => ReactDOM.unmountComponentAtNode(domNode)); + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/index.js b/x-pack/plugins/canvas/canvas_plugin_src/renderers/index.js new file mode 100644 index 0000000000000..dadabdfc6bca3 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/index.js @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { advancedFilter } from './advanced_filter'; +import { dropdownFilter } from './dropdown_filter'; +import { debug } from './debug'; +import { error } from './error'; +import { image } from './image'; +import { repeatImage } from './repeat_image'; +import { revealImage } from './reveal_image'; +import { markdown } from './markdown'; +import { metric } from './metric'; +import { pie } from './pie'; +import { plot } from './plot'; +import { shape } from './shape'; +import { table } from './table'; +import { timeFilter } from './time_filter'; +import { text } from './text'; + +export const renderFunctions = [ + advancedFilter, + dropdownFilter, + debug, + error, + image, + repeatImage, + revealImage, + markdown, + metric, + pie, + plot, + shape, + table, + timeFilter, + text, +]; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/markdown/index.js b/x-pack/plugins/canvas/canvas_plugin_src/renderers/markdown/index.js new file mode 100644 index 0000000000000..dcc22f5d9f03e --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/markdown/index.js @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import Markdown from 'markdown-it'; +import './markdown.scss'; + +const md = new Markdown(); + +export const markdown = () => ({ + name: 'markdown', + displayName: 'Markdown', + help: 'Render HTML Markup using Markdown input', + reuseDomNode: true, + render(domNode, config, handlers) { + const html = { __html: md.render(String(config.content)) }; + const fontStyle = config.font ? config.font.spec : {}; + + /* eslint-disable react/no-danger */ + ReactDOM.render( +
, + domNode, + () => handlers.done() + ); + /* eslint-enable */ + + handlers.onDestroy(() => ReactDOM.unmountComponentAtNode(domNode)); + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/markdown/markdown.scss b/x-pack/plugins/canvas/canvas_plugin_src/renderers/markdown/markdown.scss new file mode 100644 index 0000000000000..ce4fcdc03d6c3 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/markdown/markdown.scss @@ -0,0 +1,304 @@ +@import '../../lib/eui.scss'; + +// Kibana Canvas - Default styles for Markdown element +// +// 1. Links +// 2. Headings +// 3. Images +// 4. Blockquotes +// 5. Horizontal rules +// 6. Lists +// 7. Tables +// 8. Code blocks + +// Functions +// Note: The inlined base font size is set in common/functions/font.js. It should match $canvasMdFontSize. +$canvasDefaultFontSize: 14px; + +@function canvasToEm($size) { + @return #{$size / $canvasDefaultFontSize}em; +} + +.canvas .canvasMarkdown { + + // Font size variables + $canvasMarkdownFontSizeS: canvasToEm(12px); + $canvasMarkdownFontSize: canvasToEm(14px); + $canvasMarkdownFontSizeL: canvasToEm(20px); + $canvasMarkdownFontSizeXL: canvasToEm(28px); + $canvasMarkdownFontSizeXXL: canvasToEm(36px); + + // Spacing variables + $canvasMarkdownSizeL: canvasToEm(24px); + $canvasMarkdownSize: canvasToEm(16px); + $canvasMarkdownSizeS: canvasToEm(12px); + $canvasMarkdownSizeXS: canvasToEm(8px); + $canvasMarkdownSizeXXS: canvasToEm(4px); + + // Grayscale variables + $canvasMarkdownAlphaLightestShade: rgba(0,0,0,.05); + $canvasMarkdownAlphaLightShade: rgba(0,0,0,.15); + $canvasMarkdownAlphaDarkShade: rgba(0,0,0,.65); + + > *:first-child { + margin-top: 0 !important; + } + + > *:last-child { + margin-bottom: 0 !important; + } + + p, + blockquote, + ul, + ol, + dl, + table, + pre { + margin-top: 0; + margin-bottom: $canvasMarkdownSize; + line-height: 1.5em; + } + + strong { + font-weight: 600; + } + + // 1. Links + a { + color: inherit; + text-decoration: underline; + } + + a:hover { + text-decoration: underline dotted; + } + + a:active, + a:hover { + outline-width: 0; + } + + a:not([href]) { + color: inherit; + text-decoration: none; + } + + // 2. Headings + h1, + h2, + h3, + h4, + h5, + h6 { + margin-top: 0; + margin-bottom: $canvasMarkdownSizeXS; + } + + h1 { + font-size: $canvasMarkdownFontSizeXXL; + line-height: 1.333333em; + font-weight: 300; + } + + h2 { + font-size: $canvasMarkdownFontSizeXL; + line-height: 1.428571em; + font-weight: 300; + } + + h3 { + font-size: $canvasMarkdownFontSizeL; + line-height: 1.6em; + font-weight: 600; + } + + h4 { + font-size: $canvasMarkdownSize; + line-height: 1.5em; + font-weight: 600; + } + + h5 { + font-size: $canvasMarkdownFontSize; + line-height: 1.142857em; + font-weight: 700; + } + + h6 { + font-size: $canvasMarkdownFontSizeS; + line-height: 1.333333em; + font-weight: 700; + text-transform: uppercase; + } + + // 3. Images + img { + max-width: 100%; + box-sizing: content-box; + border-style: none; + } + + // 4. Blockquotes + blockquote { + padding: 0 1em; + border-left: $canvasMarkdownSizeXXS solid $canvasMarkdownAlphaLightShade; + } + + // 5. Horizontal rules + hr { + overflow: hidden; + background: transparent; + height: 2px; + padding: 0; + margin: $canvasMarkdownSizeL 0; + background-color: $canvasMarkdownAlphaLightShade; + border: 0; + } + + hr::before { + display: table; + content: ""; + } + + hr::after { + display: table; + clear: both; + content: ""; + } + + // 6. Lists + ul, + ol { + padding-left: 0; + margin-top: 0; + margin-bottom: $canvasMarkdownSize; + } + + ul { + list-style-type: disc; + } + ol { + list-style-type: decimal; + } + + ul ul { + list-style-type: circle; + } + + ol ol, + ul ol { + list-style-type: lower-roman; + } + + ul ul ol, + ul ol ol, + ol ul ol, + ol ol ol { + list-style-type: lower-alpha; + } + + dd { + margin-left: 0; + } + + ul, + ol { + padding-left: $canvasMarkdownSizeL; + } + + ul ul, + ul ol, + ol ol, + ol ul { + margin-top: 0; + margin-bottom: 0; + } + + li > p { + margin-bottom: $canvasMarkdownSizeXS; + } + + li + li { + margin-top: $canvasMarkdownSizeXXS; + } + + // 7. Tables + table { + display: block; + width: 100%; + overflow: auto; + border-left: 1px solid $canvasMarkdownAlphaLightShade; + border-spacing: 0; + border-collapse: collapse; + } + + td, + th { + padding: 0; + } + + table th, + table td { + padding: $canvasMarkdownSizeXXS $canvasMarkdownSizeS; + border-top: 1px solid $canvasMarkdownAlphaLightShade; + border-bottom: 1px solid $canvasMarkdownAlphaLightShade; + + &:last-child { + border-right: 1px solid $canvasMarkdownAlphaLightShade; + } + } + + table tr { + background-color: transparent; + border-top: 1px solid $canvasMarkdownAlphaLightShade; + } + + // 8. Code blocks + code, + pre { + margin-bottom: $canvasMarkdownSizeXS; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace; + font-size: $canvasMarkdownFontSizeS; + } + + code { + padding: $canvasMarkdownSizeXXS 0; + margin: 0; + background-color: $canvasMarkdownAlphaLightestShade; + border-radius: $canvasMarkdownSizeXXS; + } + + code::before, + code::after { + letter-spacing: -0.2em; + content: "\00a0"; + } + + pre { + padding: $canvasMarkdownSize; + overflow: auto; + font-size: $canvasMarkdownFontSizeS; + line-height: 1.333333em; + background-color: $canvasMarkdownAlphaLightestShade; + border-radius: $canvasMarkdownSizeXXS; + word-wrap: normal; + } + + pre code { + display: inline; + max-width: auto; + padding: 0; + overflow: visible; + line-height: inherit; + word-wrap: normal; + white-space: pre; + background-color: transparent; + border: 0; + } + + pre code::before, + pre code::after { + content: normal; + } +} diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/metric/index.js b/x-pack/plugins/canvas/canvas_plugin_src/renderers/metric/index.js new file mode 100644 index 0000000000000..688b183f9a61b --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/metric/index.js @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; + +export const metric = () => ({ + name: 'metric', + displayName: 'Metric', + help: 'Render HTML Markup for the Metric element', + reuseDomNode: true, + render(domNode, config, handlers) { + const metricFontStyle = config.metricFont ? config.metricFont.spec : {}; + const labelFontStyle = config.labelFont ? config.labelFont.spec : {}; + + ReactDOM.render( +
+
+ {config.metric} +
+
+ {config.label} +
+
, + domNode, + () => handlers.done() + ); + + handlers.onDestroy(() => ReactDOM.unmountComponentAtNode(domNode)); + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/pie/index.js b/x-pack/plugins/canvas/canvas_plugin_src/renderers/pie/index.js new file mode 100644 index 0000000000000..7d007d70b6bab --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/pie/index.js @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// This bit of hackiness is required because this isn't part of the main kibana bundle +import 'jquery'; +import '../../lib/flot-charts'; + +import { debounce, includes } from 'lodash'; +import { pie as piePlugin } from './plugins/pie'; + +export const pie = () => ({ + name: 'pie', + displayName: 'Pie chart', + help: 'Render a pie chart from data', + reuseDomNode: false, + render(domNode, config, handlers) { + if (!includes($.plot.plugins, piePlugin)) $.plot.plugins.push(piePlugin); + + config.options.legend.labelBoxBorderColor = 'transparent'; + + if (config.font) { + const labelFormatter = (label, slice) => { + // font color defaults to slice color if not specified + const fontSpec = { ...config.font.spec, color: config.font.spec.color || slice.color }; + const labelDiv = document.createElement('div'); + Object.assign(labelDiv.style, fontSpec); + const labelSpan = new DOMParser().parseFromString(label, 'text/html').body.firstChild; + const lineBreak = document.createElement('br'); + const percentText = document.createTextNode(`${Math.round(slice.percent)}%`); + + labelDiv.appendChild(labelSpan); + labelDiv.appendChild(lineBreak); + labelDiv.appendChild(percentText); + return labelDiv.outerHTML; + }; + config.options.series.pie.label.formatter = labelFormatter; + + const legendFormatter = label => { + const labelSpan = document.createElement('span'); + Object.assign(labelSpan.style, config.font.spec); + labelSpan.textContent = label; + return labelSpan.outerHTML; + }; + config.options.legend.labelFormatter = legendFormatter; + } + + let plot; + function draw() { + if (domNode.clientHeight < 1 || domNode.clientWidth < 1) return; + + try { + $(domNode).empty(); + if (!config.data || !config.data.length) $(domNode).empty(); + else plot = $.plot($(domNode), config.data, config.options); + } catch (e) { + console.log(e); + // Nope + } + } + + function destroy() { + if (plot) plot.shutdown(); + } + + handlers.onDestroy(destroy); + handlers.onResize(debounce(draw, 40, { maxWait: 40 })); // 1000 / 40 = 25fps + + draw(); + + return handlers.done(); + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/pie/plugins/pie.js b/x-pack/plugins/canvas/canvas_plugin_src/renderers/pie/plugins/pie.js new file mode 100644 index 0000000000000..4e6616a625023 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/pie/plugins/pie.js @@ -0,0 +1,822 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import $ from 'jquery'; + +/* Flot plugin for rendering pie charts. + +Copyright (c) 2007-2014 IOLA and Ole Laursen. + +Licensed under the MIT license. + +The plugin assumes that each series has a single data value, and that each +value is a positive integer or zero. Negative numbers don't make sense for a +pie chart, and have unpredictable results. The values do NOT need to be +passed in as percentages; the plugin will calculate the total and per-slice +percentages internally. + +* Created by Brian Medendorp + +* Updated with contributions from btburnett3, Anthony Aragues and Xavi Ivars +* Updated 2018 for Kibana Canvas by Rashid Khan + +The plugin supports these options: + + series: { + pie: { + show: true/false + radius: 0-1 for percentage of fullsize, or a specified pixel length, or 'auto' + innerRadius: 0-1 for percentage of fullsize or a specified pixel length, for creating a donut effect + startAngle: 0-2 factor of PI used for starting angle (in radians) i.e 3/2 starts at the top, 0 and 2 have the same result + tilt: 0-1 for percentage to tilt the pie, where 1 is no tilt, and 0 is completely flat (nothing will show) + offset: { + top: integer value to move the pie up or down + left: integer value to move the pie left or right, or 'auto' + }, + stroke: { + color: any hexidecimal color value (other formats may or may not work, so best to stick with something like '#FFF') + width: integer pixel width of the stroke + }, + label: { + show: true/false, or 'auto' + formatter: a user-defined function that modifies the text/style of the label text + radius: 0-1 for percentage of fullsize, or a specified pixel length + background: { + color: any hexidecimal color value (other formats may or may not work, so best to stick with something like '#000') + opacity: 0-1 + }, + threshold: 0-1 for the percentage value at which to hide labels (if they're too small) + }, + combine: { + threshold: 0-1 for the percentage value at which to combine slices (if they're too small) + color: any hexidecimal color value (other formats may or may not work, so best to stick with something like '#CCC'), if null, the plugin will automatically use the color of the first slice to be combined + label: any text value of what the combined slice should be labeled + } + highlight: { + opacity: 0-1 + } + } + } + +More detail and specific examples can be found in the included HTML file. + +*/ + +// Maximum redraw attempts when fitting labels within the plot + +const REDRAW_ATTEMPTS = 10; + +// Factor by which to shrink the pie when fitting labels within the plot + +const REDRAW_SHRINK = 0.95; + +function init(plot) { + let canvas = null; + let target = null; + let options = null; + let maxRadius = null; + let centerLeft = null; + let centerTop = null; + let processed = false; + let ctx = null; + + // interactive variables + + let highlights = []; + + // add hook to determine if pie plugin in enabled, and then perform necessary operations + + plot.hooks.processOptions.push(function(plot, options) { + if (options.series.pie.show) { + options.grid.show = false; + + // set labels.show + + if (options.series.pie.label.show === 'auto') { + if (options.legend.show) options.series.pie.label.show = false; + else options.series.pie.label.show = true; + } + + // set radius + + if (options.series.pie.radius === 'auto') { + if (options.series.pie.label.show) options.series.pie.radius = 3 / 4; + else options.series.pie.radius = 1; + } + + // ensure sane tilt + + if (options.series.pie.tilt > 1) options.series.pie.tilt = 1; + else if (options.series.pie.tilt < 0) options.series.pie.tilt = 0; + } + }); + + plot.hooks.bindEvents.push(function(plot, eventHolder) { + const options = plot.getOptions(); + if (options.series.pie.show) { + if (options.grid.hoverable) eventHolder.unbind('mousemove').mousemove(onMouseMove); + + if (options.grid.clickable) eventHolder.unbind('click').click(onClick); + } + }); + + plot.hooks.processDatapoints.push(function(plot, series, data, datapoints) { + const options = plot.getOptions(); + if (options.series.pie.show) processDatapoints(plot, series, data, datapoints); + }); + + plot.hooks.drawOverlay.push(function(plot, octx) { + const options = plot.getOptions(); + if (options.series.pie.show) drawOverlay(plot, octx); + }); + + plot.hooks.draw.push(function(plot, newCtx) { + const options = plot.getOptions(); + if (options.series.pie.show) draw(plot, newCtx); + }); + + function processDatapoints(plot) { + if (!processed) { + processed = true; + canvas = plot.getCanvas(); + target = $(canvas).parent(); + options = plot.getOptions(); + plot.setData(combine(plot.getData())); + } + } + + function combine(data) { + let total = 0; + let combined = 0; + let numCombined = 0; + let color = options.series.pie.combine.color; + const newdata = []; + + // Fix up the raw data from Flot, ensuring the data is numeric + + for (let i = 0; i < data.length; ++i) { + let value = data[i].data; + + // If the data is an array, we'll assume that it's a standard + // Flot x-y pair, and are concerned only with the second value. + + // Note how we use the original array, rather than creating a + // new one; this is more efficient and preserves any extra data + // that the user may have stored in higher indexes. + + if (Array.isArray(value) && value.length === 1) value = value[0]; + + if (Array.isArray(value)) { + // Equivalent to $.isNumeric() but compatible with jQuery < 1.7 + if (!isNaN(parseFloat(value[1])) && isFinite(value[1])) value[1] = +value[1]; + else value[1] = 0; + } else if (!isNaN(parseFloat(value)) && isFinite(value)) { + value = [1, +value]; + } else { + value = [1, 0]; + } + + data[i].data = [value]; + } + + // Sum up all the slices, so we can calculate percentages for each + + for (let i = 0; i < data.length; ++i) total += data[i].data[0][1]; + + // Count the number of slices with percentages below the combine + // threshold; if it turns out to be just one, we won't combine. + + for (let i = 0; i < data.length; ++i) { + const value = data[i].data[0][1]; + if (value / total <= options.series.pie.combine.threshold) { + combined += value; + numCombined++; + if (!color) color = data[i].color; + } + } + + for (let i = 0; i < data.length; ++i) { + const value = data[i].data[0][1]; + if (numCombined < 2 || value / total > options.series.pie.combine.threshold) { + newdata.push( + $.extend(data[i], { + /* extend to allow keeping all other original data values + and using them e.g. in labelFormatter. */ + data: [[1, value]], + color: data[i].color, + label: data[i].label, + angle: (value * Math.PI * 2) / total, + percent: value / (total / 100), + }) + ); + } + } + + if (numCombined > 1) { + newdata.push({ + data: [[1, combined]], + color: color, + label: options.series.pie.combine.label, + angle: (combined * Math.PI * 2) / total, + percent: combined / (total / 100), + }); + } + + return newdata; + } + + function draw(plot, newCtx) { + if (!target) return; // if no series were passed + + const canvasWidth = plot.getPlaceholder().width(); + const canvasHeight = plot.getPlaceholder().height(); + const legendWidth = + target + .children() + .filter('.legend') + .children() + .width() || 0; + + ctx = newCtx; + + // WARNING: HACK! REWRITE THIS CODE AS SOON AS POSSIBLE! + + // When combining smaller slices into an 'other' slice, we need to + // add a new series. Since Flot gives plugins no way to modify the + // list of series, the pie plugin uses a hack where the first call + // to processDatapoints results in a call to setData with the new + // list of series, then subsequent processDatapoints do nothing. + + // The plugin-global 'processed' flag is used to control this hack; + // it starts out false, and is set to true after the first call to + // processDatapoints. + + // Unfortunately this turns future setData calls into no-ops; they + // call processDatapoints, the flag is true, and nothing happens. + + // To fix this we'll set the flag back to false here in draw, when + // all series have been processed, so the next sequence of calls to + // processDatapoints once again starts out with a slice-combine. + // This is really a hack; in 0.9 we need to give plugins a proper + // way to modify series before any processing begins. + + processed = false; + + // calculate maximum radius and center point + + maxRadius = Math.min(canvasWidth, canvasHeight / options.series.pie.tilt) / 2; + centerTop = canvasHeight / 2 + options.series.pie.offset.top; + centerLeft = canvasWidth / 2; + + if (options.series.pie.offset.left === 'auto') { + if (options.legend.position.match('w')) centerLeft += legendWidth / 2; + else centerLeft -= legendWidth / 2; + + if (centerLeft < maxRadius) centerLeft = maxRadius; + else if (centerLeft > canvasWidth - maxRadius) centerLeft = canvasWidth - maxRadius; + } else { + centerLeft += options.series.pie.offset.left; + } + + const slices = plot.getData(); + let attempts = 0; + + // Keep shrinking the pie's radius until drawPie returns true, + // indicating that all the labels fit, or we try too many times. + + do { + if (attempts > 0) maxRadius *= REDRAW_SHRINK; + + attempts += 1; + clear(); + if (options.series.pie.tilt <= 0.8) drawShadow(); + } while (!drawPie() && attempts < REDRAW_ATTEMPTS); + + if (attempts >= REDRAW_ATTEMPTS) { + clear(); + target.prepend( + "
Could not draw pie with labels contained inside canvas
" + ); + } + + if (plot.setSeries && plot.insertLegend) { + plot.setSeries(slices); + plot.insertLegend(); + } + + // we're actually done at this point, just defining internal functions at this point + + function clear() { + ctx.clearRect(0, 0, canvasWidth, canvasHeight); + target + .children() + .filter('.pieLabel, .pieLabelBackground') + .remove(); + } + + function drawShadow() { + const shadowLeft = options.series.pie.shadow.left; + const shadowTop = options.series.pie.shadow.top; + const edge = 10; + const alpha = options.series.pie.shadow.alpha; + let radius = + options.series.pie.radius > 1 + ? options.series.pie.radius + : maxRadius * options.series.pie.radius; + + if ( + radius >= canvasWidth / 2 - shadowLeft || + radius * options.series.pie.tilt >= canvasHeight / 2 - shadowTop || + radius <= edge + ) + return; // shadow would be outside canvas, so don't draw it + + ctx.save(); + ctx.translate(shadowLeft, shadowTop); + ctx.globalAlpha = alpha; + ctx.fillStyle = '#000'; + + // center and rotate to starting position + + ctx.translate(centerLeft, centerTop); + ctx.scale(1, options.series.pie.tilt); + + //radius -= edge; + + for (let i = 1; i <= edge; i++) { + ctx.beginPath(); + ctx.arc(0, 0, radius, 0, Math.PI * 2, false); + ctx.fill(); + radius -= i; + } + + ctx.restore(); + } + + function drawPie() { + const startAngle = Math.PI * options.series.pie.startAngle; + const radius = + options.series.pie.radius > 1 + ? options.series.pie.radius + : maxRadius * options.series.pie.radius; + + // center and rotate to starting position + + ctx.save(); + ctx.translate(centerLeft, centerTop); + ctx.scale(1, options.series.pie.tilt); + //ctx.rotate(startAngle); // start at top; -- This doesn't work properly in Opera + + // draw slices + + ctx.save(); + let currentAngle = startAngle; + for (let i = 0; i < slices.length; ++i) { + slices[i].startAngle = currentAngle; + drawSlice(slices[i].angle, slices[i].color, true); + } + ctx.restore(); + + // draw slice outlines + + if (options.series.pie.stroke.width > 0) { + ctx.save(); + ctx.lineWidth = options.series.pie.stroke.width; + currentAngle = startAngle; + for (let i = 0; i < slices.length; ++i) + drawSlice(slices[i].angle, options.series.pie.stroke.color, false); + + ctx.restore(); + } + + // draw donut hole + + drawDonutHole(ctx); + + ctx.restore(); + + // Draw the labels, returning true if they fit within the plot + + if (options.series.pie.label.show) return drawLabels(); + else return true; + + function drawSlice(angle, color, fill) { + if (angle <= 0 || isNaN(angle)) return; + + if (fill) { + ctx.fillStyle = color; + } else { + ctx.strokeStyle = color; + ctx.lineJoin = 'round'; + } + + ctx.beginPath(); + if (Math.abs(angle - Math.PI * 2) > 0.000000001) ctx.moveTo(0, 0); // Center of the pie + + //ctx.arc(0, 0, radius, 0, angle, false); // This doesn't work properly in Opera + ctx.arc(0, 0, radius, currentAngle, currentAngle + angle / 2, false); + ctx.arc(0, 0, radius, currentAngle + angle / 2, currentAngle + angle, false); + ctx.closePath(); + //ctx.rotate(angle); // This doesn't work properly in Opera + currentAngle += angle; + + if (fill) ctx.fill(); + else ctx.stroke(); + } + + function drawLabels() { + let currentAngle = startAngle; + const radius = + options.series.pie.label.radius > 1 + ? options.series.pie.label.radius + : maxRadius * options.series.pie.label.radius; + + for (let i = 0; i < slices.length; ++i) { + if (slices[i].percent >= options.series.pie.label.threshold * 100) + if (!drawLabel(slices[i], currentAngle, i)) return false; + + currentAngle += slices[i].angle; + } + + return true; + + function drawLabel(slice, startAngle, index) { + if (slice.data[0][1] === 0) return true; + + // format label text + + const lf = options.legend.labelFormatter; + let text; + const plf = options.series.pie.label.formatter; + + if (lf) text = lf(slice.label, slice); + else text = slice.label; + + if (plf) text = plf(text, slice); + + const halfAngle = (startAngle + slice.angle + startAngle) / 2; + const x = centerLeft + Math.round(Math.cos(halfAngle) * radius); + const y = centerTop + Math.round(Math.sin(halfAngle) * radius) * options.series.pie.tilt; + + const html = + "" + + text + + ''; + target.append(html); + + const label = target.children('#pieLabel' + index); + const labelTop = y - label.height() / 2; + const labelLeft = x - label.width() / 2; + + label.css('top', labelTop); + label.css('left', labelLeft); + + // check to make sure that the label is not outside the canvas + + if ( + 0 - labelTop > 0 || + 0 - labelLeft > 0 || + canvasHeight - (labelTop + label.height()) < 0 || + canvasWidth - (labelLeft + label.width()) < 0 + ) + return false; + + if (options.series.pie.label.background.opacity !== 0) { + // put in the transparent background separately to avoid blended labels and label boxes + + let c = options.series.pie.label.background.color; + + if (c == null) c = slice.color; + + const pos = 'top:' + labelTop + 'px;left:' + labelLeft + 'px;'; + $( + "
" + ) + .css('opacity', options.series.pie.label.background.opacity) + .insertBefore(label); + } + + return true; + } // end individual label function + } // end drawLabels function + } // end drawPie function + } // end draw function + + // Placed here because it needs to be accessed from multiple locations + + function drawDonutHole(layer) { + if (options.series.pie.innerRadius > 0) { + // subtract the center + + layer.save(); + const innerRadius = + options.series.pie.innerRadius > 1 + ? options.series.pie.innerRadius + : maxRadius * options.series.pie.innerRadius; + layer.globalCompositeOperation = 'destination-out'; // this does not work with excanvas, but it will fall back to using the stroke color + layer.beginPath(); + layer.fillStyle = options.series.pie.stroke.color; + layer.arc(0, 0, innerRadius, 0, Math.PI * 2, false); + layer.fill(); + layer.closePath(); + layer.restore(); + + // add inner stroke + // TODO: Canvas forked flot here! + if (options.series.pie.stroke.width > 0) { + layer.save(); + layer.beginPath(); + layer.strokeStyle = options.series.pie.stroke.color; + layer.arc(0, 0, innerRadius, 0, Math.PI * 2, false); + layer.stroke(); + layer.closePath(); + layer.restore(); + } + + // TODO: add extra shadow inside hole (with a mask) if the pie is tilted. + } + } + + //-- Additional Interactive related functions -- + + function isPointInPoly(poly, pt) { + let c = false; + const l = poly.length; + let j = l - 1; + for (let i = -1; ++i < l; j = i) { + ((poly[i][1] <= pt[1] && pt[1] < poly[j][1]) || + (poly[j][1] <= pt[1] && pt[1] < poly[i][1])) && + pt[0] < + ((poly[j][0] - poly[i][0]) * (pt[1] - poly[i][1])) / (poly[j][1] - poly[i][1]) + + poly[i][0] && + (c = !c); + } + return c; + } + + function findNearbySlice(mouseX, mouseY) { + const slices = plot.getData(); + const options = plot.getOptions(); + const radius = + options.series.pie.radius > 1 + ? options.series.pie.radius + : maxRadius * options.series.pie.radius; + let x; + let y; + + for (let i = 0; i < slices.length; ++i) { + const s = slices[i]; + + if (s.pie.show) { + ctx.save(); + ctx.beginPath(); + ctx.moveTo(0, 0); // Center of the pie + //ctx.scale(1, options.series.pie.tilt); // this actually seems to break everything when here. + ctx.arc(0, 0, radius, s.startAngle, s.startAngle + s.angle / 2, false); + ctx.arc(0, 0, radius, s.startAngle + s.angle / 2, s.startAngle + s.angle, false); + ctx.closePath(); + x = mouseX - centerLeft; + y = mouseY - centerTop; + + if (ctx.isPointInPath) { + if (ctx.isPointInPath(mouseX - centerLeft, mouseY - centerTop)) { + ctx.restore(); + return { + datapoint: [s.percent, s.data], + dataIndex: 0, + series: s, + seriesIndex: i, + }; + } + } else { + // excanvas for IE doesn;t support isPointInPath, this is a workaround. + + const p1X = radius * Math.cos(s.startAngle); + const p1Y = radius * Math.sin(s.startAngle); + const p2X = radius * Math.cos(s.startAngle + s.angle / 4); + const p2Y = radius * Math.sin(s.startAngle + s.angle / 4); + const p3X = radius * Math.cos(s.startAngle + s.angle / 2); + const p3Y = radius * Math.sin(s.startAngle + s.angle / 2); + const p4X = radius * Math.cos(s.startAngle + s.angle / 1.5); + const p4Y = radius * Math.sin(s.startAngle + s.angle / 1.5); + const p5X = radius * Math.cos(s.startAngle + s.angle); + const p5Y = radius * Math.sin(s.startAngle + s.angle); + const arrPoly = [[0, 0], [p1X, p1Y], [p2X, p2Y], [p3X, p3Y], [p4X, p4Y], [p5X, p5Y]]; + const arrPoint = [x, y]; + + // TODO: perhaps do some mathmatical trickery here with the Y-coordinate to compensate for pie tilt? + + if (isPointInPoly(arrPoly, arrPoint)) { + ctx.restore(); + return { + datapoint: [s.percent, s.data], + dataIndex: 0, + series: s, + seriesIndex: i, + }; + } + } + + ctx.restore(); + } + } + + return null; + } + + function onMouseMove(e) { + triggerClickHoverEvent('plothover', e); + } + + function onClick(e) { + triggerClickHoverEvent('plotclick', e); + } + + // trigger click or hover event (they send the same parameters so we share their code) + + function triggerClickHoverEvent(eventname, e) { + const offset = plot.offset(); + const canvasX = parseInt(e.pageX - offset.left, 10); + const canvasY = parseInt(e.pageY - offset.top, 10); + const item = findNearbySlice(canvasX, canvasY); + + if (options.grid.autoHighlight) { + // clear auto-highlights + + for (let i = 0; i < highlights.length; ++i) { + const h = highlights[i]; + if (h.auto === eventname && !(item && h.series === item.series)) unhighlight(h.series); + } + } + + // highlight the slice + + if (item) highlight(item.series, eventname); + + // trigger any hover bind events + + const pos = { pageX: e.pageX, pageY: e.pageY }; + target.trigger(eventname, [pos, item]); + } + + function highlight(s, auto) { + //if (typeof s == "number") { + // s = series[s]; + //} + + const i = indexOfHighlight(s); + + if (i === -1) { + highlights.push({ series: s, auto: auto }); + plot.triggerRedrawOverlay(); + } else if (!auto) { + highlights[i].auto = false; + } + } + + function unhighlight(s) { + if (s == null) { + highlights = []; + plot.triggerRedrawOverlay(); + } + + //if (typeof s == "number") { + // s = series[s]; + //} + + const i = indexOfHighlight(s); + + if (i !== -1) { + highlights.splice(i, 1); + plot.triggerRedrawOverlay(); + } + } + + function indexOfHighlight(s) { + for (let i = 0; i < highlights.length; ++i) { + const h = highlights[i]; + if (h.series === s) return i; + } + return -1; + } + + function drawOverlay(plot, octx) { + const options = plot.getOptions(); + + const radius = + options.series.pie.radius > 1 + ? options.series.pie.radius + : maxRadius * options.series.pie.radius; + + octx.save(); + octx.translate(centerLeft, centerTop); + octx.scale(1, options.series.pie.tilt); + + for (let i = 0; i < highlights.length; ++i) drawHighlight(highlights[i].series); + + drawDonutHole(octx); + + octx.restore(); + + function drawHighlight(series) { + if (series.angle <= 0 || isNaN(series.angle)) return; + + //octx.fillStyle = parseColor(options.series.pie.highlight.color).scale(null, null, null, options.series.pie.highlight.opacity).toString(); + octx.fillStyle = 'rgba(255, 255, 255, ' + options.series.pie.highlight.opacity + ')'; // this is temporary until we have access to parseColor + octx.beginPath(); + if (Math.abs(series.angle - Math.PI * 2) > 0.000000001) octx.moveTo(0, 0); // Center of the pie + + octx.arc(0, 0, radius, series.startAngle, series.startAngle + series.angle / 2, false); + octx.arc( + 0, + 0, + radius, + series.startAngle + series.angle / 2, + series.startAngle + series.angle, + false + ); + octx.closePath(); + octx.fill(); + } + } +} // end init (plugin body) + +// define pie specific options and their default values + +const options = { + series: { + pie: { + show: false, + radius: 'auto', // actual radius of the visible pie (based on full calculated radius if <=1, or hard pixel value) + innerRadius: 0 /* for donut */, + startAngle: 3 / 2, + tilt: 1, + shadow: { + left: 5, // shadow left offset + top: 15, // shadow top offset + alpha: 0.02, // shadow alpha + }, + offset: { + top: 0, + left: 'auto', + }, + stroke: { + color: '#fff', + width: 1, + }, + label: { + show: 'auto', + formatter: function(label, slice) { + return ( + "
" + + label + + '
' + + Math.round(slice.percent) + + '%
' + ); + }, // formatter function + radius: 1, // radius at which to place the labels (based on full calculated radius if <=1, or hard pixel value) + background: { + color: null, + opacity: 0, + }, + threshold: 0, // percentage at which to hide the label (i.e. the slice is too narrow) + }, + combine: { + threshold: -1, // percentage at which to combine little slices into one larger slice + color: null, // color to give the new slice (auto-generated if null) + label: 'Other', // label to give the new slice + }, + highlight: { + //color: "#fff", // will add this functionality once parseColor is available + opacity: 0.5, + }, + }, + }, +}; + +export const pie = { + init: init, + options: options, + name: 'pie', + version: '1.1', +}; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/plot/index.js b/x-pack/plugins/canvas/canvas_plugin_src/renderers/plot/index.js new file mode 100644 index 0000000000000..67f26abb1c6b8 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/plot/index.js @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// This bit of hackiness is required because this isn't part of the main kibana bundle +import 'jquery'; +import '../../lib/flot-charts'; + +import { debounce, includes } from 'lodash'; +import { size } from './plugins/size'; +import { text } from './plugins/text'; +import './plot.scss'; + +const render = (domNode, config, handlers) => { + // TODO: OH NOES + if (!includes($.plot.plugins, size)) $.plot.plugins.push(size); + if (!includes($.plot.plugins, text)) $.plot.plugins.push(text); + + let plot; + function draw() { + if (domNode.clientHeight < 1 || domNode.clientWidth < 1) return; + + if (config.font) { + const legendFormatter = label => { + const labelSpan = document.createElement('span'); + Object.assign(labelSpan.style, config.font.spec); + labelSpan.textContent = label; + return labelSpan.outerHTML; + }; + config.options.legend.labelFormatter = legendFormatter; + } + + try { + if (!plot) { + plot = $.plot($(domNode), config.data, config.options); + } else { + plot.resize(); + plot.setupGrid(); + plot.draw(); + } + } catch (e) { + // Nope + } + } + + function destroy() { + if (plot) plot.shutdown(); + } + + handlers.onDestroy(destroy); + handlers.onResize(debounce(draw, 40, { maxWait: 40 })); // 1000 / 40 = 25fps + + draw(); + + return handlers.done(); +}; + +export const plot = () => ({ + name: 'plot', + displayName: 'Coordinate plot', + help: 'Render an XY plot from your data', + render, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/plot/plot.scss b/x-pack/plugins/canvas/canvas_plugin_src/renderers/plot/plot.scss new file mode 100644 index 0000000000000..edfeef066069c --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/plot/plot.scss @@ -0,0 +1,10 @@ +@import '../../lib/eui.scss'; + +// I assume these are flot specific selector names and should not be renamed +.legendLabel { + color: $euiTextColor; +} + +.legendColorBox > div > div { + border-radius: $euiSize; +} diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/plot/plugins/size.js b/x-pack/plugins/canvas/canvas_plugin_src/renderers/plot/plugins/size.js new file mode 100644 index 0000000000000..1f582170e8f88 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/plot/plugins/size.js @@ -0,0 +1,117 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { min, max, map, flatten } from 'lodash'; + +/* + * The MIT License +Copyright (c) 2010, 2011, 2012, 2013 by Juergen Marsch +Copyright (c) 2015 by Alexander Wunschik +Copyright (c) 2015 by Stefan Siegl +Copyright (c) 2015 by Pascal Vervest +Copyright (c) 2017 by Rashid Khan +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +The below is based on the flot bubble plugin, but with all the complex overlay logic stripped + +*/ + +const pluginName = 'simpleBubble'; +const pluginVersion = '0.1.0'; + +const options = { + series: { + bubbles: { + size: { + max: 20, + min: 2, + }, + active: true, + show: true, + fill: false, + drawbubble: drawbubbleDefault, + }, + }, +}; + +function drawbubbleDefault(ctx, series, x, y, radius, c) { + ctx.fillStyle = c; + if (series.bubbles.fill) ctx.globalAlpha = series.bubbles.fill; + ctx.strokeStyle = c; + + ctx.lineWidth = Math.round(radius / 3); + ctx.beginPath(); + + ctx.arc(x, y, radius, 0, Math.PI * 2, true); + ctx.closePath(); + if (series.bubbles.fill) ctx.fill(); + else ctx.stroke(); +} + +function init(plot) { + plot.hooks.processOptions.push(processOptions); + + function processOptions(plot, options) { + if (options.series.bubbles.active) plot.hooks.drawSeries.push(drawSeries); + } + + function drawSeries(plot, ctx, series) { + // Actually need to calculate the min/max for the entire set up here, not on an individual series basis; + const allSizes = map(map(flatten(map(plot.getData(), 'data')), 2), 'size'); + const minPoint = min(allSizes); + const maxPoint = max(allSizes); + + if (series.bubbles.show) { + const offset = plot.getPlotOffset(); + + function drawPoint(point) { + const x = offset.left + series.xaxis.p2c(point[0]); + const y = offset.top + series.yaxis.p2c(point[1]); + const size = point[2].size; + + const delta = maxPoint - minPoint; + const radius = (function() { + if (size == null) return 0; // If there is no size, draw nothing + if (delta === 0) return series.bubbles.size.min; // If there is no difference between the min and the max, draw the minimum bubble. + + // Otherwise draw something between the min and max acceptable radius. + return ( + ((series.bubbles.size.max - series.bubbles.size.min) / delta) * (size - minPoint) + + series.bubbles.size.min + ); + })(); + + const color = series.color === 'function' ? series.color.apply(this, point) : series.color; + + const seriesBubbleDrawFn = series.bubbles.drawbubble; + seriesBubbleDrawFn(ctx, series, x, y, radius, color); + } + + series.data.forEach(point => drawPoint(point)); + } + } +} + +export const size = { + init: init, + options: options, + name: pluginName, + version: pluginVersion, +}; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/plot/plugins/text.js b/x-pack/plugins/canvas/canvas_plugin_src/renderers/plot/plugins/text.js new file mode 100644 index 0000000000000..7dfcd2c2f2b68 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/plot/plugins/text.js @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import $ from 'jquery'; +import { get } from 'lodash'; + +/* + FreeBSD-License +*/ +const options = { + numbers: {}, +}; + +const xAlign = function(x) { + return x; +}; +const yAlign = function(y) { + return y; +}; +//const horizontalShift = 1; + +function processOptions(/*plot, options*/) { + // Nothing +} + +function draw(plot, ctx) { + $('.valueLabel', plot.getPlaceholder()).remove(); + plot.getData().forEach(function(series) { + const show = get(series.numbers, 'show'); + if (!show) return; + + let points = series.data; + + // TODO: This might only work on single x and y axis charts. + if (series.stack != null) { + points = points.map((point, i) => { + const p = point.slice(0); + + // This magic * 3 and + 1 are due to the way the stacking plugin for flot modifies the series. + // Note that series.data and series.datapoints.point are different, both in meaning and in format + // series.data is the original data supplied by the user + // series.datapoints.point are the calculated points made as result of data processing. + p[1] = series.datapoints.points[i * 3 + 1]; + return p; + }); + } + + const offset = plot.getPlotOffset(); + ctx.save(); + ctx.textBaseline = 'middle'; + ctx.textAlign = 'center'; + + function writeText(text, x, y) { + if (typeof text === 'undefined') return; + const textNode = $('
') + .text(String(text)) + .addClass('valueLabel') + .css({ + position: 'absolute', + }); + + plot.getPlaceholder().append(textNode); + + textNode.css({ + left: x - textNode.width() / 2, + top: y - textNode.height() / 2, + }); + } + + for (let i = 0; i < points.length; i++) { + const point = { + x: xAlign(points[i][0]), + y: yAlign(points[i][1]), // Need to calculate here. + }; + + const text = points[i][2].text; + const c = plot.p2c(point); + writeText(text, c.left + offset.left, c.top + offset.top + 1); + } + + ctx.restore(); + }); +} + +function init(plot) { + plot.hooks.processOptions.push(processOptions); + plot.hooks.draw.push(draw); +} + +export const text = { + init: init, + options: options, + name: 'text', + version: '0.1.0', +}; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/register.js b/x-pack/plugins/canvas/canvas_plugin_src/renderers/register.js new file mode 100644 index 0000000000000..2b3c1ca5681bc --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/register.js @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { renderFunctions } from './index'; + +renderFunctions.forEach(canvas.register); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/repeat_image.js b/x-pack/plugins/canvas/canvas_plugin_src/renderers/repeat_image.js new file mode 100644 index 0000000000000..dbc6338d8939c --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/repeat_image.js @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import $ from 'jquery'; +import { times } from 'lodash'; +import { elasticOutline } from '../lib/elastic_outline'; +import { isValid } from '../../common/lib/url'; + +export const repeatImage = () => ({ + name: 'repeatImage', + displayName: 'Image Repeat', + help: 'Repeat an image a given number of times', + reuseDomNode: true, + render(domNode, config, handlers) { + const settings = { + count: 10, + ...config, + image: isValid(config.image) ? config.image : elasticOutline, + }; + + const container = $('
'); + + function setSize(img) { + if (img.naturalHeight > img.naturalWidth) img.height = settings.size; + else img.width = settings.size; + } + + function finish() { + $(domNode).html(container); + handlers.done(); + } + + const img = new Image(); + img.onload = function() { + setSize(img); + if (settings.max && settings.count > settings.max) settings.count = settings.max; + times(settings.count, () => container.append(img.cloneNode(true))); + + if (isValid(settings.emptyImage)) { + if (settings.max == null) throw new Error('max must be set if using an emptyImage'); + + const emptyImage = new Image(); + emptyImage.onload = function() { + setSize(emptyImage); + times(settings.max - settings.count, () => container.append(emptyImage.cloneNode(true))); + finish(); + }; + emptyImage.src = settings.emptyImage; + } else { + finish(); + } + }; + + img.src = settings.image; + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/reveal_image/index.js b/x-pack/plugins/canvas/canvas_plugin_src/renderers/reveal_image/index.js new file mode 100644 index 0000000000000..de43c1fff0da8 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/reveal_image/index.js @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { elasticOutline } from '../../lib/elastic_outline'; +import { isValid } from '../../../common/lib/url'; +import './reveal_image.scss'; + +export const revealImage = () => ({ + name: 'revealImage', + displayName: 'Image Reveal', + help: 'Reveal a percentage of an image to make a custom gauge-style chart', + reuseDomNode: true, + render(domNode, config, handlers) { + const aligner = document.createElement('div'); + const img = new Image(); + + // modify the top-level container class + domNode.className = 'revealImage'; + + // set up the overlay image + img.onload = function() { + setSize(); + finish(); + }; + + img.className = 'revealImage__image'; + img.style.clipPath = getClipPath(config.percent, config.origin); + img.style['-webkit-clip-path'] = getClipPath(config.percent, config.origin); + img.src = isValid(config.image) ? config.image : elasticOutline; + handlers.onResize(img.onload); + + // set up the underlay, "empty" image + aligner.className = 'revealImageAligner'; + aligner.appendChild(img); + if (isValid(config.emptyImage)) { + // only use empty image if one is provided + aligner.style.backgroundImage = `url(${config.emptyImage})`; + } + + function finish() { + const firstChild = domNode.firstChild; + if (firstChild) domNode.replaceChild(aligner, firstChild); + else domNode.appendChild(aligner); + handlers.done(); + } + + function getClipPath(percent, origin = 'bottom') { + const directions = { bottom: 0, left: 1, top: 2, right: 3 }; + const values = [0, 0, 0, 0]; + values[directions[origin]] = `${100 - percent * 100}%`; + return `inset(${values.join(' ')})`; + } + + function setSize() { + const imgDimensions = { + height: img.naturalHeight, + width: img.naturalWidth, + ratio: img.naturalHeight / img.naturalWidth, + }; + + const domNodeDimensions = { + height: domNode.clientHeight, + width: domNode.clientWidth, + ratio: domNode.clientHeight / domNode.clientWidth, + }; + + if (imgDimensions.ratio > domNodeDimensions.ratio) { + img.style.height = `${domNodeDimensions.height}px`; + img.style.width = 'initial'; + } else { + img.style.width = `${domNodeDimensions.width}px`; + img.style.height = 'initial'; + } + } + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/reveal_image/reveal_image.scss b/x-pack/plugins/canvas/canvas_plugin_src/renderers/reveal_image/reveal_image.scss new file mode 100644 index 0000000000000..f020b0a63996e --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/reveal_image/reveal_image.scss @@ -0,0 +1,22 @@ +.revealImage { + height: 100%; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + + .revealImageAligner { + background-size: contain; + background-repeat: no-repeat; + } + + // disables selection and dragging + .revealImage__image { + -khtml-user-select: none; + -o-user-select: none; + -moz-user-select: none; + -webkit-user-select: none; + user-select: none; + } +} diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/index.js b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/index.js new file mode 100644 index 0000000000000..f31b39ff0c466 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/index.js @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { shapes } from './shapes'; + +export const shape = () => ({ + name: 'shape', + displayName: 'Shape', + help: 'Render an shape', + reuseDomNode: true, + render(domNode, config, handlers) { + const { shape, fill, border, borderWidth, maintainAspect } = config; + const parser = new DOMParser(); + const [shapeSvg] = parser + .parseFromString(shapes[shape], 'image/svg+xml') + .getElementsByTagName('svg'); + + const shapeContent = shapeSvg.firstElementChild; + + if (fill) shapeContent.setAttribute('fill', fill); + if (border) shapeContent.setAttribute('stroke', border); + if (borderWidth >= 0) shapeContent.setAttribute('stroke-width', borderWidth); + shapeContent.setAttribute('stroke-miterlimit', 999); + shapeContent.setAttribute('vector-effect', 'non-scaling-stroke'); + + shapeSvg.setAttribute('preserveAspectRatio', maintainAspect ? 'xMidYMid meet' : 'none'); + shapeSvg.setAttribute('overflow', 'visible'); + + const initialViewBox = shapeSvg + .getAttribute('viewBox') + .split(' ') + .map(v => parseInt(v, 10)); + + const draw = () => { + const width = domNode.offsetWidth; + const height = domNode.offsetHeight; + + // adjust viewBox based on border width + let [minX, minY, maxX, maxY] = initialViewBox; + + const xScale = (maxX - minX) / width; + const yScale = (maxY - minY) / height; + const borderOffset = borderWidth / 2; + const xOffset = borderOffset * xScale; + const yOffset = borderOffset * yScale; + + minX -= xOffset; // min-x + minY -= yOffset; // min-y + maxX += xOffset * 2; // width + maxY += yOffset * 2; // height + + shapeSvg.setAttribute('width', width); + shapeSvg.setAttribute('height', height); + shapeSvg.setAttribute('viewBox', [minX, minY, maxX, maxY].join(' ')); + + const oldShape = domNode.firstElementChild; + if (oldShape) domNode.removeChild(oldShape); + + domNode.appendChild(shapeSvg); + }; + + draw(); + handlers.done(); + handlers.onResize(draw); // debouncing avoided for fluidity + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/arrow.svg b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/arrow.svg new file mode 100644 index 0000000000000..2048bf5992c70 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/arrow.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/arrow_multi.svg b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/arrow_multi.svg new file mode 100644 index 0000000000000..0d1fcfd435ad4 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/arrow_multi.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/bookmark.svg b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/bookmark.svg new file mode 100644 index 0000000000000..5cb144bd3367a --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/bookmark.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/circle.svg b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/circle.svg new file mode 100644 index 0000000000000..d4024c138b710 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/circle.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/cross.svg b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/cross.svg new file mode 100644 index 0000000000000..72f0b7b6d556a --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/cross.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/hexagon.svg b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/hexagon.svg new file mode 100644 index 0000000000000..e1096039b44a8 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/hexagon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/index.js b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/index.js new file mode 100644 index 0000000000000..46437f001551c --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/index.js @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import arrow from '!!raw-loader!./arrow.svg'; +import arrowMulti from '!!raw-loader!./arrow_multi.svg'; +import bookmark from '!!raw-loader!./bookmark.svg'; +import cross from '!!raw-loader!./cross.svg'; +import circle from '!!raw-loader!./circle.svg'; +import hexagon from '!!raw-loader!./hexagon.svg'; +import kite from '!!raw-loader!./kite.svg'; +import pentagon from '!!raw-loader!./pentagon.svg'; +import rhombus from '!!raw-loader!./rhombus.svg'; +import semicircle from '!!raw-loader!./semicircle.svg'; +import speechBubble from '!!raw-loader!./speech_bubble.svg'; +import square from '!!raw-loader!./square.svg'; +import star from '!!raw-loader!./star.svg'; +import tag from '!!raw-loader!./tag.svg'; +import triangle from '!!raw-loader!./triangle.svg'; +import triangleRight from '!!raw-loader!./triangle_right.svg'; + +export const shapes = { + arrow, + arrowMulti, + bookmark, + cross, + circle, + hexagon, + kite, + pentagon, + rhombus, + semicircle, + speechBubble, + square, + star, + tag, + triangle, + triangleRight, +}; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/kite.svg b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/kite.svg new file mode 100644 index 0000000000000..76121bf0c5758 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/kite.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/pentagon.svg b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/pentagon.svg new file mode 100644 index 0000000000000..578b095479b70 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/pentagon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/rhombus.svg b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/rhombus.svg new file mode 100644 index 0000000000000..9512d458f174a --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/rhombus.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/semicircle.svg b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/semicircle.svg new file mode 100644 index 0000000000000..48b9928d33a28 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/semicircle.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/speech_bubble.svg b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/speech_bubble.svg new file mode 100644 index 0000000000000..1feebc4d5de4b --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/speech_bubble.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/square.svg b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/square.svg new file mode 100644 index 0000000000000..f964547722bbc --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/square.svg @@ -0,0 +1,3 @@ + + + diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/star.svg b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/star.svg new file mode 100644 index 0000000000000..66749baef6bca --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/star.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/tag.svg b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/tag.svg new file mode 100644 index 0000000000000..845d81c9dabe6 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/tag.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/triangle.svg b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/triangle.svg new file mode 100644 index 0000000000000..6642aaf7ed0ac --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/triangle.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/triangle_right.svg b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/triangle_right.svg new file mode 100644 index 0000000000000..e3b6050654fd8 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/triangle_right.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/table.js b/x-pack/plugins/canvas/canvas_plugin_src/renderers/table.js new file mode 100644 index 0000000000000..09950f743c9d9 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/table.js @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import ReactDOM from 'react-dom'; +import React from 'react'; +import { get } from 'lodash'; +import { Datatable } from '../../public/components/datatable'; + +export const table = () => ({ + name: 'table', + displayName: 'Data Table', + help: 'Render tabular data as HTML', + reuseDomNode: true, + render(domNode, config, handlers) { + const { datatable, paginate, perPage, font, showHeader } = config; + ReactDOM.render( +
+ +
, + domNode, + () => handlers.done() + ); + + handlers.onDestroy(() => ReactDOM.unmountComponentAtNode(domNode)); + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/text.js b/x-pack/plugins/canvas/canvas_plugin_src/renderers/text.js new file mode 100644 index 0000000000000..f5aa833f437d0 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/text.js @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import ReactDOM from 'react-dom'; +import React from 'react'; + +export const text = () => ({ + name: 'text', + displayName: 'Plain Text', + help: 'Render output as plain text', + reuseDomNode: true, + render(domNode, { text }, handlers) { + ReactDOM.render(
{text}
, domNode, () => handlers.done()); + handlers.onDestroy(() => ReactDOM.unmountComponentAtNode(domNode)); + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/datetime_calendar/datetime_calendar.js b/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/datetime_calendar/datetime_calendar.js new file mode 100644 index 0000000000000..2b7f37a9de5af --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/datetime_calendar/datetime_calendar.js @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import dateMath from '@elastic/datemath'; +import { EuiDatePicker } from '@elastic/eui'; +import { DatetimeInput } from '../datetime_input'; +import './datetime_calendar.scss'; + +export const DatetimeCalendar = ({ value, onSelect, startDate, endDate }) => ( +
+ + +
+); + +DatetimeCalendar.propTypes = { + value: PropTypes.object, + onSelect: PropTypes.func, // Called with a moment + startDate: PropTypes.object, // a moment + endDate: PropTypes.object, // a moment +}; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/datetime_calendar/datetime_calendar.scss b/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/datetime_calendar/datetime_calendar.scss new file mode 100644 index 0000000000000..ec38f0f9c21ee --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/datetime_calendar/datetime_calendar.scss @@ -0,0 +1,7 @@ +@import '../../../../lib/eui.scss'; + +.canvasDateTimeCal { + display: inline-grid; + height: 100%; + border: $euiBorderThin; +} diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/datetime_calendar/index.js b/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/datetime_calendar/index.js new file mode 100644 index 0000000000000..9ac576db0cc8a --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/datetime_calendar/index.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { compose } from 'recompose'; +import { DatetimeCalendar as Component } from './datetime_calendar'; + +export const DatetimeCalendar = compose()(Component); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/datetime_input/datetime_input.js b/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/datetime_input/datetime_input.js new file mode 100644 index 0000000000000..c48fcf110111b --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/datetime_input/datetime_input.js @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { EuiFieldText } from '@elastic/eui'; +import moment from 'moment'; + +export const DatetimeInput = ({ strValue, setStrValue, setMoment, valid, setValid }) => { + function check(e) { + const parsed = moment(e.target.value, 'YYYY-MM-DD HH:mm:ss', true); + if (parsed.isValid()) { + setMoment(parsed); + setValid(true); + } else { + setValid(false); + } + setStrValue(e.target.value); + } + + return ( + + ); +}; + +DatetimeInput.propTypes = { + setMoment: PropTypes.func, + strValue: PropTypes.string, + setStrValue: PropTypes.func, + valid: PropTypes.bool, + setValid: PropTypes.func, +}; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/datetime_input/index.js b/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/datetime_input/index.js new file mode 100644 index 0000000000000..cdb3ca6290947 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/datetime_input/index.js @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { compose, withState, lifecycle } from 'recompose'; +import { DatetimeInput as Component } from './datetime_input'; + +export const DatetimeInput = compose( + withState('valid', 'setValid', () => true), + withState('strValue', 'setStrValue', ({ moment }) => moment.format('YYYY-MM-DD HH:mm:ss')), + lifecycle({ + componentWillReceiveProps({ moment, setStrValue, setValid }) { + if (this.props.moment.isSame(moment)) return; + setStrValue(moment.format('YYYY-MM-DD HH:mm:ss')); + setValid(true); + }, + }) +)(Component); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/datetime_quick_list/datetime_quick_list.js b/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/datetime_quick_list/datetime_quick_list.js new file mode 100644 index 0000000000000..61828362dcbb8 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/datetime_quick_list/datetime_quick_list.js @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { EuiButton, EuiButtonEmpty } from '@elastic/eui'; +import 'react-datetime/css/react-datetime.css'; + +export const DatetimeQuickList = ({ from, to, ranges, onSelect, children }) => ( +
+ {ranges.map( + (range, i) => + from === range.from && to === range.to ? ( + onSelect(range.from, range.to)}> + {range.display} + + ) : ( + onSelect(range.from, range.to)}> + {range.display} + + ) + )} + {children} +
+); + +DatetimeQuickList.propTypes = { + from: PropTypes.string, + to: PropTypes.string, + ranges: PropTypes.array, + onSelect: PropTypes.func, + children: PropTypes.node, +}; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/datetime_quick_list/index.js b/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/datetime_quick_list/index.js new file mode 100644 index 0000000000000..85de3de8d04ed --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/datetime_quick_list/index.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { compose } from 'recompose'; +import { DatetimeQuickList as Component } from './datetime_quick_list'; + +export const DatetimeQuickList = compose()(Component); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/datetime_range_absolute/datetime_range_absolute.js b/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/datetime_range_absolute/datetime_range_absolute.js new file mode 100644 index 0000000000000..a727a57427cf0 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/datetime_range_absolute/datetime_range_absolute.js @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { DatetimeCalendar } from '../datetime_calendar'; +import './datetime_range_absolute.scss'; + +export const DatetimeRangeAbsolute = ({ from, to, onSelect }) => ( +
+
+ onSelect(val, to)} + /> +
+
+ onSelect(from, val)} + /> +
+
+); + +DatetimeRangeAbsolute.propTypes = { + from: PropTypes.object, // a moment + to: PropTypes.object, // a moment + onSelect: PropTypes.func, +}; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/datetime_range_absolute/datetime_range_absolute.scss b/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/datetime_range_absolute/datetime_range_absolute.scss new file mode 100644 index 0000000000000..cfefdb1f7384a --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/datetime_range_absolute/datetime_range_absolute.scss @@ -0,0 +1,9 @@ +@import '../../../../lib/eui.scss'; + +.canvasDateTimeRangeAbsolute { + display: flex; + + > div:not(:last-child) { + margin-right: $euiSizeS; + } +} diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/datetime_range_absolute/index.js b/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/datetime_range_absolute/index.js new file mode 100644 index 0000000000000..75ee8685b3e8a --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/datetime_range_absolute/index.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { compose } from 'recompose'; +import { DatetimeRangeAbsolute as Component } from './datetime_range_absolute'; + +export const DatetimeRangeAbsolute = compose()(Component); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/pretty_duration/index.js b/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/pretty_duration/index.js new file mode 100644 index 0000000000000..a35a4aba66487 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/pretty_duration/index.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pure } from 'recompose'; +import { PrettyDuration as Component } from './pretty_duration'; + +export const PrettyDuration = pure(Component); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/pretty_duration/lib/format_duration.js b/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/pretty_duration/lib/format_duration.js new file mode 100644 index 0000000000000..8f85eb9dbaf08 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/pretty_duration/lib/format_duration.js @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import dateMath from '@elastic/datemath'; +import moment from 'moment'; +import { quickRanges } from './quick_ranges'; +import { timeUnits } from './time_units'; + +const lookupByRange = {}; +quickRanges.forEach(function(frame) { + lookupByRange[frame.from + ' to ' + frame.to] = frame; +}); + +function formatTime(time, roundUp = false) { + if (moment.isMoment(time)) { + return time.format('lll'); + } else { + if (time === 'now') { + return 'now'; + } else { + const tryParse = dateMath.parse(time, { roundUp }); + return moment.isMoment(tryParse) ? '~ ' + tryParse.fromNow() : time; + } + } +} + +function cantLookup(from, to) { + return `${formatTime(from)} to ${formatTime(to)}`; +} + +export function formatDuration(from, to) { + let text; + // If both parts are date math, try to look up a reasonable string + if (from && to && !moment.isMoment(from) && !moment.isMoment(to)) { + const tryLookup = lookupByRange[from.toString() + ' to ' + to.toString()]; + if (tryLookup) { + return tryLookup.display; + } else { + const fromParts = from.toString().split('-'); + if (to.toString() === 'now' && fromParts[0] === 'now' && fromParts[1]) { + const rounded = fromParts[1].split('/'); + text = 'Last ' + rounded[0]; + if (rounded[1]) text = text + ' rounded to the ' + timeUnits[rounded[1]]; + + return text; + } else { + return cantLookup(from, to); + } + } + // If at least one part is a moment, try to make pretty strings by parsing date math + } else { + return cantLookup(from, to); + } +} diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/pretty_duration/lib/quick_ranges.js b/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/pretty_duration/lib/quick_ranges.js new file mode 100644 index 0000000000000..4d01628725637 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/pretty_duration/lib/quick_ranges.js @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const quickRanges = [ + { from: 'now/d', to: 'now/d', display: 'Today', section: 0 }, + { from: 'now/w', to: 'now/w', display: 'This week', section: 0 }, + { from: 'now/M', to: 'now/M', display: 'This month', section: 0 }, + { from: 'now/y', to: 'now/y', display: 'This year', section: 0 }, + { from: 'now/d', to: 'now', display: 'The day so far', section: 0 }, + { from: 'now/w', to: 'now', display: 'Week to date', section: 0 }, + { from: 'now/M', to: 'now', display: 'Month to date', section: 0 }, + { from: 'now/y', to: 'now', display: 'Year to date', section: 0 }, + + { from: 'now-1d/d', to: 'now-1d/d', display: 'Yesterday', section: 1 }, + { from: 'now-2d/d', to: 'now-2d/d', display: 'Day before yesterday', section: 1 }, + { from: 'now-7d/d', to: 'now-7d/d', display: 'This day last week', section: 1 }, + { from: 'now-1w/w', to: 'now-1w/w', display: 'Previous week', section: 1 }, + { from: 'now-1M/M', to: 'now-1M/M', display: 'Previous month', section: 1 }, + { from: 'now-1y/y', to: 'now-1y/y', display: 'Previous year', section: 1 }, + + { from: 'now-15m', to: 'now', display: 'Last 15 minutes', section: 2 }, + { from: 'now-30m', to: 'now', display: 'Last 30 minutes', section: 2 }, + { from: 'now-1h', to: 'now', display: 'Last 1 hour', section: 2 }, + { from: 'now-4h', to: 'now', display: 'Last 4 hours', section: 2 }, + { from: 'now-12h', to: 'now', display: 'Last 12 hours', section: 2 }, + { from: 'now-24h', to: 'now', display: 'Last 24 hours', section: 2 }, + { from: 'now-7d', to: 'now', display: 'Last 7 days', section: 2 }, + + { from: 'now-30d', to: 'now', display: 'Last 30 days', section: 3 }, + { from: 'now-60d', to: 'now', display: 'Last 60 days', section: 3 }, + { from: 'now-90d', to: 'now', display: 'Last 90 days', section: 3 }, + { from: 'now-6M', to: 'now', display: 'Last 6 months', section: 3 }, + { from: 'now-1y', to: 'now', display: 'Last 1 year', section: 3 }, + { from: 'now-2y', to: 'now', display: 'Last 2 years', section: 3 }, + { from: 'now-5y', to: 'now', display: 'Last 5 years', section: 3 }, +]; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/pretty_duration/lib/time_units.js b/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/pretty_duration/lib/time_units.js new file mode 100644 index 0000000000000..5f602e38b4979 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/pretty_duration/lib/time_units.js @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const timeUnits = { + s: 'second', + m: 'minute', + h: 'hour', + d: 'day', + w: 'week', + M: 'month', + y: 'year', +}; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/pretty_duration/pretty_duration.js b/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/pretty_duration/pretty_duration.js new file mode 100644 index 0000000000000..743aa69feaa4f --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/pretty_duration/pretty_duration.js @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { PropTypes } from 'prop-types'; +import { formatDuration } from './lib/format_duration'; + +export const PrettyDuration = ({ from, to }) => {formatDuration(from, to)}; + +PrettyDuration.propTypes = { + from: PropTypes.any.isRequired, + to: PropTypes.any.isRequired, +}; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/time_filter/index.js b/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/time_filter/index.js new file mode 100644 index 0000000000000..f6aa643d97bd5 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/time_filter/index.js @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { compose, withState } from 'recompose'; +import { TimeFilter as Component } from './time_filter'; + +export const TimeFilter = compose(withState('filter', 'setFilter', ({ filter }) => filter))( + Component +); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/time_filter/time_filter.js b/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/time_filter/time_filter.js new file mode 100644 index 0000000000000..d04a8f0b54d96 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/time_filter/time_filter.js @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { get } from 'lodash'; +import { fromExpression } from '../../../../../common/lib/ast'; +import { TimePicker } from '../time_picker'; +import { TimePickerMini } from '../time_picker_mini'; + +export const TimeFilter = ({ compact, filter, setFilter, commit }) => { + const ast = fromExpression(filter); + + const from = get(ast, 'chain[0].arguments.from[0]'); + const to = get(ast, 'chain[0].arguments.to[0]'); + const column = get(ast, 'chain[0].arguments.column[0]'); + + function doSetFilter(from, to) { + const filter = `timefilter from="${from}" to=${to} column=${column}`; + + // TODO: Changes to element.filter do not cause a re-render + setFilter(filter); + commit(filter); + } + + if (compact) return ; + else return ; +}; + +TimeFilter.propTypes = { + filter: PropTypes.string, + setFilter: PropTypes.func, // Local state + commit: PropTypes.func, // Canvas filter + compact: PropTypes.bool, +}; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/time_picker/index.js b/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/time_picker/index.js new file mode 100644 index 0000000000000..9ec8fc5752435 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/time_picker/index.js @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { compose, withState, lifecycle } from 'recompose'; +import { TimePicker as Component } from './time_picker'; + +export const TimePicker = compose( + withState('range', 'setRange', ({ from, to }) => ({ from, to })), + withState('dirty', 'setDirty', false), + lifecycle({ + componentWillReceiveProps({ from, to }) { + if (from !== this.props.from || to !== this.props.to) { + this.props.setRange({ from, to }); + this.props.setDirty(false); + } + }, + }) +)(Component); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/time_picker/time_picker.js b/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/time_picker/time_picker.js new file mode 100644 index 0000000000000..97c8712d7515a --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/time_picker/time_picker.js @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import dateMath from '@elastic/datemath'; +import { EuiButton } from '@elastic/eui'; +import moment from 'moment'; +import { DatetimeRangeAbsolute } from '../datetime_range_absolute'; +import { DatetimeQuickList } from '../datetime_quick_list'; +import './time_picker.scss'; + +export const quickRanges = [ + { from: 'now-24h', to: 'now', display: 'Last 24 hours' }, + { from: 'now-7d', to: 'now', display: 'Last 7 days' }, + { from: 'now-14d', to: 'now', display: 'Last 2 weeks' }, + { from: 'now-30d', to: 'now', display: 'Last 30 days' }, + { from: 'now-90d', to: 'now', display: 'Last 90 days' }, + { from: 'now-1y', to: 'now', display: 'Last 1 year' }, +]; + +export const TimePicker = ({ range, setRange, dirty, setDirty, onSelect }) => { + const { from, to } = range; + + function absoluteSelect(from, to) { + setDirty(true); + setRange({ from: moment(from).toISOString(), to: moment(to).toISOString() }); + } + + return ( +
+ + + { + setDirty(false); + onSelect(range.from, range.to); + }} + > + Apply + + +
+ ); +}; + +TimePicker.propTypes = { + range: PropTypes.object, + setRange: PropTypes.func, + dirty: PropTypes.bool, + setDirty: PropTypes.func, + onSelect: PropTypes.func, +}; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/time_picker/time_picker.scss b/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/time_picker/time_picker.scss new file mode 100644 index 0000000000000..5ba0e7293ccb7 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/time_picker/time_picker.scss @@ -0,0 +1,9 @@ +@import '../../../../lib/eui.scss'; + +.canvasTimePicker { + display: flex; + + > div:not(:last-child) { + margin-right: $euiSizeS; + } +} diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/time_picker_mini/index.js b/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/time_picker_mini/index.js new file mode 100644 index 0000000000000..9146778c243d6 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/time_picker_mini/index.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { compose } from 'recompose'; +import { TimePickerMini as Component } from './time_picker_mini'; + +export const TimePickerMini = compose()(Component); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/time_picker_mini/time_picker_mini.js b/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/time_picker_mini/time_picker_mini.js new file mode 100644 index 0000000000000..cea4f1018532b --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/time_picker_mini/time_picker_mini.js @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { Popover } from '../../../../../public/components/popover'; +import { PrettyDuration } from '../pretty_duration'; +import { TimePicker } from '../time_picker'; +import './time_picker_mini.scss'; + +export const TimePickerMini = ({ from, to, onSelect }) => { + const button = handleClick => ( + + ); + + return ( + + {() => } + + ); +}; + +TimePickerMini.propTypes = { + from: PropTypes.string, + to: PropTypes.string, + onSelect: PropTypes.func, +}; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/time_picker_mini/time_picker_mini.scss b/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/time_picker_mini/time_picker_mini.scss new file mode 100644 index 0000000000000..de2f96259f8a0 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/time_picker_mini/time_picker_mini.scss @@ -0,0 +1,21 @@ +@import '../../../../lib/eui.scss'; + +.canvasTimePickerMini { + width: 100%; + + .canvasTimePickerMini__button { + width: 100%; + padding: $euiSizeXS; + border: $euiBorderThin; + border-radius: $euiBorderRadius; + background-color: $euiColorEmptyShade; + + &:hover { + background-color: $euiColorLightestShade; + } + } + + .euiPopover__anchor { + width: 100%; + } +} diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/index.js b/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/index.js new file mode 100644 index 0000000000000..d9d1ae3c2d754 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/time_filter/index.js @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import ReactDOM from 'react-dom'; +import React from 'react'; +import { get, set } from 'lodash'; +import { fromExpression, toExpression } from '../../../common/lib/ast'; +import { TimeFilter } from './components/time_filter'; + +export const timeFilter = () => ({ + name: 'time_filter', + displayName: 'Time Filter', + help: 'Set a time window', + reuseDomNode: true, + render(domNode, config, handlers) { + const ast = fromExpression(handlers.getFilter()); + + // Check if the current column is what we expect it to be. If the user changes column this will be called again, + // but we don't want to run setFilter() unless we have to because it will cause a data refresh + const column = get(ast, 'chain[0].arguments.column[0]'); + if (column !== config.column) { + set(ast, 'chain[0].arguments.column[0]', config.column); + handlers.setFilter(toExpression(ast)); + } + + ReactDOM.render( + , + domNode, + () => handlers.done() + ); + + handlers.onDestroy(() => { + handlers.setFilter(''); + ReactDOM.unmountComponentAtNode(domNode); + }); + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/types/boolean.js b/x-pack/plugins/canvas/canvas_plugin_src/types/boolean.js new file mode 100644 index 0000000000000..697277a471fea --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/types/boolean.js @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const boolean = () => ({ + name: 'boolean', + from: { + null: () => false, + number: n => Boolean(n), + string: s => Boolean(s), + }, + to: { + render: value => { + const text = `${value}`; + return { + type: 'render', + as: 'text', + value: { text }, + }; + }, + datatable: value => ({ + type: 'datatable', + columns: [{ name: 'value', type: 'boolean' }], + rows: [{ value }], + }), + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/types/datatable.js b/x-pack/plugins/canvas/canvas_plugin_src/types/datatable.js new file mode 100644 index 0000000000000..cfe75605f1ebf --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/types/datatable.js @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { map, zipObject } from 'lodash'; + +export const datatable = () => ({ + name: 'datatable', + validate: datatable => { + // TODO: Check columns types. Only string, boolean, number, date, allowed for now. + if (!datatable.columns) + throw new Error('datatable must have a columns array, even if it is empty'); + + if (!datatable.rows) throw new Error('datatable must have a rows array, even if it is empty'); + }, + serialize: datatable => { + const { columns, rows } = datatable; + return { + ...datatable, + rows: rows.map(row => { + return columns.map(column => row[column.name]); + }), + }; + }, + deserialize: datatable => { + const { columns, rows } = datatable; + return { + ...datatable, + rows: rows.map(row => { + return zipObject(map(columns, 'name'), row); + }), + }; + }, + from: { + null: () => { + return { + type: 'datatable', + rows: [], + columns: [], + }; + }, + pointseries: context => { + return { + type: 'datatable', + rows: context.rows, + columns: map(context.columns, (val, name) => { + return { name: name, type: val.type, role: val.role }; + }), + }; + }, + }, + to: { + render: datatable => { + return { + type: 'render', + as: 'table', + value: { + datatable, + paginate: true, + perPage: 10, + showHeader: true, + }, + }; + }, + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/types/error.js b/x-pack/plugins/canvas/canvas_plugin_src/types/error.js new file mode 100644 index 0000000000000..51051c804db56 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/types/error.js @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const error = () => ({ + name: 'error', + to: { + render: input => { + const { error, info } = input; + return { + type: 'render', + as: 'error', + value: { + error, + info, + }, + }; + }, + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/types/filter.js b/x-pack/plugins/canvas/canvas_plugin_src/types/filter.js new file mode 100644 index 0000000000000..8627dd20bb89f --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/types/filter.js @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const filter = () => ({ + name: 'filter', + from: { + null: () => { + return { + type: 'filter', + // Any meta data you wish to pass along. + meta: {}, + // And filters. If you need an "or", create a filter type for it. + and: [], + }; + }, + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/types/image.js b/x-pack/plugins/canvas/canvas_plugin_src/types/image.js new file mode 100644 index 0000000000000..f63d3f1b8b2aa --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/types/image.js @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const image = () => ({ + name: 'image', + to: { + render: input => { + return { + type: 'render', + as: 'image', + value: input, + }; + }, + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/types/index.js b/x-pack/plugins/canvas/canvas_plugin_src/types/index.js new file mode 100644 index 0000000000000..2e9a4fa02ef8e --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/types/index.js @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { boolean } from './boolean'; +import { datatable } from './datatable'; +import { error } from './error'; +import { filter } from './filter'; +import { image } from './image'; +import { nullType } from './null'; +import { number } from './number'; +import { pointseries } from './pointseries'; +import { render } from './render'; +import { shape } from './shape'; +import { string } from './string'; +import { style } from './style'; + +export const typeSpecs = [ + boolean, + datatable, + error, + filter, + image, + number, + nullType, + pointseries, + render, + shape, + string, + style, +]; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/types/null.js b/x-pack/plugins/canvas/canvas_plugin_src/types/null.js new file mode 100644 index 0000000000000..27e9cdf59b004 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/types/null.js @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const nullType = () => ({ + name: 'null', + from: { + '*': () => null, + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/types/number.js b/x-pack/plugins/canvas/canvas_plugin_src/types/number.js new file mode 100644 index 0000000000000..63ee587075fdd --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/types/number.js @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const number = () => ({ + name: 'number', + from: { + null: () => 0, + boolean: b => Number(b), + string: n => Number(n), + }, + to: { + render: value => { + const text = `${value}`; + return { + type: 'render', + as: 'text', + value: { text }, + }; + }, + datatable: value => ({ + type: 'datatable', + columns: [{ name: 'value', type: 'number' }], + rows: [{ value }], + }), + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/types/pointseries.js b/x-pack/plugins/canvas/canvas_plugin_src/types/pointseries.js new file mode 100644 index 0000000000000..1a00738620050 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/types/pointseries.js @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const pointseries = () => ({ + name: 'pointseries', + from: { + null: () => { + return { + type: 'pointseries', + rows: [], + columns: [], + }; + }, + }, + to: { + render: (pointseries, types) => { + const datatable = types.datatable.from(pointseries, types); + return { + type: 'render', + as: 'table', + value: { + datatable, + showHeader: true, + }, + }; + }, + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/types/register.js b/x-pack/plugins/canvas/canvas_plugin_src/types/register.js new file mode 100644 index 0000000000000..d5c6e98288e89 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/types/register.js @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { typeSpecs } from './index'; + +typeSpecs.forEach(canvas.register); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/types/render.js b/x-pack/plugins/canvas/canvas_plugin_src/types/render.js new file mode 100644 index 0000000000000..0f261f0398816 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/types/render.js @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const render = () => ({ + name: 'render', + from: { + '*': v => ({ + type: 'render', + as: 'debug', + value: v, + }), + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/types/shape.js b/x-pack/plugins/canvas/canvas_plugin_src/types/shape.js new file mode 100644 index 0000000000000..1b306b7b1c391 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/types/shape.js @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const shape = () => ({ + name: 'shape', + to: { + render: input => { + return { + type: 'render', + as: 'shape', + value: input, + }; + }, + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/types/string.js b/x-pack/plugins/canvas/canvas_plugin_src/types/string.js new file mode 100644 index 0000000000000..c8d58aaaffbca --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/types/string.js @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const string = () => ({ + name: 'string', + from: { + null: () => '', + boolean: b => String(b), + number: n => String(n), + }, + to: { + render: text => { + return { + type: 'render', + as: 'text', + value: { text }, + }; + }, + datatable: value => ({ + type: 'datatable', + columns: [{ name: 'value', type: 'string' }], + rows: [{ value }], + }), + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/types/style.js b/x-pack/plugins/canvas/canvas_plugin_src/types/style.js new file mode 100644 index 0000000000000..62632c03231ad --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/types/style.js @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const style = () => ({ + name: 'style', + from: { + null: () => { + return { + type: 'style', + spec: {}, + css: '', + }; + }, + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/extended_template.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/extended_template.js new file mode 100644 index 0000000000000..c9729f3911f04 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/extended_template.js @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { EuiSelect, EuiFormRow, EuiText } from '@elastic/eui'; +import { set } from 'object-path-immutable'; +import { get } from 'lodash'; + +const defaultExpression = { + type: 'expression', + chain: [ + { + type: 'function', + function: 'axisConfig', + arguments: {}, + }, + ], +}; + +export class ExtendedTemplate extends React.PureComponent { + static propTypes = { + onValueChange: PropTypes.func.isRequired, + argValue: PropTypes.oneOfType([ + PropTypes.bool, + PropTypes.shape({ + chain: PropTypes.array, + }).isRequired, + ]), + typeInstance: PropTypes.object.isRequired, + argId: PropTypes.string.isRequired, + }; + + // TODO: this should be in a helper, it's the same code from container_style + getArgValue = (name, alt) => { + return get(this.props.argValue, ['chain', 0, 'arguments', name, 0], alt); + }; + + // TODO: this should be in a helper, it's the same code from container_style + setArgValue = name => ev => { + const val = ev.target.value; + const { argValue, onValueChange } = this.props; + const oldVal = typeof argValue === 'boolean' ? defaultExpression : argValue; + const newValue = set(oldVal, ['chain', 0, 'arguments', name, 0], val); + onValueChange(newValue); + }; + + render() { + const isDisabled = typeof this.props.argValue === 'boolean' && this.props.argValue === false; + + if (isDisabled) return The axis is disabled; + + const positions = { + xaxis: ['bottom', 'top'], + yaxis: ['left', 'right'], + }; + const argName = this.props.typeInstance.name; + const position = this.getArgValue('position', positions[argName][0]); + + const options = positions[argName].map(val => ({ value: val, text: val })); + + return ( + + + + + + ); + } +} + +ExtendedTemplate.displayName = 'AxisConfigExtendedInput'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/index.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/index.js new file mode 100644 index 0000000000000..31635bf2a5b1e --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/index.js @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { templateFromReactComponent } from '../../../../public/lib/template_from_react_component'; +import { SimpleTemplate } from './simple_template'; +import { ExtendedTemplate } from './extended_template'; + +export const axisConfig = () => ({ + name: 'axisConfig', + displayName: 'Axis Config', + help: 'Visualization axis configuration', + simpleTemplate: templateFromReactComponent(SimpleTemplate), + template: templateFromReactComponent(ExtendedTemplate), +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/simple_template.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/simple_template.js new file mode 100644 index 0000000000000..23bca236f4506 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/simple_template.js @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { EuiSwitch } from '@elastic/eui'; + +export const SimpleTemplate = ({ onValueChange, argValue }) => ( + onValueChange(!Boolean(argValue))} /> +); + +SimpleTemplate.propTypes = { + onValueChange: PropTypes.func.isRequired, + argValue: PropTypes.oneOfType([PropTypes.bool, PropTypes.object]).isRequired, +}; + +SimpleTemplate.displayName = 'AxisConfigSimpleInput'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/datacolumn/__tests__/get_form_object.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/datacolumn/__tests__/get_form_object.js new file mode 100644 index 0000000000000..cf1ae0ea37afc --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/datacolumn/__tests__/get_form_object.js @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { getFormObject } from '../get_form_object'; + +describe('getFormObject', () => { + describe('valid input', () => { + it('string', () => { + expect(getFormObject('field')).to.be.eql({ fn: '', column: 'field' }); + }); + it('simple expression', () => { + expect(getFormObject('mean(field)')).to.be.eql({ fn: 'mean', column: 'field' }); + }); + }); + describe('invalid input', () => { + it('number', () => { + expect(getFormObject) + .withArgs('2') + .to.throwException(e => { + expect(e.message).to.be('Cannot render scalar values or complex math expressions'); + }); + }); + it('complex expression', () => { + expect(getFormObject) + .withArgs('mean(field * 3)') + .to.throwException(e => { + expect(e.message).to.be('Cannot render scalar values or complex math expressions'); + }); + }); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/datacolumn/get_form_object.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/datacolumn/get_form_object.js new file mode 100644 index 0000000000000..7f5fe8b2cce12 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/datacolumn/get_form_object.js @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { parse } from 'tinymath'; +import { unquoteString } from '../../../../common/lib/unquote_string'; + +// break out into separate function, write unit tests first +export function getFormObject(argValue) { + if (argValue === '') { + return { + fn: '', + column: '', + }; + } + + // check if the value is a math expression, and set its type if it is + const mathObj = parse(argValue); + // A symbol node is a plain string, so we guess that they're looking for a column. + if (typeof mathObj === 'string') { + return { + fn: '', + column: unquoteString(argValue), + }; + } + + // Check if its a simple function, eg a function wrapping a symbol node + // check for only one arg of type string + if ( + typeof mathObj === 'object' && + mathObj.args.length === 1 && + typeof mathObj.args[0] === 'string' + ) { + return { + fn: mathObj.name, + column: unquoteString(mathObj.args[0]), + }; + } + + // Screw it, textarea for you my fancy. + throw new Error(`Cannot render scalar values or complex math expressions`); +} diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/datacolumn/index.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/datacolumn/index.js new file mode 100644 index 0000000000000..ded0600cdb3ff --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/datacolumn/index.js @@ -0,0 +1,135 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Component } from 'react'; +import { compose, withPropsOnChange, withHandlers } from 'recompose'; +import PropTypes from 'prop-types'; +import { EuiSelect, EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; +import { sortBy } from 'lodash'; +import { createStatefulPropHoc } from '../../../../public/components/enhance/stateful_prop'; +import { getType } from '../../../../common/lib/get_type'; +import { templateFromReactComponent } from '../../../../public/lib/template_from_react_component'; +import { SimpleMathFunction } from './simple_math_function'; +import { getFormObject } from './get_form_object'; + +const maybeQuoteValue = val => (val.match(/\s/) ? `'${val}'` : val); + +// TODO: Garbage, we could make a much nicer math form that can handle way more. +class DatacolumnArgInput extends Component { + static propTypes = { + columns: PropTypes.array.isRequired, + onValueChange: PropTypes.func.isRequired, + mathValue: PropTypes.object.isRequired, + setMathFunction: PropTypes.func.isRequired, + typeInstance: PropTypes.object.isRequired, + renderError: PropTypes.func.isRequired, + argId: PropTypes.string.isRequired, + }; + + inputRefs = {}; + + render() { + const { + onValueChange, + columns, + mathValue, + setMathFunction, + renderError, + argId, + typeInstance, + } = this.props; + + if (mathValue.error) { + renderError(); + return null; + } + + const allowedTypes = typeInstance.options.allowedTypes || false; + const onlyShowMathFunctions = typeInstance.options.onlyMath || false; + const valueNotSet = val => !val || val.length === 0; + + const updateFunctionValue = () => { + // if setting size, auto-select the first column if no column is already set + if (this.inputRefs.fn.value === 'size') { + const col = this.inputRefs.column.value || (columns[0] && columns[0].name); + if (col) return onValueChange(`${this.inputRefs.fn.value}(${maybeQuoteValue(col)})`); + } + + // this.inputRefs.column is the column selection, if there is no value, do nothing + if (valueNotSet(this.inputRefs.column.value)) return setMathFunction(this.inputRefs.fn.value); + + // this.inputRefs.fn is the math function to use, if it's not set, just use the value input + if (valueNotSet(this.inputRefs.fn.value)) return onValueChange(this.inputRefs.column.value); + + // this.inputRefs.fn has a value, so use it as a math.js expression + onValueChange(`${this.inputRefs.fn.value}(${maybeQuoteValue(this.inputRefs.column.value)})`); + }; + + const column = columns.map(col => col.name).find(colName => colName === mathValue.column) || ''; + + const options = [{ value: '', text: 'select column', disabled: true }]; + + sortBy(columns, 'name').forEach(column => { + if (allowedTypes && !allowedTypes.includes(column.type)) return; + options.push({ value: column.name, text: column.name }); + }); + + return ( + + + (this.inputRefs.fn = ref)} + onlymath={onlyShowMathFunctions} + onChange={updateFunctionValue} + /> + + + (this.inputRefs.column = ref)} + onChange={updateFunctionValue} + /> + + + ); + } +} + +const EnhancedDatacolumnArgInput = compose( + withPropsOnChange(['argValue', 'columns'], ({ argValue, columns }) => ({ + mathValue: (argValue => { + if (getType(argValue) !== 'string') return { error: 'argValue is not a string type' }; + try { + const matchedCol = columns.find(({ name }) => argValue === name); + const val = matchedCol ? maybeQuoteValue(matchedCol.name) : argValue; + return getFormObject(val); + } catch (e) { + return { error: e.message }; + } + })(argValue), + })), + createStatefulPropHoc('mathValue', 'setMathValue'), + withHandlers({ + setMathFunction: ({ mathValue, setMathValue }) => fn => setMathValue({ ...mathValue, fn }), + }) +)(DatacolumnArgInput); + +EnhancedDatacolumnArgInput.propTypes = { + argValue: PropTypes.oneOfType([PropTypes.string, PropTypes.object]).isRequired, + columns: PropTypes.array.isRequired, +}; + +export const datacolumn = () => ({ + name: 'datacolumn', + displayName: 'Column', + help: 'Select the data column', + default: '""', + simpleTemplate: templateFromReactComponent(EnhancedDatacolumnArgInput), +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/datacolumn/simple_math_function.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/datacolumn/simple_math_function.js new file mode 100644 index 0000000000000..2af6a7a0d8445 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/datacolumn/simple_math_function.js @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { EuiSelect } from '@elastic/eui'; + +export const SimpleMathFunction = ({ onChange, value, inputRef, onlymath }) => { + const options = [ + { text: 'Average', value: 'mean' }, + { text: 'Count', value: 'size' }, + { text: 'First', value: 'first' }, + { text: 'Last', value: 'last' }, + { text: 'Max', value: 'max' }, + { text: 'Median', value: 'median' }, + { text: 'Min', value: 'min' }, + { text: 'Sum', value: 'sum' }, + { text: 'Unique', value: 'unique' }, + ]; + + if (!onlymath) options.unshift({ text: 'Value', value: '' }); + + return ( + + ); +}; + +SimpleMathFunction.propTypes = { + onChange: PropTypes.func, + value: PropTypes.string, + inputRef: PropTypes.func, + onlymath: PropTypes.bool, +}; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/image_upload/image_upload.scss b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/image_upload/image_upload.scss new file mode 100644 index 0000000000000..145d531b5bac5 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/image_upload/image_upload.scss @@ -0,0 +1,9 @@ +@import '../../../lib/eui.scss'; + +.canvasArgImage { + .canvasArgImage--preview { + max-height: 100px; + overflow: hidden; + border: $euiBorderThin; + } +} diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/image_upload/index.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/image_upload/index.js new file mode 100644 index 0000000000000..e955f38136cca --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/image_upload/index.js @@ -0,0 +1,176 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiImage, + EuiSpacer, + EuiSelect, + EuiButton, + EuiFieldText, +} from '@elastic/eui'; +import { Loading } from '../../../../public/components/loading'; +import { FileUpload } from '../../../../public/components/file_upload'; +import { elasticOutline } from '../../../lib/elastic_outline'; +import { resolveFromArgs } from '../../../../common/lib/resolve_dataurl'; +import { isValid as isValidHttpUrl } from '../../../../common/lib/httpurl'; +import { encode, isValid as isValidDataUrl } from '../../../../common/lib/dataurl'; +import { templateFromReactComponent } from '../../../../public/lib/template_from_react_component'; +import './image_upload.scss'; + +class ImageUpload extends React.Component { + static propTypes = { + onAssetAdd: PropTypes.func.isRequired, + onValueChange: PropTypes.func.isRequired, + typeInstance: PropTypes.object.isRequired, + resolvedArgValue: PropTypes.string, + }; + + constructor(props) { + super(props); + + const url = this.props.resolvedArgValue || null; + const urlType = isValidHttpUrl(url) ? 'src' : 'inline'; // if not a valid base64 string, will show as missing asset icon + + this.inputRefs = {}; + + this.state = { + loading: false, + url, // what to show in preview / paste url text input + urlType, // what panel to show, fileupload or paste url + }; + } + + componentWillUnmount() { + this._isMounted = false; + } + + // keep track of whether or not the component is mounted, to prevent rogue setState calls + _isMounted = true; + + handleUpload = files => { + const { onAssetAdd, onValueChange } = this.props; + const [upload] = files; + this.setState({ loading: true }); // start loading indicator + + encode(upload) + .then(dataurl => onAssetAdd('dataurl', dataurl)) + .then(assetId => { + onValueChange({ + type: 'expression', + chain: [ + { + type: 'function', + function: 'asset', + arguments: { + _: [assetId], + }, + }, + ], + }); + + // this component can go away when onValueChange is called, check for _isMounted + this._isMounted && this.setState({ loading: false }); // set loading state back to false + }); + }; + + changeUrlType = ({ target = {} }) => { + this.setState({ urlType: target.value }); + }; + + setSrcUrl = () => { + const { value: srcUrl } = this.inputRefs.srcUrlText; + this.setState({ url: srcUrl }); + + const { onValueChange } = this.props; + onValueChange(srcUrl); + }; + + urlTypeOptions = [ + { value: 'inline', text: 'Upload Image' }, + { value: 'src', text: 'Paste Image URL' }, + ]; + + render() { + const { loading, url, urlType } = this.state; + const urlTypeInline = urlType === 'inline'; + const urlTypeSrc = urlType === 'src'; + + const selectUrlType = ( + + ); + + let uploadImage = null; + if (urlTypeInline) { + uploadImage = loading ? ( + + ) : ( + + ); + } + + const pasteImageUrl = urlTypeSrc ? ( +
+ (this.inputRefs.srcUrlText = ref)} + placeholder="Image URL" + aria-label="Image URL" + /> + + Set + + + ) : null; + + const shouldPreview = + (urlTypeSrc && isValidHttpUrl(url)) || (urlTypeInline && isValidDataUrl(url)); + + return ( +
+ {selectUrlType} + + + + {uploadImage} + {pasteImageUrl} + + {shouldPreview ? ( + + + + ) : null} + +
+ ); + } +} + +export const imageUpload = () => ({ + name: 'imageUpload', + displayName: 'Image Upload', + help: 'Select or upload an image', + resolveArgValue: true, + template: templateFromReactComponent(ImageUpload), + resolve({ args }) { + return { dataurl: resolveFromArgs(args, elasticOutline) }; + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/index.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/index.js new file mode 100644 index 0000000000000..092e02893ac33 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/index.js @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { axisConfig } from './axis_config'; +import { datacolumn } from './datacolumn'; +import { imageUpload } from './image_upload'; +import { number } from './number'; +import { palette } from './palette'; +import { percentage } from './percentage'; +import { range } from './range'; +import { select } from './select'; +import { shape } from './shape'; +import { string } from './string'; +import { textarea } from './textarea'; +import { toggle } from './toggle'; + +export const args = [ + axisConfig, + datacolumn, + imageUpload, + number, + palette, + percentage, + range, + select, + shape, + string, + textarea, + toggle, +]; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/number.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/number.js new file mode 100644 index 0000000000000..49c22e80a2eb6 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/number.js @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { compose, withProps } from 'recompose'; +import { EuiFieldNumber, EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { get } from 'lodash'; +import { createStatefulPropHoc } from '../../../public/components/enhance/stateful_prop'; +import { templateFromReactComponent } from '../../../public/lib/template_from_react_component'; + +// This is basically a direct copy of the string input, but with some Number() goodness maybe you think that's cheating and it should be +// abstracted. If you can think of a 3rd or 4th usage for that abstraction, cool, do it, just don't make it more confusing. Copying is the +// most understandable way to do this. There, I said it. + +// TODO: Support max/min as options +const NumberArgInput = ({ updateValue, value, confirm, commit, argId }) => ( + + + commit(Number(ev.target.value))} + /> + + {confirm && ( + + commit(Number(value))}> + {confirm} + + + )} + +); + +NumberArgInput.propTypes = { + argId: PropTypes.string.isRequired, + updateValue: PropTypes.func.isRequired, + value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, + confirm: PropTypes.string, + commit: PropTypes.func.isRequired, +}; + +const EnhancedNumberArgInput = compose( + withProps(({ onValueChange, typeInstance, argValue }) => ({ + confirm: get(typeInstance, 'options.confirm'), + commit: onValueChange, + value: argValue, + })), + createStatefulPropHoc('value') +)(NumberArgInput); + +EnhancedNumberArgInput.propTypes = { + argValue: PropTypes.any.isRequired, + onValueChange: PropTypes.func.isRequired, + typeInstance: PropTypes.object.isRequired, +}; + +export const number = () => ({ + name: 'number', + displayName: 'number', + help: 'Input a number', + simpleTemplate: templateFromReactComponent(EnhancedNumberArgInput), + default: '0', +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/palette.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/palette.js new file mode 100644 index 0000000000000..61c5208b108f5 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/palette.js @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { get } from 'lodash'; +import { PalettePicker } from '../../../public/components/palette_picker'; +import { getType } from '../../../common/lib/get_type'; +import { templateFromReactComponent } from '../../../public/lib/template_from_react_component'; + +const PaletteArgInput = ({ onValueChange, argValue, renderError }) => { + // Why is this neccesary? Does the dialog really need to know what parameter it is setting? + + const throwNotParsed = () => renderError(); + + // TODO: This is weird, its basically a reimplementation of what the interpretter would return. + // Probably a better way todo this, and maybe a better way to handle template stype objects in general? + function astToPalette({ chain }) { + if (chain.length !== 1 || chain[0].function !== 'palette') throwNotParsed(); + try { + const colors = chain[0].arguments._.map(astObj => { + if (getType(astObj) !== 'string') throwNotParsed(); + return astObj; + }); + + const gradient = get(chain[0].arguments.gradient, '[0]'); + + return { colors, gradient }; + } catch (e) { + throwNotParsed(); + } + } + + function handleChange(palette) { + const astObj = { + type: 'expression', + chain: [ + { + type: 'function', + function: 'palette', + arguments: { + _: palette.colors, + gradient: [palette.gradient], + }, + }, + ], + }; + + onValueChange(astObj); + } + + const palette = astToPalette(argValue); + + return ; +}; + +PaletteArgInput.propTypes = { + onValueChange: PropTypes.func.isRequired, + argValue: PropTypes.any.isRequired, + renderError: PropTypes.func, +}; + +export const palette = () => ({ + name: 'palette', + displayName: 'Color Palette', + help: 'Choose a color palette', + default: + '{palette #882E72 #B178A6 #D6C1DE #1965B0 #5289C7 #7BAFDE #4EB265 #90C987 #CAE0AB #F7EE55 #F6C141 #F1932D #E8601C #DC050C}', + simpleTemplate: templateFromReactComponent(PaletteArgInput), +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/percentage.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/percentage.js new file mode 100644 index 0000000000000..da4af530b5d12 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/percentage.js @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { EuiRange } from '@elastic/eui'; +import { templateFromReactComponent } from '../../../public/lib/template_from_react_component'; + +const PercentageArgInput = ({ onValueChange, argValue }) => { + const handleChange = ev => { + return onValueChange(ev.target.value / 100); + }; + + return ( + + ); +}; + +PercentageArgInput.propTypes = { + onValueChange: PropTypes.func.isRequired, + argValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.bool]).isRequired, + argId: PropTypes.string.isRequired, +}; + +export const percentage = () => ({ + name: 'percentage', + displayName: 'Percentage', + help: 'Slider for percentage ', + simpleTemplate: templateFromReactComponent(PercentageArgInput), +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/range.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/range.js new file mode 100644 index 0000000000000..5d8776a108efd --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/range.js @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { EuiRange } from '@elastic/eui'; +import { templateFromReactComponent } from '../../../public/lib/template_from_react_component'; + +const RangeArgInput = ({ typeInstance, onValueChange, argValue }) => { + const { min, max, step } = typeInstance.options; + const handleChange = ev => { + return onValueChange(Number(ev.target.value)); + }; + + return ( + + ); +}; + +RangeArgInput.propTypes = { + onValueChange: PropTypes.func.isRequired, + argValue: PropTypes.number.isRequired, + typeInstance: PropTypes.shape({ + options: PropTypes.shape({ + min: PropTypes.number.isRequired, + max: PropTypes.number.isRequired, + step: PropTypes.number, + }).isRequired, + }), + argId: PropTypes.string.isRequired, +}; + +export const range = () => ({ + name: 'range', + displayName: 'Range', + help: 'Slider for values within a range', + simpleTemplate: templateFromReactComponent(RangeArgInput), +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/register.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/register.js new file mode 100644 index 0000000000000..4d4585f4ffcf2 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/register.js @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { args } from './index'; + +args.forEach(canvas.register); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/select.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/select.js new file mode 100644 index 0000000000000..7a65f33ef7780 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/select.js @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { EuiSelect } from '@elastic/eui'; +import { templateFromReactComponent } from '../../../public/lib/template_from_react_component'; + +const SelectArgInput = ({ typeInstance, onValueChange, argValue, argId }) => { + const choices = typeInstance.options.choices.map(({ value, name }) => ({ value, text: name })); + const handleChange = ev => { + // Get the value from the choices passed in since it could be a number or + // boolean, but ev.target.value is always a string + const { value } = choices[ev.target.selectedIndex]; + return onValueChange(value); + }; + + return ( + + ); +}; + +SelectArgInput.propTypes = { + onValueChange: PropTypes.func.isRequired, + argValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.bool]).isRequired, + typeInstance: PropTypes.shape({ + name: PropTypes.string.isRequired, + options: PropTypes.shape({ + choices: PropTypes.arrayOf( + PropTypes.shape({ + name: PropTypes.string.isRequired, + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.bool]) + .isRequired, + }) + ).isRequired, + }), + }), + argId: PropTypes.string.isRequired, +}; + +export const select = () => ({ + name: 'select', + displayName: 'Select', + help: 'Select from multiple options in a drop down', + simpleTemplate: templateFromReactComponent(SelectArgInput), +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/shape.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/shape.js new file mode 100644 index 0000000000000..2a71e9b42e480 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/shape.js @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { shapes } from '../../renderers/shape/shapes'; +import { templateFromReactComponent } from '../../../public/lib/template_from_react_component'; +import { ShapePickerMini } from '../../../public/components/shape_picker_mini/'; + +const ShapeArgInput = ({ onValueChange, argValue }) => ( + + + + + +); + +ShapeArgInput.propTypes = { + argValue: PropTypes.any.isRequired, + onValueChange: PropTypes.func.isRequired, +}; + +export const shape = () => ({ + name: 'shape', + displayName: 'Shape', + help: 'Shape picker', + simpleTemplate: templateFromReactComponent(ShapeArgInput), + default: '"square"', +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/string.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/string.js new file mode 100644 index 0000000000000..013b0f19f6ca0 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/string.js @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { compose, withProps } from 'recompose'; +import { EuiFlexItem, EuiFlexGroup, EuiFieldText, EuiButton } from '@elastic/eui'; +import { get } from 'lodash'; +import { createStatefulPropHoc } from '../../../public/components/enhance/stateful_prop'; +import { templateFromReactComponent } from '../../../public/lib/template_from_react_component'; + +const StringArgInput = ({ updateValue, value, confirm, commit, argId }) => ( + + + commit(ev.target.value)} + /> + + {confirm && ( + + commit(value)}> + {confirm} + + + )} + +); + +StringArgInput.propTypes = { + updateValue: PropTypes.func.isRequired, + value: PropTypes.string.isRequired, + confirm: PropTypes.string, + commit: PropTypes.func.isRequired, + argId: PropTypes.string.isRequired, +}; + +const EnhancedStringArgInput = compose( + withProps(({ onValueChange, typeInstance, argValue }) => ({ + confirm: get(typeInstance, 'options.confirm'), + commit: onValueChange, + value: argValue, + })), + createStatefulPropHoc('value') +)(StringArgInput); + +EnhancedStringArgInput.propTypes = { + argValue: PropTypes.any.isRequired, + onValueChange: PropTypes.func.isRequired, + typeInstance: PropTypes.object.isRequired, +}; + +export const string = () => ({ + name: 'string', + displayName: 'string', + help: 'Input short strings', + simpleTemplate: templateFromReactComponent(EnhancedStringArgInput), +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/textarea.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/textarea.js new file mode 100644 index 0000000000000..d0dbfb22a5dd3 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/textarea.js @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { compose, withProps } from 'recompose'; +import { EuiForm, EuiTextArea, EuiSpacer, EuiButton } from '@elastic/eui'; +import { get } from 'lodash'; +import { createStatefulPropHoc } from '../../../public/components/enhance/stateful_prop'; +import { templateFromReactComponent } from '../../../public/lib/template_from_react_component'; + +const TextAreaArgInput = ({ updateValue, value, confirm, commit, renderError, argId }) => { + if (typeof value !== 'string') { + renderError(); + return null; + } + return ( + + commit(ev.target.value)} + /> + + commit(value)}> + {confirm} + + + + ); +}; + +TextAreaArgInput.propTypes = { + updateValue: PropTypes.func.isRequired, + value: PropTypes.oneOfType([PropTypes.string, PropTypes.object]).isRequired, + confirm: PropTypes.string, + commit: PropTypes.func.isRequired, + renderError: PropTypes.func, + argId: PropTypes.string.isRequired, +}; + +const EnhancedTextAreaArgInput = compose( + withProps(({ onValueChange, typeInstance, argValue }) => ({ + confirm: get(typeInstance, 'options.confirm'), + commit: onValueChange, + value: argValue, + })), + createStatefulPropHoc('value') +)(TextAreaArgInput); + +EnhancedTextAreaArgInput.propTypes = { + argValue: PropTypes.any.isRequired, + onValueChange: PropTypes.func.isRequired, + typeInstance: PropTypes.object.isRequired, + renderError: PropTypes.func.isRequired, +}; + +export const textarea = () => ({ + name: 'textarea', + displayName: 'textarea', + help: 'Input long strings', + template: templateFromReactComponent(EnhancedTextAreaArgInput), +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/toggle.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/toggle.js new file mode 100644 index 0000000000000..15a9b8c7ce347 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/toggle.js @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { EuiSwitch } from '@elastic/eui'; +import { templateFromReactComponent } from '../../../public/lib/template_from_react_component'; + +const ToggleArgInput = ({ onValueChange, argValue, argId }) => { + const handleChange = () => onValueChange(!argValue); + + return ; +}; + +ToggleArgInput.propTypes = { + onValueChange: PropTypes.func.isRequired, + argValue: PropTypes.bool.isRequired, + argId: PropTypes.string.isRequired, +}; + +export const toggle = () => ({ + name: 'toggle', + displayName: 'Toggle', + help: 'A true/false toggle switch', + simpleTemplate: templateFromReactComponent(ToggleArgInput), + default: 'false', +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/datasources/demodata.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/datasources/demodata.js new file mode 100644 index 0000000000000..66e18c8bf5392 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/datasources/demodata.js @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiText } from '@elastic/eui'; +import { templateFromReactComponent } from '../../../public/lib/template_from_react_component'; + +const DemodataDatasource = () => ( + +

You are using demo data

+

+ This data source is connected to every Canvas element by default. Its purpose is to give you + some playground data to get started. The demo set contains 4 strings, 3 numbers and a date. + Feel free to experiment and, when you're ready, click the Change Datasource{' '} + link below to connect to your own data. +

+
+); + +export const demodata = () => ({ + name: 'demodata', + displayName: 'Demo Data', + help: 'Mock data set with with usernames, prices, projects, countries and phases.', + // Replace this with a better icon when we have time. + image: 'logoElasticStack', + template: templateFromReactComponent(DemodataDatasource), +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/datasources/essql.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/datasources/essql.js new file mode 100644 index 0000000000000..9acfa963c0dab --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/datasources/essql.js @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { EuiFormRow, EuiTextArea } from '@elastic/eui'; +import { getSimpleArg, setSimpleArg } from '../../../public/lib/arg_helpers'; +import { templateFromReactComponent } from '../../../public/lib/template_from_react_component'; + +class EssqlDatasource extends PureComponent { + componentDidMount() { + const query = this.getQuery(); + if (typeof query !== 'string') this.setArg(this.getArgName(), this.defaultQuery); + else this.props.setInvalid(!query.trim()); + } + + defaultQuery = 'SELECT * FROM logstash*'; + + getQuery = () => getSimpleArg(this.getArgName(), this.props.args)[0]; + + // TODO: This is a terrible way of doing defaults. We need to find a way to read the defaults for the function + // and set them for the data source UI. + getArgName = () => { + const { args } = this.props; + if (getSimpleArg('_', args)[0]) return '_'; + if (getSimpleArg('q', args)[0]) return 'q'; + return 'query'; + }; + + setArg = (name, value) => { + const { args, updateArgs } = this.props; + updateArgs && + updateArgs({ + ...args, + ...setSimpleArg(name, value), + }); + }; + + onChange = e => { + const { value } = e.target; + this.props.setInvalid(!value.trim()); + this.setArg(this.getArgName(), value); + }; + + render() { + const { isInvalid } = this.props; + return ( + + + + ); + } +} + +EssqlDatasource.propTypes = { + args: PropTypes.object.isRequired, + updateArgs: PropTypes.func, + isInvalid: PropTypes.bool, + setInvalid: PropTypes.func, +}; + +export const essql = () => ({ + name: 'essql', + displayName: 'Elasticsearch SQL', + help: 'Use Elasticsearch SQL to get a datatable', + // Replace this with a SQL logo when we have one in EUI + image: 'logoElasticsearch', + template: templateFromReactComponent(EssqlDatasource), +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/datasources/index.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/datasources/index.js new file mode 100644 index 0000000000000..107d4d241d2e7 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/datasources/index.js @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { timelion } from './timelion'; +import { demodata } from './demodata'; +import { essql } from './essql'; + +export const datasourceSpecs = [timelion, demodata, essql]; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/datasources/register.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/datasources/register.js new file mode 100644 index 0000000000000..4c6e949b92110 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/datasources/register.js @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { datasourceSpecs } from './index'; + +datasourceSpecs.forEach(canvas.register); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/datasources/timelion.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/datasources/timelion.js new file mode 100644 index 0000000000000..386b8ce89214f --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/datasources/timelion.js @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { + EuiFormRow, + EuiFieldText, + EuiCallOut, + EuiSpacer, + EuiCode, + EuiText, + EuiTextArea, +} from '@elastic/eui'; +import { getSimpleArg, setSimpleArg } from '../../../public/lib/arg_helpers'; +import { templateFromReactComponent } from '../../../public/lib/template_from_react_component'; + +const TimelionDatasource = ({ args, updateArgs }) => { + const setArg = (name, value) => { + updateArgs && + updateArgs({ + ...args, + ...setSimpleArg(name, value), + }); + }; + + const getArgName = () => { + if (getSimpleArg('_', args)[0]) return '_'; + if (getSimpleArg('q', args)[0]) return 'q'; + return 'query'; + }; + + const argName = getArgName(); + + // TODO: This is a terrible way of doing defaults. We need to find a way to read the defaults for the function + // and set them for the data source UI. + const getQuery = () => { + return getSimpleArg(argName, args)[0] || '.es(*)'; + }; + + const getInterval = () => { + return getSimpleArg('interval', args)[0] || 'auto'; + }; + + return ( +
+ +

Timelion

+

+ Canvas integrates with Kibana's Timelion application to allow you to use Timelion queries + to pull back timeseries data in a tabular format that can be used with Canvas elements. +

+
+ + + + + setArg(argName, e.target.value)} + /> + + { + // TODO: Time timelion interval picker should be a drop down + } + + setArg('interval', e.target.value)} /> + + + +
    +
  • + Timelion requires a time range, you should add a time filter element to your page + somewhere, or use the code editor to pass in a time filter. +
  • +
  • + Some Timelion functions, such as .color(), don't translate to a + Canvas data table. Anything todo with data manipulation should work grand. +
  • +
+
+
+ ); +}; + +TimelionDatasource.propTypes = { + args: PropTypes.object.isRequired, + updateArgs: PropTypes.func, +}; + +export const timelion = () => ({ + name: 'timelion', + displayName: 'Timelion', + help: 'Use timelion syntax to retrieve a timeseries', + image: 'timelionApp', + template: templateFromReactComponent(TimelionDatasource), +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/models/index.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/models/index.js new file mode 100644 index 0000000000000..e5b047d83c924 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/models/index.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pointseries } from './point_series'; +import { math } from './math'; + +export const modelSpecs = [pointseries, math]; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/models/math.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/models/math.js new file mode 100644 index 0000000000000..22cfe3d486bae --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/models/math.js @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get } from 'lodash'; +import { getState, getValue } from '../../../public/lib/resolved_arg'; + +export const math = () => ({ + name: 'math', + displayName: 'Measure', + args: [ + { + name: '_', + displayName: 'Value', + help: 'Function and column to use in extracting a value from the datasource', + argType: 'datacolumn', + options: { + onlyMath: false, + }, + }, + ], + resolve({ context }) { + if (getState(context) !== 'ready') return { columns: [] }; + return { columns: get(getValue(context), 'columns', []) }; + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/models/point_series.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/models/point_series.js new file mode 100644 index 0000000000000..60fe433ef60b3 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/models/point_series.js @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get } from 'lodash'; +import { getState, getValue } from '../../../public/lib/resolved_arg'; + +export const pointseries = () => ({ + name: 'pointseries', + displayName: 'Dimensions & Measures', + args: [ + { + name: 'x', + displayName: 'X-axis', + help: 'Data along the horizontal axis. Usually a number, string or date', + argType: 'datacolumn', + }, + { + name: 'y', + displayName: 'Y-axis', + help: 'Data along the vertical axis. Usually a number.', + argType: 'datacolumn', + }, + { + name: 'color', + displayName: 'Color', + help: 'Determines the color of a mark or series', + argType: 'datacolumn', + }, + { + name: 'size', + displayName: 'Size', + help: 'Determine the size of a mark', + argType: 'datacolumn', + }, + { + name: 'text', + displayName: 'Text', + help: 'Set the text to use as, or around, the mark', + argType: 'datacolumn', + }, + ], + resolve({ context }) { + if (getState(context) !== 'ready') return { columns: [] }; + return { columns: get(getValue(context), 'columns', []) }; + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/models/register.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/models/register.js new file mode 100644 index 0000000000000..854d28be96048 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/models/register.js @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { modelSpecs } from './index'; + +modelSpecs.forEach(canvas.register); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/transforms/index.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/transforms/index.js new file mode 100644 index 0000000000000..591461d414990 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/transforms/index.js @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { sort } from './sort'; + +export const transformSpecs = [sort]; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/transforms/register.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/transforms/register.js new file mode 100644 index 0000000000000..de693d5c3d52c --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/transforms/register.js @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { transformSpecs } from './index'; + +transformSpecs.forEach(canvas.register); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/transforms/sort.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/transforms/sort.js new file mode 100644 index 0000000000000..ac19bafc27e5a --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/transforms/sort.js @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get } from 'lodash'; +import { getState, getValue } from '../../../public/lib/resolved_arg'; + +export const sort = () => ({ + name: 'sort', + displayName: 'Datatable Sorting', + args: [ + { + name: '_', + displayName: 'Sort Field', + argType: 'datacolumn', + }, + { + name: 'reverse', + displayName: 'Descending', + argType: 'toggle', + }, + ], + resolve({ context }) { + if (getState(context) === 'ready') return { columns: get(getValue(context), 'columns', []) }; + + return { columns: [] }; + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/views/dropdownControl.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/views/dropdownControl.js new file mode 100644 index 0000000000000..e88f8e8bba369 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/views/dropdownControl.js @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get } from 'lodash'; +import { getState, getValue } from '../../../public/lib/resolved_arg'; + +export const dropdownControl = () => ({ + name: 'dropdownControl', + displayName: 'Dropdown Filter', + modelArgs: [], + args: [ + { + name: 'valueColumn', + displayName: 'Values Column', + help: 'Column from which to extract values to make available in the dropdown', + argType: 'string', + options: { + confirm: 'Set', + }, + }, + { + name: 'filterColumn', + displayName: 'Filter Column ', + help: 'Column to which the value selected from the dropdown is applied', + argType: 'string', + options: { + confirm: 'Set', + }, + }, + ], + resolve({ context }) { + if (getState(context) !== 'ready') return { columns: [] }; + return { columns: get(getValue(context), 'columns', []) }; + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/views/getCell.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/views/getCell.js new file mode 100644 index 0000000000000..f356b74c7127e --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/views/getCell.js @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const getCell = () => ({ + name: 'getCell', + displayName: 'Get Cell', + help: 'Grab the first row and first column', + modelArgs: ['size'], + requiresContext: true, + args: [], +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/views/image.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/views/image.js new file mode 100644 index 0000000000000..488b42d3a1011 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/views/image.js @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { elasticLogo } from '../../lib/elastic_logo'; +import { resolveFromArgs } from '../../../common/lib/resolve_dataurl'; + +export const image = () => ({ + name: 'image', + displayName: 'Image', + modelArgs: [], + requiresContext: false, + args: [ + { + name: 'dataurl', + argType: 'imageUpload', + resolve({ args }) { + return { dataurl: resolveFromArgs(args, elasticLogo) }; + }, + }, + { + name: 'mode', + displayName: 'Fill mode', + help: 'Note: Stretched fill may not work with vector images', + argType: 'select', + options: { + choices: [ + { value: 'contain', name: 'Contain' }, + { value: 'cover', name: 'Cover' }, + { value: 'stretch', name: 'Stretch' }, + ], + }, + }, + ], +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/views/index.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/views/index.js new file mode 100644 index 0000000000000..7215030a8a001 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/views/index.js @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { dropdownControl } from './dropdownControl'; +import { image } from './image'; +import { markdown } from './markdown'; +import { metric } from './metric'; +import { pie } from './pie'; +import { plot } from './plot'; +import { getCell } from './getCell'; +import { repeatImage } from './repeatImage'; +import { revealImage } from './revealImage'; +import { render } from './render'; +import { shape } from './shape'; +import { table } from './table'; +import { timefilterControl } from './timefilterControl'; + +export const viewSpecs = [ + dropdownControl, + image, + markdown, + metric, + pie, + plot, + getCell, + repeatImage, + revealImage, + render, + shape, + table, + timefilterControl, +]; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/views/markdown.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/views/markdown.js new file mode 100644 index 0000000000000..a17af7000e66d --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/views/markdown.js @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const markdown = () => ({ + name: 'markdown', + displayName: 'Markdown', + help: 'Generate markup using markdown', + modelArgs: [], + requiresContext: false, + args: [ + { + name: '_', + displayName: 'Markdown content', + help: 'Markdown formatted text', + argType: 'textarea', + default: '""', + options: { + confirm: 'Apply', + }, + multi: true, + }, + { + name: 'font', + argType: 'font', + }, + ], +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/views/metric.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/views/metric.js new file mode 100644 index 0000000000000..e03f4d5359a6b --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/views/metric.js @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { openSans } from '../../../common/lib/fonts'; + +export const metric = () => ({ + name: 'metric', + displayName: 'Metric', + modelArgs: [['_', { label: 'Number' }]], + requiresContext: false, + args: [ + { + name: '_', + displayName: 'Label', + help: 'Describes the metric', + argType: 'string', + default: '""', + }, + { + name: 'metricFont', + displayName: 'Metric Text Settings', + help: 'Fonts, alignment and color', + argType: 'font', + default: `{font size=48 family="${openSans.value}" color="#000000" align=center lHeight=48}`, + }, + { + name: 'labelFont', + displayName: 'Label Text Settings', + help: 'Fonts, alignment and color', + argType: 'font', + default: `{font size=18 family="${openSans.value}" color="#000000" align=center}`, + }, + ], +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/views/pie.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/views/pie.js new file mode 100644 index 0000000000000..7c63b4cf559ad --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/views/pie.js @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { map, uniq } from 'lodash'; +import { legendOptions } from '../../../public/lib/legend_options'; +import { getState, getValue } from '../../../public/lib/resolved_arg'; + +export const pie = () => ({ + name: 'pie', + displayName: 'Chart Style', + modelArgs: [['color', { label: 'Slice Labels' }], ['size', { label: 'Slice Angles' }]], + args: [ + { + name: 'palette', + argType: 'palette', + }, + { + name: 'hole', + displayName: 'Inner Radius', + help: 'Radius of the hole', + argType: 'range', + default: 50, + options: { + min: 0, + max: 100, + }, + }, + { + name: 'labels', + displayName: 'Labels', + help: 'Show/hide labels', + argType: 'toggle', + default: true, + }, + { + name: 'labelRadius', + displayName: 'Label Radius', + help: 'Distance of the labels from the center of the pie', + argType: 'range', + default: 100, + options: { + min: 0, + max: 100, + }, + }, + { + name: 'legend', + displayName: 'Legend Position', + help: 'Disable or position the legend', + argType: 'select', + default: 'ne', + options: { + choices: legendOptions, + }, + }, + { + name: 'radius', + displayName: 'Radius', + help: 'Radius of the pie', + argType: 'percentage', + default: 1, + }, + { + name: 'seriesStyle', + argType: 'seriesStyle', + multi: true, + }, + { + name: 'font', + argType: 'font', + }, + { + name: 'tilt', + displayName: 'Tilt Angle', + help: 'Percentage of tilt where 1 is fully vertical and 0 is completely flat', + argType: 'percentage', + default: 1, + }, + ], + resolve({ context }) { + if (getState(context) !== 'ready') return { labels: [] }; + return { labels: uniq(map(getValue(context).rows, 'color').filter(v => v !== undefined)) }; + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/views/plot.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/views/plot.js new file mode 100644 index 0000000000000..e56e2bcfd08c4 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/views/plot.js @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { map, uniq } from 'lodash'; +import { getState, getValue } from '../../../public/lib/resolved_arg'; +import { legendOptions } from '../../../public/lib/legend_options'; + +const styleProps = ['lines', 'bars', 'points', 'fill', 'stack']; + +export const plot = () => ({ + name: 'plot', + displayName: 'Chart Style', + modelArgs: ['x', 'y', 'color', 'size', 'text'], + args: [ + { + name: 'palette', + argType: 'palette', + }, + { + name: 'legend', + displayName: 'Legend Position', + help: 'Disable or position the legend', + argType: 'select', + default: 'ne', + options: { + choices: legendOptions, + }, + }, + { + name: 'xaxis', + displayName: 'X-Axis', + help: 'Configure or disable the x-axis', + argType: 'axisConfig', + default: true, + }, + { + name: 'yaxis', + displayName: 'Y-Axis', + help: 'Configure or disable the Y-axis', + argType: 'axisConfig', + default: true, + }, + { + name: 'font', + argType: 'font', + }, + { + name: 'defaultStyle', + displayName: 'Default style', + help: 'Set the style to be used by default by every series, unless overridden.', + argType: 'seriesStyle', + default: '{seriesStyle points=5}', + options: { + include: styleProps, + }, + }, + { + name: 'seriesStyle', + argType: 'seriesStyle', + options: { + include: styleProps, + }, + multi: true, + }, + ], + resolve({ context }) { + if (getState(context) !== 'ready') return { labels: [] }; + return { labels: uniq(map(getValue(context).rows, 'color').filter(v => v !== undefined)) }; + }, +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/views/register.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/views/register.js new file mode 100644 index 0000000000000..9240a4259ea04 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/views/register.js @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { viewSpecs } from './index'; + +viewSpecs.forEach(canvas.register); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/views/render.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/views/render.js new file mode 100644 index 0000000000000..af87174a8cd11 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/views/render.js @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const render = () => ({ + name: 'render', + displayName: 'Element style', + help: 'Setting for the container around your element', + modelArgs: [], + requiresContext: false, + args: [ + { + name: 'containerStyle', + argType: 'containerStyle', + }, + { + name: 'css', + displayName: 'CSS', + help: 'A CSS stylesheet scoped to your element', + argType: 'textarea', + default: `".canvasRenderEl { + +}"`, + options: { + confirm: 'Apply stylesheet', + }, + }, + ], +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/views/repeatImage.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/views/repeatImage.js new file mode 100644 index 0000000000000..8a9d51e5bc005 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/views/repeatImage.js @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const repeatImage = () => ({ + name: 'repeatImage', + displayName: 'Repeating Image', + help: '', + modelArgs: [['_', { label: 'Value' }]], + args: [ + { + name: 'image', + displayName: 'Image', + help: 'An image to repeat', + argType: 'imageUpload', + }, + { + name: 'emptyImage', + displayName: 'Empty Image', + help: 'An image to fill up the difference between the value and the max count', + argType: 'imageUpload', + }, + { + name: 'size', + displayName: 'Image size', + help: + 'The size of the largest dimension of the image. Eg, if the image is tall but not wide, this is the height', + argType: 'number', + default: '100', + }, + { + name: 'max', + displayName: 'Max count', + help: 'The maximum number of repeated images', + argType: 'number', + default: '1000', + }, + ], +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/views/revealImage.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/views/revealImage.js new file mode 100644 index 0000000000000..58af27ba0ce92 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/views/revealImage.js @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const revealImage = () => ({ + name: 'revealImage', + displayName: 'Reveal Image', + help: '', + modelArgs: [['_', { label: 'Value' }]], + args: [ + { + name: 'image', + displayName: 'Image', + help: 'An image to reveal given the function input. Eg, a full glass', + argType: 'imageUpload', + }, + { + name: 'emptyImage', + displayName: 'Background Image', + help: 'A background image. Eg, an empty glass', + argType: 'imageUpload', + }, + { + name: 'origin', + displayName: 'Reveal from', + help: 'The direction from which to start the reveal', + argType: 'select', + options: { + choices: [ + { value: 'top', name: 'Top' }, + { value: 'left', name: 'Left' }, + { value: 'bottom', name: 'Bottom' }, + { value: 'right', name: 'Right' }, + ], + }, + }, + ], +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/views/shape.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/views/shape.js new file mode 100644 index 0000000000000..70dd12aa0f4b4 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/views/shape.js @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { shapes } from '../../renderers/shape/shapes'; + +export const shape = () => ({ + name: 'shape', + displayName: 'Shape', + modelArgs: [], + requiresContext: false, + args: [ + { + name: '_', + displayName: 'Select a Shape', + argType: 'shape', + help: 'A basic shape', + options: { + choices: Object.keys(shapes).map(shape => ({ + value: shape, + name: shape, + })), + }, + }, + { + name: 'fill', + displayName: 'Fill', + argType: 'color', + help: 'Fill color of the shape', + }, + { + name: 'border', + displayName: 'Border', + argType: 'color', + help: 'Border color', + }, + { + name: 'borderWidth', + displayName: 'Border Width', + argType: 'number', + help: 'Border width', + }, + { + name: 'maintainAspect', + displayName: 'Maintain Aspect Ratio', + argType: 'toggle', + help: `Select 'true' to maintain aspect ratio`, + }, + ], +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/views/table.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/views/table.js new file mode 100644 index 0000000000000..59c2d8dbdbef4 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/views/table.js @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const table = () => ({ + name: 'table', + displayName: 'Table Style', + help: 'Set styling for a Table element', + modelArgs: [], + args: [ + { + name: 'font', + argType: 'font', + }, + { + name: 'perPage', + displayName: 'Rows per page', + help: 'Number of rows to display per table page.', + argType: 'select', + default: 10, + options: { + choices: ['', 5, 10, 25, 50, 100].map(v => ({ name: String(v), value: v })), + }, + }, + { + name: 'paginate', + displayName: 'Pagination', + help: 'Show or hide pagination controls. If disabled only the first page will be shown.', + argType: 'toggle', + default: true, + }, + { + name: 'showHeader', + displayName: 'Header', + help: 'Show or hide the header row with titles for each column.', + argType: 'toggle', + default: true, + }, + ], +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/views/timefilterControl.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/views/timefilterControl.js new file mode 100644 index 0000000000000..288de400b17f0 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/views/timefilterControl.js @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get } from 'lodash'; +import { getState, getValue } from '../../../public/lib/resolved_arg'; + +export const timefilterControl = () => ({ + name: 'timefilterControl', + displayName: 'Time Filter', + modelArgs: [], + args: [ + { + name: 'column', + displayName: 'Column', + help: 'Column to which selected time is applied', + argType: 'string', + options: { + confirm: 'Set', + }, + }, + ], + resolve({ context }) { + if (getState(context) !== 'ready') return { columns: [] }; + return { columns: get(getValue(context), 'columns', []) }; + }, +}); diff --git a/x-pack/plugins/canvas/common/functions/index.js b/x-pack/plugins/canvas/common/functions/index.js new file mode 100644 index 0000000000000..00e60b13b0b6a --- /dev/null +++ b/x-pack/plugins/canvas/common/functions/index.js @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { to } from './to'; + +export const commonFunctions = [to]; diff --git a/x-pack/plugins/canvas/common/functions/to.js b/x-pack/plugins/canvas/common/functions/to.js new file mode 100644 index 0000000000000..142ffe490ca38 --- /dev/null +++ b/x-pack/plugins/canvas/common/functions/to.js @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { castProvider } from '../interpreter/cast'; + +export const to = () => ({ + name: 'to', + aliases: [], + help: 'Explicitly cast from one type to another.', + context: {}, + args: { + type: { + types: ['string'], + help: 'A known type', + aliases: ['_'], + multi: true, + }, + }, + fn: (context, args, { types }) => { + if (!args.type) throw new Error('Must specify a casting type'); + + return castProvider(types)(context, args.type); + }, +}); diff --git a/x-pack/plugins/canvas/common/interpreter/cast.js b/x-pack/plugins/canvas/common/interpreter/cast.js new file mode 100644 index 0000000000000..7e559afcba40e --- /dev/null +++ b/x-pack/plugins/canvas/common/interpreter/cast.js @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getType } from '../lib/get_type'; + +export function castProvider(types) { + return function cast(node, toTypeNames) { + // If you don't give us anything to cast to, you'll get your input back + if (!toTypeNames || toTypeNames.length === 0) return node; + + // No need to cast if node is already one of the valid types + const fromTypeName = getType(node); + if (toTypeNames.includes(fromTypeName)) return node; + + const fromTypeDef = types[fromTypeName]; + + for (let i = 0; i < toTypeNames.length; i++) { + // First check if the current type can cast to this type + if (fromTypeDef && fromTypeDef.castsTo(toTypeNames[i])) + return fromTypeDef.to(node, toTypeNames[i], types); + + // If that isn't possible, check if this type can cast from the current type + const toTypeDef = types[toTypeNames[i]]; + if (toTypeDef && toTypeDef.castsFrom(fromTypeName)) return toTypeDef.from(node, types); + } + + throw new Error(`Can not cast '${fromTypeName}' to any of '${toTypeNames.join(', ')}'`); + }; +} diff --git a/x-pack/plugins/canvas/common/interpreter/interpret.js b/x-pack/plugins/canvas/common/interpreter/interpret.js new file mode 100644 index 0000000000000..2777e9d0b80ea --- /dev/null +++ b/x-pack/plugins/canvas/common/interpreter/interpret.js @@ -0,0 +1,197 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import clone from 'lodash.clone'; +import { each, keys, last, mapValues, reduce, zipObject } from 'lodash'; +import { getType } from '../lib/get_type'; +import { fromExpression } from '../lib/ast'; +import { getByAlias } from '../lib/get_by_alias'; +import { typesRegistry } from '../lib/types_registry'; +import { castProvider } from './cast'; + +const createError = (err, { name, context, args }) => ({ + type: 'error', + error: { + stack: err.stack, + message: typeof err === 'string' ? err : err.message, + }, + info: { + context, + args, + functionName: name, + }, +}); + +export function interpretProvider(config) { + const { functions, onFunctionNotFound, types } = config; + const handlers = { ...config.handlers, types }; + const cast = castProvider(types); + + return interpret; + + function interpret(node, context = null) { + switch (getType(node)) { + case 'expression': + return invokeChain(node.chain, context); + case 'string': + case 'number': + case 'null': + case 'boolean': + return node; + default: + throw new Error(`Unknown AST object: ${JSON.stringify(node)}`); + } + } + + async function invokeChain(chainArr, context) { + if (!chainArr.length) return Promise.resolve(context); + + const chain = clone(chainArr); + const link = chain.shift(); // Every thing in the chain will always be a function right? + const { function: fnName, arguments: fnArgs } = link; + const fnDef = getByAlias(functions, fnName); + + // if the function is not found, pass the expression chain to the not found handler + // in this case, it will try to execute the function in another context + if (!fnDef) { + chain.unshift(link); + return onFunctionNotFound({ type: 'expression', chain: chain }, context); + } + + try { + // Resolve arguments before passing to function + // resolveArgs returns an object because the arguments themselves might + // actually have a 'then' function which would be treated as a promise + const { resolvedArgs } = await resolveArgs(fnDef, context, fnArgs); + const newContext = await invokeFunction(fnDef, context, resolvedArgs); + + // if something failed, just return the failure + if (getType(newContext) === 'error') { + console.log('newContext error', newContext); + return newContext; + } + + // Continue re-invoking chain until it's empty + return await invokeChain(chain, newContext); + } catch (err) { + console.error(`common/interpret ${fnName}: invokeChain rejected`, err); + return createError(err, { name: fnName, context, args: fnArgs }); + } + } + + async function invokeFunction(fnDef, context, args) { + // Check function input. + const acceptableContext = cast(context, fnDef.context.types); + const fnOutput = await fnDef.fn(acceptableContext, args, handlers); + + // Validate that the function returned the type it said it would. + // This isn't really required, but it keeps function developers honest. + const returnType = getType(fnOutput); + const expectedType = fnDef.type; + if (expectedType && returnType !== expectedType) { + throw new Error( + `Function '${fnDef.name}' should return '${expectedType}',` + + ` actually returned '${returnType}'` + ); + } + + // Validate the function output against the type definition's validate function + const type = typesRegistry.get(fnDef.type); + if (type && type.validate) { + try { + type.validate(fnOutput); + } catch (e) { + throw new Error(`Output of '${fnDef.name}' is not a valid type '${fnDef.type}': ${e}`); + } + } + + return fnOutput; + } + + // Processes the multi-valued AST argument values into arguments that can be passed to the function + async function resolveArgs(fnDef, context, argAsts) { + const argDefs = fnDef.args; + + // Use the non-alias name from the argument definition + const dealiasedArgAsts = reduce( + argAsts, + (argAsts, argAst, argName) => { + const argDef = getByAlias(argDefs, argName); + // TODO: Implement a system to allow for undeclared arguments + if (!argDef) + throw new Error(`Unknown argument '${argName}' passed to function '${fnDef.name}'`); + + argAsts[argDef.name] = (argAsts[argDef.name] || []).concat(argAst); + return argAsts; + }, + {} + ); + + // Check for missing required arguments + each(argDefs, argDef => { + const { aliases, default: argDefault, name: argName, required } = argDef; + if ( + typeof argDefault === 'undefined' && + required && + typeof dealiasedArgAsts[argName] === 'undefined' + ) { + if (aliases.length === 0) { + throw new Error(`${fnDef.name} requires an argument`); + } else { + const errorArg = argName === '_' ? aliases[0] : argName; // use an alias if _ is the missing arg + throw new Error(`${fnDef.name} requires an "${errorArg}" argument`); + } + } + }); + + // Fill in default values from argument definition + const argAstsWithDefaults = reduce( + argDefs, + (argAsts, argDef, argName) => { + if (typeof argAsts[argName] === 'undefined' && typeof argDef.default !== 'undefined') + argAsts[argName] = [fromExpression(argDef.default, 'argument')]; + + return argAsts; + }, + dealiasedArgAsts + ); + + // Create the functions to resolve the argument ASTs into values + // These are what are passed to the actual functions if you opt out of resolving + const resolveArgFns = mapValues(argAstsWithDefaults, (argAsts, argName) => { + return argAsts.map(argAst => { + return async (ctx = context) => { + const newContext = await interpret(argAst, ctx); + if (getType(newContext) === 'error') throw newContext.error; + return cast(newContext, argDefs[argName].types); + }; + }); + }); + + const argNames = keys(resolveArgFns); + + // Actually resolve unless the argument definition says not to + const resolvedArgValues = await Promise.all( + argNames.map(argName => { + const interpretFns = resolveArgFns[argName]; + if (!argDefs[argName].resolve) return interpretFns; + return Promise.all(interpretFns.map(fn => fn())); + }) + ); + + const resolvedMultiArgs = zipObject(argNames, resolvedArgValues); + + // Just return the last unless the argument definition allows multiple + const resolvedArgs = mapValues(resolvedMultiArgs, (argValues, argName) => { + if (argDefs[argName].multi) return argValues; + return last(argValues); + }); + + // Return an object here because the arguments themselves might actually have a 'then' + // function which would be treated as a promise + return { resolvedArgs }; + } +} diff --git a/x-pack/plugins/canvas/common/interpreter/socket_interpret.js b/x-pack/plugins/canvas/common/interpreter/socket_interpret.js new file mode 100644 index 0000000000000..a9ddb8c19c3f9 --- /dev/null +++ b/x-pack/plugins/canvas/common/interpreter/socket_interpret.js @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import uuid from 'uuid/v4'; +import { getByAlias } from '../lib/get_by_alias'; +import { serializeProvider } from '../lib/serialize'; +import { interpretProvider } from './interpret'; + +/* + Returns an interpet function that can shuttle partial ASTs and context between instances of itself over a socket + This is the interpreter that gets called during interactive sessions in the browser and communicates with the + same instance on the backend + + types: a registry of types + functions: registry of known functions + referableFunctions: An array, or a promise for an array, with a list of functions that are available to be defered to + socket: the socket to communicate over +*/ + +export function socketInterpreterProvider({ + types, + functions, + handlers, + referableFunctions, + socket, +}) { + // Return the interpet() function + return interpretProvider({ + types, + functions, + handlers, + + onFunctionNotFound: (ast, context) => { + // Get the name of the function that wasn't found + const functionName = ast.chain[0].function; + + // Get the list of functions that are known elsewhere + return Promise.resolve(referableFunctions).then(referableFunctionMap => { + // Check if the not-found function is in the list of alternatives, if not, throw + if (!getByAlias(referableFunctionMap, functionName)) + throw new Error(`Function not found: ${functionName}`); + + // set a unique message ID so the code knows what response to process + const id = uuid(); + + return new Promise((resolve, reject) => { + const { serialize, deserialize } = serializeProvider(types); + + const listener = resp => { + if (resp.error) { + // cast error strings back into error instances + const err = resp.error instanceof Error ? resp.error : new Error(resp.error); + if (resp.stack) err.stack = resp.stack; + reject(err); + } else { + resolve(deserialize(resp.value)); + } + }; + + socket.once(`resp:${id}`, listener); + + // Go run the remaining AST and context somewhere else, meaning either the browser or the server, depending on + // where this file was loaded + socket.emit('run', { ast, context: serialize(context), id }); + }); + }); + }, + }); +} diff --git a/x-pack/plugins/canvas/common/lib/__tests__/arg.js b/x-pack/plugins/canvas/common/lib/__tests__/arg.js new file mode 100644 index 0000000000000..f8badc67175ac --- /dev/null +++ b/x-pack/plugins/canvas/common/lib/__tests__/arg.js @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { Arg } from '../arg'; + +describe('Arg', () => { + it('sets required to false by default', () => { + const isOptional = new Arg({ + name: 'optional_me', + }); + expect(isOptional.required).to.equal(false); + + const isRequired = new Arg({ + name: 'require_me', + required: true, + }); + expect(isRequired.required).to.equal(true); + }); +}); diff --git a/x-pack/plugins/canvas/common/lib/__tests__/ast.from_expression.js b/x-pack/plugins/canvas/common/lib/__tests__/ast.from_expression.js new file mode 100644 index 0000000000000..631973247dc6c --- /dev/null +++ b/x-pack/plugins/canvas/common/lib/__tests__/ast.from_expression.js @@ -0,0 +1,153 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { fromExpression } from '../ast'; +import { getType } from '../../lib/get_type'; + +describe('ast fromExpression', () => { + describe('invalid expression', () => { + it('throws when empty', () => { + const check = () => fromExpression(''); + expect(check).to.throwException(/Unable to parse expression/i); + }); + + it('throws with invalid expression', () => { + const check = () => fromExpression('wat!'); + expect(check).to.throwException(/Unable to parse expression/i); + }); + }); + + describe('single item expression', () => { + it('is a chain', () => { + const expression = 'whatever'; + expect(fromExpression(expression)).to.have.property('chain'); + }); + + it('is a value', () => { + const expression = '"hello"'; + expect(fromExpression(expression, 'argument')).to.equal('hello'); + }); + + describe('function without arguments', () => { + let expression; + let astObject; + let block; + + beforeEach(() => { + expression = 'csv'; + astObject = fromExpression(expression); + block = astObject.chain[0]; + }); + + it('is a function ', () => { + expect(getType(block)).to.equal('function'); + }); + + it('is csv function', () => { + expect(block.function).to.equal('csv'); + }); + + it('has no arguments', () => { + expect(block.arguments).to.eql({}); + }); + }); + + describe('with string values', () => { + let expression; + let astObject; + let block; + + beforeEach(() => { + expression = 'elasticsearch index="logstash-*" oranges=bananas'; + astObject = fromExpression(expression); + block = astObject.chain[0]; + }); + + it('has arguemnts properties', () => { + expect(block.arguments).not.to.eql({}); + }); + + it('has index argument with string value', () => { + expect(block.arguments).to.have.property('index'); + expect(block.arguments.index).to.eql(['logstash-*']); + }); + + it('has oranges argument with string value', () => { + expect(block.arguments).to.have.property('oranges'); + expect(block.arguments.oranges).to.eql(['bananas']); + }); + }); + + describe('with function value', () => { + let expression; + let astObject; + let block; + + beforeEach(() => { + expression = 'it exampleFunction={someFunction q="do something"}'; + astObject = fromExpression(expression); + block = astObject.chain[0]; + }); + + it('is expression type', () => { + expect(block.arguments).to.have.property('exampleFunction'); + expect(block.arguments.exampleFunction[0]).to.have.property('type', 'expression'); + }); + + it('has expected shape', () => { + expect(block.arguments.exampleFunction).to.eql([ + { + type: 'expression', + chain: [ + { + type: 'function', + function: 'someFunction', + arguments: { + q: ['do something'], + }, + }, + ], + }, + ]); + }); + }); + + describe('with partial value', () => { + let expression; + let astObject; + let block; + + beforeEach(() => { + expression = 'it examplePartial=${somePartialFunction q="do something"}'; + astObject = fromExpression(expression); + block = astObject.chain[0]; + }); + + it('is expression type', () => { + expect(block.arguments).to.have.property('examplePartial'); + expect(block.arguments.examplePartial[0]).to.have.property('type', 'expression'); + }); + + it('has expected shape', () => { + expect(block.arguments.examplePartial).to.eql([ + { + type: 'expression', + chain: [ + { + type: 'function', + function: 'somePartialFunction', + arguments: { + q: ['do something'], + }, + }, + ], + }, + ]); + }); + }); + }); +}); diff --git a/x-pack/plugins/canvas/common/lib/__tests__/ast.to_expression.js b/x-pack/plugins/canvas/common/lib/__tests__/ast.to_expression.js new file mode 100644 index 0000000000000..4b5985832e6ab --- /dev/null +++ b/x-pack/plugins/canvas/common/lib/__tests__/ast.to_expression.js @@ -0,0 +1,609 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { toExpression } from '../ast'; + +describe('ast toExpression', () => { + describe('single expression', () => { + it('throws if not correct type', () => { + const errMsg = 'Expression must be an expression or argument function'; + const astObject = { hello: 'world' }; + expect(() => toExpression(astObject)).to.throwException(errMsg); + }); + + it('throws if expression without chain', () => { + const errMsg = 'Expressions must contain a chain'; + const astObject = { + type: 'expression', + hello: 'world', + }; + expect(() => toExpression(astObject)).to.throwException(errMsg); + }); + + it('throws if arguments type is invalid', () => { + const errMsg = 'Arguments can only be an object'; + const invalidTypes = [null, []]; + + function validate(obj) { + expect(() => toExpression(obj)).to.throwException(errMsg); + } + + for (let i = 0; i < invalidTypes.length; i++) { + const astObject = { + type: 'expression', + chain: [ + { + type: 'function', + function: 'test', + arguments: invalidTypes[i], + }, + ], + }; + + // eslint-disable-next-line no-loop-func + validate(astObject); + } + }); + + it('throws if function arguments type is invalid', () => { + const errMsg = 'Arguments can only be an object'; + const astObject = { + type: 'function', + function: 'pointseries', + arguments: null, + }; + expect(() => toExpression(astObject)).to.throwException(errMsg); + }); + + it('throws on invalid argument type', () => { + const argType = '__invalid__wat__'; + const errMsg = `invalid argument type: ${argType}`; + const astObject = { + type: 'expression', + chain: [ + { + type: 'function', + function: 'test', + arguments: { + test: [ + { + type: argType, + value: 'invalid type', + }, + ], + }, + }, + ], + }; + + expect(() => toExpression(astObject)).to.throwException(errMsg); + }); + + it('throws on expressions without chains', () => { + const errMsg = 'Expressions must contain a chain'; + + const astObject = { + type: 'expression', + chain: [ + { + type: 'function', + function: 'test', + arguments: { + test: [ + { + type: 'expression', + invalid: 'no chain here', + }, + ], + }, + }, + ], + }; + + expect(() => toExpression(astObject)).to.throwException(errMsg); + }); + + it('throws on nameless functions and partials', () => { + const errMsg = 'Functions must have a function name'; + + const astObject = { + type: 'expression', + chain: [ + { + type: 'function', + function: '', + }, + ], + }; + + expect(() => toExpression(astObject)).to.throwException(errMsg); + }); + + it('single expression', () => { + const astObj = { + type: 'expression', + chain: [ + { + type: 'function', + function: 'csv', + arguments: {}, + }, + ], + }; + + const expression = toExpression(astObj); + expect(expression).to.equal('csv'); + }); + + it('single expression with string argument', () => { + const astObj = { + type: 'expression', + chain: [ + { + type: 'function', + function: 'csv', + arguments: { + input: ['stuff\nthings'], + }, + }, + ], + }; + + const expression = toExpression(astObj); + expect(expression).to.equal('csv input="stuff\nthings"'); + }); + + it('single expression string value with a backslash', () => { + const astObj = { + type: 'expression', + chain: [ + { + type: 'function', + function: 'csv', + arguments: { + input: ['slash \\\\ slash'], + }, + }, + ], + }; + + const expression = toExpression(astObj); + expect(expression).to.equal('csv input="slash \\\\\\\\ slash"'); + }); + + it('single expression string value with a double quote', () => { + const astObj = { + type: 'expression', + chain: [ + { + type: 'function', + function: 'csv', + arguments: { + input: ['stuff\nthings\n"such"'], + }, + }, + ], + }; + + const expression = toExpression(astObj); + expect(expression).to.equal('csv input="stuff\nthings\n\\"such\\""'); + }); + + it('single expression with number argument', () => { + const astObj = { + type: 'expression', + chain: [ + { + type: 'function', + function: 'series', + arguments: { + input: [1234], + }, + }, + ], + }; + + const expression = toExpression(astObj); + expect(expression).to.equal('series input=1234'); + }); + + it('single expression with boolean argument', () => { + const astObj = { + type: 'expression', + chain: [ + { + type: 'function', + function: 'series', + arguments: { + input: [true], + }, + }, + ], + }; + + const expression = toExpression(astObj); + expect(expression).to.equal('series input=true'); + }); + + it('single expression with null argument', () => { + const astObj = { + type: 'expression', + chain: [ + { + type: 'function', + function: 'series', + arguments: { + input: [null], + }, + }, + ], + }; + + const expression = toExpression(astObj); + expect(expression).to.equal('series input=null'); + }); + + it('single expression with multiple arguments', () => { + const astObj = { + type: 'expression', + chain: [ + { + type: 'function', + function: 'csv', + arguments: { + input: ['stuff\nthings'], + separator: ['\\n'], + }, + }, + ], + }; + + const expression = toExpression(astObj); + expect(expression).to.equal('csv input="stuff\nthings" separator="\\\\n"'); + }); + + it('single expression with multiple and repeated arguments', () => { + const astObj = { + type: 'expression', + chain: [ + { + type: 'function', + function: 'csv', + arguments: { + input: ['stuff\nthings', 'more,things\nmore,stuff'], + separator: ['\\n'], + }, + }, + ], + }; + + const expression = toExpression(astObj); + expect(expression).to.equal( + 'csv input="stuff\nthings" input="more,things\nmore,stuff" separator="\\\\n"' + ); + }); + + it('single expression with expression argument', () => { + const astObj = { + type: 'expression', + chain: [ + { + type: 'function', + function: 'csv', + arguments: { + calc: [ + { + type: 'expression', + chain: [ + { + type: 'function', + function: 'getcalc', + arguments: {}, + }, + ], + }, + ], + input: ['stuff\nthings'], + }, + }, + ], + }; + + const expression = toExpression(astObj); + expect(expression).to.equal('csv calc={getcalc} input="stuff\nthings"'); + }); + + it('single expression with expression argument', () => { + const astObj = { + type: 'expression', + chain: [ + { + type: 'function', + function: 'csv', + arguments: { + calc: [ + { + type: 'expression', + chain: [ + { + type: 'function', + function: 'partcalc', + arguments: {}, + }, + ], + }, + ], + input: ['stuff\nthings'], + }, + }, + ], + }; + + const expression = toExpression(astObj); + expect(expression).to.equal('csv calc={partcalc} input="stuff\nthings"'); + }); + + it('single expression with expression arguments, with arguments', () => { + const astObj = { + type: 'expression', + chain: [ + { + type: 'function', + function: 'csv', + arguments: { + sep: [ + { + type: 'expression', + chain: [ + { + type: 'function', + function: 'partcalc', + arguments: { + type: ['comma'], + }, + }, + ], + }, + ], + input: ['stuff\nthings'], + break: [ + { + type: 'expression', + chain: [ + { + type: 'function', + function: 'setBreak', + arguments: { + type: ['newline'], + }, + }, + ], + }, + ], + }, + }, + ], + }; + + const expression = toExpression(astObj); + expect(expression).to.equal( + 'csv sep={partcalc type="comma"} input="stuff\nthings" break={setBreak type="newline"}' + ); + }); + }); + + describe('multiple expressions', () => { + it('two chained expressions', () => { + const astObj = { + type: 'expression', + chain: [ + { + type: 'function', + function: 'csv', + arguments: { + input: [ + 'year,make,model,price\n2016,honda,cr-v,23845\n2016,honda,fit,15890,\n2016,honda,civic,18640', + ], + }, + }, + { + type: 'function', + function: 'line', + arguments: { + x: [ + { + type: 'expression', + chain: [ + { + type: 'function', + function: 'distinct', + arguments: { + f: ['year'], + }, + }, + ], + }, + ], + y: [ + { + type: 'expression', + chain: [ + { + type: 'function', + function: 'sum', + arguments: { + f: ['price'], + }, + }, + ], + }, + ], + colors: [ + { + type: 'expression', + chain: [ + { + type: 'function', + function: 'distinct', + arguments: { + f: ['model'], + }, + }, + ], + }, + ], + }, + }, + ], + }; + + const expression = toExpression(astObj); + const expected = [ + 'csv \n input="year,make,model,price', + '2016,honda,cr-v,23845', + '2016,honda,fit,15890,', + '2016,honda,civic,18640"\n| line x={distinct f="year"} y={sum f="price"} colors={distinct f="model"}', + ]; + expect(expression).to.equal(expected.join('\n')); + }); + + it('three chained expressions', () => { + const astObj = { + type: 'expression', + chain: [ + { + type: 'function', + function: 'csv', + arguments: { + input: [ + 'year,make,model,price\n2016,honda,cr-v,23845\n2016,honda,fit,15890,\n2016,honda,civic,18640', + ], + }, + }, + { + type: 'function', + function: 'pointseries', + arguments: { + x: [ + { + type: 'expression', + chain: [ + { + type: 'function', + function: 'distinct', + arguments: { + f: ['year'], + }, + }, + ], + }, + ], + y: [ + { + type: 'expression', + chain: [ + { + type: 'function', + function: 'sum', + arguments: { + f: ['price'], + }, + }, + ], + }, + ], + colors: [ + { + type: 'expression', + chain: [ + { + type: 'function', + function: 'distinct', + arguments: { + f: ['model'], + }, + }, + ], + }, + ], + }, + }, + { + type: 'function', + function: 'line', + arguments: { + pallette: [ + { + type: 'expression', + chain: [ + { + type: 'function', + function: 'getColorPallette', + arguments: { + name: ['elastic'], + }, + }, + ], + }, + ], + }, + }, + ], + }; + + const expression = toExpression(astObj); + const expected = [ + 'csv \n input="year,make,model,price', + '2016,honda,cr-v,23845', + '2016,honda,fit,15890,', + '2016,honda,civic,18640"\n| pointseries x={distinct f="year"} y={sum f="price"} ' + + 'colors={distinct f="model"}\n| line pallette={getColorPallette name="elastic"}', + ]; + expect(expression).to.equal(expected.join('\n')); + }); + }); + + describe('unnamed arguments', () => { + it('only unnamed', () => { + const astObj = { + type: 'expression', + chain: [ + { + type: 'function', + function: 'list', + arguments: { + _: ['one', 'two', 'three'], + }, + }, + ], + }; + + const expression = toExpression(astObj); + expect(expression).to.equal('list "one" "two" "three"'); + }); + + it('named and unnamed', () => { + const astObj = { + type: 'expression', + chain: [ + { + type: 'function', + function: 'both', + arguments: { + named: ['example'], + another: ['item'], + _: ['one', 'two', 'three'], + }, + }, + ], + }; + + const expression = toExpression(astObj); + expect(expression).to.equal('both named="example" another="item" "one" "two" "three"'); + }); + }); +}); diff --git a/x-pack/plugins/canvas/common/lib/__tests__/find_in_object.js b/x-pack/plugins/canvas/common/lib/__tests__/find_in_object.js new file mode 100644 index 0000000000000..b07cd4563a124 --- /dev/null +++ b/x-pack/plugins/canvas/common/lib/__tests__/find_in_object.js @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { findInObject } from '../find_in_object'; + +const findMe = { + foo: { + baz: { + id: 0, + bar: 5, + }, + beer: { + id: 1, + bar: 10, + }, + thing: { + id: 4, + stuff: { + id: 3, + dude: [ + { + bar: 5, + id: 2, + }, + ], + baz: { + bar: 5, + id: 7, + }, + nice: { + bar: 5, + id: 8, + }, + thing: [], + thing2: [[[[]], { bar: 5, id: 6 }]], + }, + }, + }, +}; + +describe('findInObject', () => { + it('Finds object matching a function', () => { + expect(findInObject(findMe, obj => obj.bar === 5).length).to.eql(5); + expect(findInObject(findMe, obj => obj.bar === 5)[0].id).to.eql(0); + expect(findInObject(findMe, obj => obj.bar === 5)[1].id).to.eql(2); + + expect(findInObject(findMe, obj => obj.id === 4).length).to.eql(1); + expect(findInObject(findMe, obj => obj.id === 10000).length).to.eql(0); + }); +}); diff --git a/x-pack/plugins/canvas/common/lib/__tests__/get_by_alias.js b/x-pack/plugins/canvas/common/lib/__tests__/get_by_alias.js new file mode 100644 index 0000000000000..f00092d573d68 --- /dev/null +++ b/x-pack/plugins/canvas/common/lib/__tests__/get_by_alias.js @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { getByAlias } from '../get_by_alias'; + +describe('getByAlias', () => { + const fns = { + foo: { aliases: ['f'] }, + bar: { aliases: ['b'] }, + }; + + it('returns the function by name', () => { + expect(getByAlias(fns, 'foo')).to.be(fns.foo); + expect(getByAlias(fns, 'bar')).to.be(fns.bar); + }); + + it('returns the function by alias', () => { + expect(getByAlias(fns, 'f')).to.be(fns.foo); + expect(getByAlias(fns, 'b')).to.be(fns.bar); + }); + + it('returns the function by case-insensitive name', () => { + expect(getByAlias(fns, 'FOO')).to.be(fns.foo); + expect(getByAlias(fns, 'BAR')).to.be(fns.bar); + }); + + it('returns the function by case-insensitive alias', () => { + expect(getByAlias(fns, 'F')).to.be(fns.foo); + expect(getByAlias(fns, 'B')).to.be(fns.bar); + }); + + it('handles empty strings', () => { + const emptyStringFns = { '': {} }; + const emptyStringAliasFns = { foo: { aliases: [''] } }; + expect(getByAlias(emptyStringFns, '')).to.be(emptyStringFns['']); + expect(getByAlias(emptyStringAliasFns, '')).to.be(emptyStringAliasFns.foo); + }); + + it('handles "undefined" strings', () => { + const emptyStringFns = { undefined: {} }; + const emptyStringAliasFns = { foo: { aliases: ['undefined'] } }; + expect(getByAlias(emptyStringFns, 'undefined')).to.be(emptyStringFns.undefined); + expect(getByAlias(emptyStringAliasFns, 'undefined')).to.be(emptyStringAliasFns.foo); + }); + + it('returns undefined if not found', () => { + expect(getByAlias(fns, 'baz')).to.be(undefined); + }); +}); diff --git a/x-pack/plugins/canvas/common/lib/__tests__/get_colors_from_palette.js b/x-pack/plugins/canvas/common/lib/__tests__/get_colors_from_palette.js new file mode 100644 index 0000000000000..79824edc2e2f7 --- /dev/null +++ b/x-pack/plugins/canvas/common/lib/__tests__/get_colors_from_palette.js @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { getColorsFromPalette } from '../../lib/get_colors_from_palette'; +import { + grayscalePalette, + gradientPalette, +} from '../../../canvas_plugin_src/functions/common/__tests__/fixtures/test_styles'; + +describe('getColorsFromPalette', () => { + it('returns the array of colors from a palette object when gradient is false', () => { + expect(getColorsFromPalette(grayscalePalette, 20)).to.eql(grayscalePalette.colors); + }); + + it('returns an array of colors with equidistant colors with length equal to the number of series when gradient is true', () => { + const result = getColorsFromPalette(gradientPalette, 16); + expect(result) + .to.have.length(16) + .and.to.eql([ + '#ffffff', + '#eeeeee', + '#dddddd', + '#cccccc', + '#bbbbbb', + '#aaaaaa', + '#999999', + '#888888', + '#777777', + '#666666', + '#555555', + '#444444', + '#333333', + '#222222', + '#111111', + '#000000', + ]); + }); +}); diff --git a/x-pack/plugins/canvas/common/lib/__tests__/get_field_type.js b/x-pack/plugins/canvas/common/lib/__tests__/get_field_type.js new file mode 100644 index 0000000000000..cb8f7f68a796c --- /dev/null +++ b/x-pack/plugins/canvas/common/lib/__tests__/get_field_type.js @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { getFieldType } from '../get_field_type'; +import { + emptyTable, + testTable, +} from '../../../canvas_plugin_src/functions/common/__tests__/fixtures/test_tables'; + +describe('getFieldType', () => { + it('returns type of a field in a datatable', () => { + expect(getFieldType(testTable.columns, 'name')).to.be('string'); + expect(getFieldType(testTable.columns, 'time')).to.be('date'); + expect(getFieldType(testTable.columns, 'price')).to.be('number'); + expect(getFieldType(testTable.columns, 'quantity')).to.be('number'); + expect(getFieldType(testTable.columns, 'in_stock')).to.be('boolean'); + }); + it(`returns 'null' if field does not exist in datatable`, () => { + expect(getFieldType(testTable.columns, 'foo')).to.be('null'); + expect(getFieldType(emptyTable.columns, 'foo')).to.be('null'); + }); +}); diff --git a/x-pack/plugins/canvas/common/lib/__tests__/get_legend_config.js b/x-pack/plugins/canvas/common/lib/__tests__/get_legend_config.js new file mode 100644 index 0000000000000..cca2cd73d0faf --- /dev/null +++ b/x-pack/plugins/canvas/common/lib/__tests__/get_legend_config.js @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { getLegendConfig } from '../get_legend_config'; + +describe('getLegendConfig', () => { + describe('show', () => { + it('hides the legend', () => { + expect(getLegendConfig(false, 2)) + .to.only.have.key('show') + .and.to.have.property('show', false); + expect(getLegendConfig(false, 10)) + .to.only.have.key('show') + .and.to.have.property('show', false); + }); + + it('hides the legend when there are less than 2 series', () => { + expect(getLegendConfig(false, 1)) + .to.only.have.key('show') + .and.to.have.property('show', false); + expect(getLegendConfig(true, 1)) + .to.only.have.key('show') + .and.to.have.property('show', false); + }); + + it('shows the legend when there are two or more series', () => { + expect(getLegendConfig('sw', 2)).to.have.property('show', true); + expect(getLegendConfig(true, 5)).to.have.property('show', true); + }); + }); + + describe('position', () => { + it('sets the position of the legend', () => { + expect(getLegendConfig('nw')).to.have.property('position', 'nw'); + expect(getLegendConfig('ne')).to.have.property('position', 'ne'); + expect(getLegendConfig('sw')).to.have.property('position', 'sw'); + expect(getLegendConfig('se')).to.have.property('position', 'se'); + }); + + it("defaults to 'ne'", () => { + expect(getLegendConfig(true)).to.have.property('position', 'ne'); + expect(getLegendConfig('foo')).to.have.property('position', 'ne'); + }); + }); +}); diff --git a/x-pack/plugins/canvas/common/lib/__tests__/httpurl.js b/x-pack/plugins/canvas/common/lib/__tests__/httpurl.js new file mode 100644 index 0000000000000..52ff4ead36a56 --- /dev/null +++ b/x-pack/plugins/canvas/common/lib/__tests__/httpurl.js @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { isValid } from '../httpurl'; + +describe('httpurl.isValid', () => { + it('matches HTTP URLs', () => { + expect(isValid('http://server.com/veggie/hamburger.jpg')).to.be(true); + expect(isValid('https://server.com:4443/veggie/hamburger.jpg')).to.be(true); + expect(isValid('http://user:password@server.com:4443/veggie/hamburger.jpg')).to.be(true); + expect(isValid('http://virtual-machine/veggiehamburger.jpg')).to.be(true); + expect(isValid('https://virtual-machine:44330/veggie.jpg?hamburger')).to.be(true); + expect(isValid('http://192.168.1.50/veggie/hamburger.jpg')).to.be(true); + expect(isValid('https://2600::/veggie/hamburger.jpg')).to.be(true); // ipv6 + expect(isValid('http://2001:4860:4860::8844/veggie/hamburger.jpg')).to.be(true); // ipv6 + }); + it('rejects non-HTTP URLs', () => { + expect(isValid('')).to.be(false); + expect(isValid('http://server.com')).to.be(false); + expect(isValid('file:///Users/programmer/Pictures/hamburger.jpeg')).to.be(false); + expect(isValid('ftp://hostz.com:1111/path/to/image.png')).to.be(false); + expect(isValid('ftp://user:password@host:1111/path/to/image.png')).to.be(false); + expect( + isValid('data:image/gif;base64,R0lGODlhPQBEAPeoAJosM//AwO/AwHVYZ/z595kzAP/s7P+...') + ).to.be(false); + }); +}); diff --git a/x-pack/plugins/canvas/common/lib/__tests__/latest_change.js b/x-pack/plugins/canvas/common/lib/__tests__/latest_change.js new file mode 100644 index 0000000000000..cc5c246b2c074 --- /dev/null +++ b/x-pack/plugins/canvas/common/lib/__tests__/latest_change.js @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { latestChange } from '../latest_change'; + +describe('latestChange', () => { + it('returns a function', () => { + const checker = latestChange(); + expect(checker).to.be.a('function'); + }); + + it('returns null without args', () => { + const checker = latestChange(); + expect(checker()).to.be(null); + }); + + describe('checker function', () => { + let checker; + + beforeEach(() => { + checker = latestChange(1, 2, 3); + }); + + it('returns null if nothing changed', () => { + expect(checker(1, 2, 3)).to.be(null); + }); + + it('returns the latest value', () => { + expect(checker(1, 4, 3)).to.equal(4); + }); + + it('returns the newst value every time', () => { + expect(checker(1, 4, 3)).to.equal(4); + expect(checker(10, 4, 3)).to.equal(10); + expect(checker(10, 4, 30)).to.equal(30); + }); + + it('returns the previous value if nothing changed', () => { + expect(checker(1, 4, 3)).to.equal(4); + expect(checker(1, 4, 3)).to.equal(4); + }); + + it('returns only the first changed value', () => { + expect(checker(2, 4, 3)).to.equal(2); + expect(checker(2, 10, 11)).to.equal(10); + }); + + it('does not check new arguments', () => { + // 4th arg is new, so nothing changed compared to the first state + expect(checker(1, 2, 3, 4)).to.be(null); + expect(checker(1, 2, 3, 5)).to.be(null); + expect(checker(1, 2, 3, 6)).to.be(null); + }); + + it('returns changes with too many args', () => { + expect(checker(20, 2, 3, 4)).to.equal(20); + expect(checker(20, 2, 30, 5)).to.equal(30); + }); + + it('returns undefined values', () => { + expect(checker(1, undefined, 3, 4)).to.be(undefined); + }); + }); +}); diff --git a/x-pack/plugins/canvas/common/lib/__tests__/pivot_object_array.js b/x-pack/plugins/canvas/common/lib/__tests__/pivot_object_array.js new file mode 100644 index 0000000000000..58173c4c5a872 --- /dev/null +++ b/x-pack/plugins/canvas/common/lib/__tests__/pivot_object_array.js @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { pivotObjectArray } from '../pivot_object_array'; + +describe('pivotObjectArray', () => { + let rows; + + beforeEach(() => { + rows = [ + { make: 'honda', model: 'civic', price: '10000' }, + { make: 'toyota', model: 'corolla', price: '12000' }, + { make: 'tesla', model: 'model 3', price: '35000' }, + ]; + }); + + it('converts array of objects', () => { + const data = pivotObjectArray(rows); + + expect(data).to.be.an('object'); + expect(data).to.have.property('make'); + expect(data).to.have.property('model'); + expect(data).to.have.property('price'); + + expect(data.make).to.eql(['honda', 'toyota', 'tesla']); + expect(data.model).to.eql(['civic', 'corolla', 'model 3']); + expect(data.price).to.eql(['10000', '12000', '35000']); + }); + + it('uses passed in column list', () => { + const data = pivotObjectArray(rows, ['price']); + + expect(data).to.be.an('object'); + expect(data).to.eql({ price: ['10000', '12000', '35000'] }); + }); + + it('adds missing columns with undefined values', () => { + const data = pivotObjectArray(rows, ['price', 'missing']); + + expect(data).to.be.an('object'); + expect(data).to.eql({ + price: ['10000', '12000', '35000'], + missing: [undefined, undefined, undefined], + }); + }); + + it('throws when given an invalid column list', () => { + const check = () => pivotObjectArray(rows, [{ name: 'price' }, { name: 'missing' }]); + expect(check).to.throwException('Columns should be an array of strings'); + }); +}); diff --git a/x-pack/plugins/canvas/common/lib/__tests__/registry.js b/x-pack/plugins/canvas/common/lib/__tests__/registry.js new file mode 100644 index 0000000000000..fd19bf0300417 --- /dev/null +++ b/x-pack/plugins/canvas/common/lib/__tests__/registry.js @@ -0,0 +1,190 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { Registry } from '../registry'; + +function validateRegistry(registry, elements) { + it('gets items by lookup property', () => { + expect(registry.get('__test2')).to.eql(elements[1]()); + }); + + it('ignores case when getting items', () => { + expect(registry.get('__TeSt2')).to.eql(elements[1]()); + expect(registry.get('__tESt2')).to.eql(elements[1]()); + }); + + it('gets a shallow clone', () => { + expect(registry.get('__test2')).to.not.equal(elements[1]()); + }); + + it('is null with no match', () => { + expect(registry.get('@@nope_nope')).to.be(null); + }); + + it('returns shallow clone of the whole registry via toJS', () => { + const regAsJs = registry.toJS(); + expect(regAsJs).to.eql({ + __test1: elements[0](), + __test2: elements[1](), + }); + expect(regAsJs.__test1).to.eql(elements[0]()); + expect(regAsJs.__test1).to.not.equal(elements[0]()); + }); + + it('returns shallow clone array via toArray', () => { + const regAsArray = registry.toArray(); + expect(regAsArray).to.be.an(Array); + expect(regAsArray[0]).to.eql(elements[0]()); + expect(regAsArray[0]).to.not.equal(elements[0]()); + }); + + it('resets the registry', () => { + expect(registry.get('__test2')).to.eql(elements[1]()); + registry.reset(); + expect(registry.get('__test2')).to.equal(null); + }); +} + +describe('Registry', () => { + describe('name registry', () => { + const elements = [ + () => ({ + name: '__test1', + prop1: 'some value', + }), + () => ({ + name: '__test2', + prop2: 'some other value', + type: 'unused', + }), + ]; + + const registry = new Registry(); + registry.register(elements[0]); + registry.register(elements[1]); + + validateRegistry(registry, elements); + + it('has a prop of name', () => { + expect(registry.getProp()).to.equal('name'); + }); + + it('throws when object is missing the lookup prop', () => { + const check = () => registry.register(() => ({ hello: 'world' })); + expect(check).to.throwException(/object with a name property/i); + }); + }); + + describe('type registry', () => { + const elements = [ + () => ({ + type: '__test1', + prop1: 'some value', + }), + () => ({ + type: '__test2', + prop2: 'some other value', + name: 'unused', + }), + ]; + + const registry = new Registry('type'); + registry.register(elements[0]); + registry.register(elements[1]); + + validateRegistry(registry, elements); + + it('has a prop of type', () => { + expect(registry.getProp()).to.equal('type'); + }); + + it('throws when object is missing the lookup prop', () => { + const check = () => registry.register(() => ({ hello: 'world' })); + expect(check).to.throwException(/object with a type property/i); + }); + }); + + describe('wrapped registry', () => { + let idx = 0; + const elements = [ + () => ({ + name: '__test1', + prop1: 'some value', + }), + () => ({ + name: '__test2', + prop2: 'some other value', + type: 'unused', + }), + ]; + + class CustomRegistry extends Registry { + wrapper(obj) { + // append custom prop to shallow cloned object, with index as a value + return { + ...obj, + __CUSTOM_PROP__: (idx += 1), + }; + } + } + + const registry = new CustomRegistry(); + registry.register(elements[0]); + registry.register(elements[1]); + + it('contains wrapped elements', () => { + // test for the custom prop on the returned elements + expect(registry.get(elements[0]().name)).to.have.property('__CUSTOM_PROP__', 1); + expect(registry.get(elements[1]().name)).to.have.property('__CUSTOM_PROP__', 2); + }); + }); + + describe('shallow clone full prototype', () => { + const name = 'test_thing'; + let registry; + let thing; + + beforeEach(() => { + registry = new Registry(); + class Base { + constructor(name) { + this.name = name; + } + + baseFunc() { + return 'base'; + } + } + + class Thing extends Base { + doThing() { + return 'done'; + } + } + + thing = () => new Thing(name); + registry.register(thing); + }); + + it('get contains the full prototype', () => { + expect(thing().baseFunc).to.be.a('function'); + expect(registry.get(name).baseFunc).to.be.a('function'); + }); + + it('toJS contains the full prototype', () => { + const val = registry.toJS(); + expect(val[name].baseFunc).to.be.a('function'); + }); + }); + + describe('throws when lookup prop is not a string', () => { + const check = () => new Registry(2); + expect(check).to.throwException(e => { + expect(e.message).to.be('Registry property name must be a string'); + }); + }); +}); diff --git a/x-pack/plugins/canvas/common/lib/__tests__/unquote_string.js b/x-pack/plugins/canvas/common/lib/__tests__/unquote_string.js new file mode 100644 index 0000000000000..465338816f155 --- /dev/null +++ b/x-pack/plugins/canvas/common/lib/__tests__/unquote_string.js @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { unquoteString } from '../unquote_string'; + +describe('unquoteString', () => { + it('removes double quotes', () => { + expect(unquoteString('"hello world"')).to.equal('hello world'); + }); + + it('removes single quotes', () => { + expect(unquoteString("'hello world'")).to.equal('hello world'); + }); + + it('returns unquoted strings', () => { + expect(unquoteString('hello world')).to.equal('hello world'); + expect(unquoteString('hello')).to.equal('hello'); + expect(unquoteString('hello"world')).to.equal('hello"world'); + expect(unquoteString("hello'world")).to.equal("hello'world"); + expect(unquoteString("'hello'world")).to.equal("'hello'world"); + expect(unquoteString('"hello"world')).to.equal('"hello"world'); + }); +}); diff --git a/x-pack/plugins/canvas/common/lib/arg.js b/x-pack/plugins/canvas/common/lib/arg.js new file mode 100644 index 0000000000000..d220e30d23237 --- /dev/null +++ b/x-pack/plugins/canvas/common/lib/arg.js @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { includes } from 'lodash'; + +export function Arg(config) { + if (config.name === '_') throw Error('Arg names must not be _. Use it in aliases instead.'); + this.name = config.name; + this.required = config.required || false; + this.help = config.help; + this.types = config.types || []; + this.default = config.default; + this.aliases = config.aliases || []; + this.multi = config.multi == null ? false : config.multi; + this.resolve = config.resolve == null ? true : config.resolve; + this.accepts = type => { + if (!this.types.length) return true; + return includes(config.types, type); + }; +} diff --git a/x-pack/plugins/canvas/common/lib/ast.js b/x-pack/plugins/canvas/common/lib/ast.js new file mode 100644 index 0000000000000..b31848944e9db --- /dev/null +++ b/x-pack/plugins/canvas/common/lib/ast.js @@ -0,0 +1,130 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getType } from '../lib/get_type'; +import { parse } from './grammar'; + +function getArgumentString(arg, argKey, level = 0) { + const type = getType(arg); + + function maybeArgKey(argKey, argString) { + return argKey == null || argKey === '_' ? argString : `${argKey}=${argString}`; + } + + if (type === 'string') { + // correctly (re)escape double quotes + const escapedArg = arg.replace(/[\\"]/g, '\\$&'); // $& means the whole matched string + return maybeArgKey(argKey, `"${escapedArg}"`); + } + + if (type === 'boolean' || type === 'null' || type === 'number') { + // use values directly + return maybeArgKey(argKey, `${arg}`); + } + + if (type === 'expression') { + // build subexpressions + return maybeArgKey(argKey, `{${getExpression(arg.chain, level + 1)}}`); + } + + // unknown type, throw with type value + throw new Error(`Invalid argument type in AST: ${type}`); +} + +function getExpressionArgs(block, level = 0) { + const args = block.arguments; + const hasValidArgs = typeof args === 'object' && args != null && !Array.isArray(args); + + if (!hasValidArgs) throw new Error('Arguments can only be an object'); + + const argKeys = Object.keys(args); + const MAX_LINE_LENGTH = 80; // length before wrapping arguments + return argKeys.map(argKey => + args[argKey].reduce((acc, arg) => { + const argString = getArgumentString(arg, argKey, level); + const lineLength = acc.split('\n').pop().length; + + // if arg values are too long, move it to the next line + if (level === 0 && lineLength + argString.length > MAX_LINE_LENGTH) + return `${acc}\n ${argString}`; + + // append arg values to existing arg values + if (lineLength > 0) return `${acc} ${argString}`; + + // start the accumulator with the first arg value + return argString; + }, '') + ); +} + +function fnWithArgs(fnName, args) { + if (!args || args.length === 0) return fnName; + return `${fnName} ${args.join(' ')}`; +} + +function getExpression(chain, level = 0) { + if (!chain) throw new Error('Expressions must contain a chain'); + + // break new functions onto new lines if we're not in a nested/sub-expression + const separator = level > 0 ? ' | ' : '\n| '; + + return chain + .map(chainObj => { + const type = getType(chainObj); + + if (type === 'function') { + const fn = chainObj.function; + if (!fn || fn.length === 0) throw new Error('Functions must have a function name'); + + const expArgs = getExpressionArgs(chainObj, level); + + return fnWithArgs(fn, expArgs); + } + }, []) + .join(separator); +} + +export function fromExpression(expression, type = 'expression') { + try { + return parse(String(expression), { startRule: type }); + } catch (e) { + throw new Error(`Unable to parse expression: ${e.message}`); + } +} + +// TODO: OMG This is so bad, we need to talk about the right way to handle bad expressions since some are element based and others not +export function safeElementFromExpression(expression) { + try { + return fromExpression(expression); + } catch (e) { + return fromExpression( + `markdown +"## Crud. +Canvas could not parse this element's expression. I am so sorry this error isn't more useful. I promise it will be soon. + +Thanks for understanding, +#### Management +"` + ); + } +} + +// TODO: Respect the user's existing formatting +export function toExpression(astObj, type = 'expression') { + if (type === 'argument') return getArgumentString(astObj); + + const validType = ['expression', 'function'].includes(getType(astObj)); + if (!validType) throw new Error('Expression must be an expression or argument function'); + + if (getType(astObj) === 'expression') { + if (!Array.isArray(astObj.chain)) throw new Error('Expressions must contain a chain'); + + return getExpression(astObj.chain); + } + + const expArgs = getExpressionArgs(astObj); + return fnWithArgs(astObj.function, expArgs); +} diff --git a/x-pack/plugins/canvas/common/lib/constants.js b/x-pack/plugins/canvas/common/lib/constants.js new file mode 100644 index 0000000000000..1e15dff2da8f8 --- /dev/null +++ b/x-pack/plugins/canvas/common/lib/constants.js @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const CANVAS_TYPE = 'canvas-workpad'; +export const CANVAS_APP = 'canvas'; +export const APP_ROUTE = '/app/canvas'; +export const APP_ROUTE_WORKPAD = `${APP_ROUTE}#/workpad`; +export const API_ROUTE = '/api/canvas'; +export const API_ROUTE_WORKPAD = `${API_ROUTE}/workpad`; +export const LOCALSTORAGE_LASTPAGE = 'canvas:lastpage'; +export const FETCH_TIMEOUT = 30000; // 30 seconds +export const CANVAS_USAGE_TYPE = 'canvas'; diff --git a/x-pack/plugins/canvas/common/lib/datatable/query.js b/x-pack/plugins/canvas/common/lib/datatable/query.js new file mode 100644 index 0000000000000..0051df595a679 --- /dev/null +++ b/x-pack/plugins/canvas/common/lib/datatable/query.js @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export function queryDatatable(datatable, query) { + if (query.size) { + datatable = { + ...datatable, + rows: datatable.rows.slice(0, query.size), + }; + } + + if (query.and) { + // Todo: figure out type of filters + query.and.forEach(filter => { + if (filter.type === 'exactly') { + datatable.rows = datatable.rows.filter(row => { + return row[filter.column] === filter.value; + }); + } + // TODO: Handle timefilter + }); + } + + return datatable; +} diff --git a/x-pack/plugins/canvas/common/lib/dataurl.js b/x-pack/plugins/canvas/common/lib/dataurl.js new file mode 100644 index 0000000000000..d8d995e148d2e --- /dev/null +++ b/x-pack/plugins/canvas/common/lib/dataurl.js @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { fromByteArray } from 'base64-js'; +import mime from 'mime/lite'; + +const dataurlRegex = /^data:([a-z]+\/[a-z0-9-+.]+)(;[a-z-]+=[a-z0-9-]+)?(;([a-z0-9]+))?,/; + +export const imageTypes = ['image/svg+xml', 'image/jpeg', 'image/png', 'image/gif']; + +export function parse(str, withData = false) { + if (typeof str !== 'string') return; + + const matches = str.match(dataurlRegex); + + if (!matches) return; + + const [, mimetype, charset, , encoding] = matches; + + // all types except for svg need to be base64 encoded + const imageTypeIndex = imageTypes.indexOf(matches[1]); + if (imageTypeIndex > 0 && encoding !== 'base64') return; + + return { + mimetype, + encoding, + charset: charset && charset.split('=')[1], + data: !withData ? null : str.split(',')[1], + isImage: imageTypeIndex >= 0, + extension: mime.getExtension(mimetype), + }; +} + +export function isValid(str) { + return dataurlRegex.test(str); +} + +export function encode(data, type = 'text/plain') { + // use FileReader if it's available, like in the browser + if (FileReader) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result); + reader.onerror = err => reject(err); + reader.readAsDataURL(data); + }); + } + + // otherwise fall back to fromByteArray + // note: Buffer doesn't seem to correctly base64 encode binary data + return Promise.resolve(`data:${type};base64,${fromByteArray(data)}`); +} diff --git a/x-pack/plugins/canvas/common/lib/errors.js b/x-pack/plugins/canvas/common/lib/errors.js new file mode 100644 index 0000000000000..4bb72ad72470b --- /dev/null +++ b/x-pack/plugins/canvas/common/lib/errors.js @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// helper to correctly set the prototype of custom error constructor +function setErrorPrototype(CustomError) { + CustomError.prototype = Object.create(Error.prototype, { + constructor: { + value: Error, + enumerable: false, + writable: true, + configurable: true, + }, + }); + + Object.setPrototypeOf(CustomError, Error); +} + +// helper to create a custom error by name +function createError(name) { + function CustomError(...args) { + const instance = new Error(...args); + instance.name = this.name = name; + + if (Error.captureStackTrace) { + Error.captureStackTrace(instance, CustomError); + } else { + Object.defineProperty(this, 'stack', { + get() { + return instance.stack; + }, + }); + } + return instance; + } + + setErrorPrototype(CustomError); + + return CustomError; +} + +export const RenderError = createError('RenderError'); diff --git a/x-pack/plugins/canvas/common/lib/expression_form_handlers.js b/x-pack/plugins/canvas/common/lib/expression_form_handlers.js new file mode 100644 index 0000000000000..2819d068c6edf --- /dev/null +++ b/x-pack/plugins/canvas/common/lib/expression_form_handlers.js @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export class ExpressionFormHandlers { + constructor() { + this.destroy = () => {}; + this.done = () => {}; + } + + onDestroy(fn) { + this.destroy = fn; + } +} diff --git a/x-pack/plugins/canvas/common/lib/fetch.js b/x-pack/plugins/canvas/common/lib/fetch.js new file mode 100644 index 0000000000000..8af72b9aef25b --- /dev/null +++ b/x-pack/plugins/canvas/common/lib/fetch.js @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import axios from 'axios'; +import { FETCH_TIMEOUT } from './constants'; + +export const fetch = axios.create({ + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'kbn-xsrf': 'professionally-crafted-string-of-text', + }, + timeout: FETCH_TIMEOUT, +}); diff --git a/x-pack/plugins/canvas/common/lib/find_in_object.js b/x-pack/plugins/canvas/common/lib/find_in_object.js new file mode 100644 index 0000000000000..2664351629ebb --- /dev/null +++ b/x-pack/plugins/canvas/common/lib/find_in_object.js @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { each } from 'lodash'; + +export function findInObject(o, fn, memo, name) { + memo = memo || []; + if (fn(o, name)) memo.push(o); + if (o != null && typeof o === 'object') each(o, (val, name) => findInObject(val, fn, memo, name)); + + return memo; +} diff --git a/x-pack/plugins/canvas/common/lib/fn.js b/x-pack/plugins/canvas/common/lib/fn.js new file mode 100644 index 0000000000000..70948c76579b3 --- /dev/null +++ b/x-pack/plugins/canvas/common/lib/fn.js @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { mapValues, includes } from 'lodash'; +import { Arg } from './arg'; + +export function Fn(config) { + // Required + this.name = config.name; // Name of function + + // Return type of function. + // This SHOULD be supplied. We use it for UI and autocomplete hinting, + // We may also use it for optimizations in the future. + this.type = config.type; + this.aliases = config.aliases || []; + + // Function to run function (context, args) + this.fn = (...args) => Promise.resolve(config.fn(...args)); + + // Optional + this.help = config.help || ''; // A short help text + this.args = mapValues(config.args || {}, (arg, name) => new Arg({ name, ...arg })); + + this.context = config.context || {}; + + this.accepts = type => { + if (!this.context.types) return true; // If you don't tell us about context, we'll assume you don't care what you get + return includes(this.context.types, type); // Otherwise, check it + }; +} diff --git a/x-pack/plugins/canvas/common/lib/fonts.js b/x-pack/plugins/canvas/common/lib/fonts.js new file mode 100644 index 0000000000000..69b3bf2bb71b7 --- /dev/null +++ b/x-pack/plugins/canvas/common/lib/fonts.js @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const americanTypewriter = { + label: 'American Typewriter', + value: `'American Typewriter', 'Courier New', Courier, Monaco, mono`, +}; +export const arial = { label: 'Arial', value: `Arial, sans-serif` }; +export const baskerville = { + label: 'Baskerville', + value: `Baskerville, Georgia, Garamond, 'Times New Roman', Times, serif`, +}; +export const bookAntiqua = { + label: 'Book Antiqua', + value: `'Book Antiqua', Georgia, Garamond, 'Times New Roman', Times, serif`, +}; +export const brushScript = { + label: 'Brush Script', + value: `'Brush Script MT', 'Comic Sans', sans-serif`, +}; +export const chalkboard = { label: 'Chalkboard', value: `Chalkboard, 'Comic Sans', sans-serif` }; +export const didot = { + label: 'Didot', + value: `Didot, Georgia, Garamond, 'Times New Roman', Times, serif`, +}; +export const futura = { label: 'Futura', value: `Futura, Impact, Helvetica, Arial, sans-serif` }; +export const gillSans = { + label: 'Gill Sans', + value: `'Gill Sans', 'Lucida Grande', 'Lucida Sans Unicode', Verdana, Helvetica, Arial, sans-serif`, +}; +export const helveticaNeue = { + label: 'Helvetica Neue', + value: `'Helvetica Neue', Helvetica, Arial, sans-serif`, +}; +export const hoeflerText = { + label: 'Hoefler Text', + value: `'Hoefler Text', Garamond, Georgia, 'Times New Roman', Times, serif`, +}; +export const lucidaGrande = { + label: 'Lucida Grande', + value: `'Lucida Grande', 'Lucida Sans Unicode', Lucida, Verdana, Helvetica, Arial, sans-serif`, +}; +export const myriad = { label: 'Myriad', value: `Myriad, Helvetica, Arial, sans-serif` }; +export const openSans = { label: 'Open Sans', value: `'Open Sans', Helvetica, Arial, sans-serif` }; +export const optima = { + label: 'Optima', + value: `Optima, 'Lucida Grande', 'Lucida Sans Unicode', Verdana, Helvetica, Arial, sans-serif`, +}; +export const palatino = { + label: 'Palatino', + value: `Palatino, 'Book Antiqua', Georgia, Garamond, 'Times New Roman', Times, serif`, +}; +export const fonts = [ + americanTypewriter, + arial, + baskerville, + bookAntiqua, + brushScript, + chalkboard, + didot, + futura, + gillSans, + helveticaNeue, + hoeflerText, + lucidaGrande, + myriad, + openSans, + optima, + palatino, +]; diff --git a/x-pack/plugins/canvas/common/lib/functions_registry.js b/x-pack/plugins/canvas/common/lib/functions_registry.js new file mode 100644 index 0000000000000..af8e8f0b122d0 --- /dev/null +++ b/x-pack/plugins/canvas/common/lib/functions_registry.js @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Registry } from '../../common/lib/registry'; +import { Fn } from '../lib/fn'; + +class FunctionsRegistry extends Registry { + wrapper(obj) { + return new Fn(obj); + } +} + +export const functionsRegistry = new FunctionsRegistry(); diff --git a/x-pack/plugins/canvas/common/lib/get_by_alias.js b/x-pack/plugins/canvas/common/lib/get_by_alias.js new file mode 100644 index 0000000000000..ff705f07af516 --- /dev/null +++ b/x-pack/plugins/canvas/common/lib/get_by_alias.js @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/** + * This is used for looking up function/argument definitions. It looks through + * the given object for a case-insensitive match, which could be either the + * name of the key itself, or something under the `aliases` property. + */ +export function getByAlias(specs, name) { + const lowerCaseName = name.toLowerCase(); + const key = Object.keys(specs).find(key => { + if (key.toLowerCase() === lowerCaseName) return true; + return (specs[key].aliases || []).some(alias => { + return alias.toLowerCase() === lowerCaseName; + }); + }); + if (typeof key !== undefined) return specs[key]; +} diff --git a/x-pack/plugins/canvas/common/lib/get_colors_from_palette.js b/x-pack/plugins/canvas/common/lib/get_colors_from_palette.js new file mode 100644 index 0000000000000..9b18c1eb95197 --- /dev/null +++ b/x-pack/plugins/canvas/common/lib/get_colors_from_palette.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import chroma from 'chroma-js'; + +export const getColorsFromPalette = (palette, size) => + palette.gradient ? chroma.scale(palette.colors).colors(size) : palette.colors; diff --git a/x-pack/plugins/canvas/common/lib/get_field_type.js b/x-pack/plugins/canvas/common/lib/get_field_type.js new file mode 100644 index 0000000000000..863621b8bccb2 --- /dev/null +++ b/x-pack/plugins/canvas/common/lib/get_field_type.js @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { unquoteString } from './unquote_string'; + +export function getFieldType(columns, field) { + if (!field) return 'null'; + const realField = unquoteString(field); + const column = columns.find(column => column.name === realField); + return column ? column.type : 'null'; +} diff --git a/x-pack/plugins/canvas/common/lib/get_legend_config.js b/x-pack/plugins/canvas/common/lib/get_legend_config.js new file mode 100644 index 0000000000000..85c7733f29c88 --- /dev/null +++ b/x-pack/plugins/canvas/common/lib/get_legend_config.js @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const getLegendConfig = (legend, size) => { + if (!legend || size < 2) return { show: false }; + + const config = { + show: true, + backgroundOpacity: 0, + labelBoxBorderColor: 'transparent', + }; + + const acceptedPositions = ['nw', 'ne', 'sw', 'se']; + + config.position = !legend || acceptedPositions.includes(legend) ? legend : 'ne'; + + return config; +}; diff --git a/x-pack/plugins/canvas/common/lib/get_type.js b/x-pack/plugins/canvas/common/lib/get_type.js new file mode 100644 index 0000000000000..8d2b5a13cb283 --- /dev/null +++ b/x-pack/plugins/canvas/common/lib/get_type.js @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export function getType(node) { + if (node == null) return 'null'; + if (typeof node === 'object') { + if (!node.type) throw new Error('Objects must have a type propery'); + return node.type; + } + + return typeof node; +} diff --git a/x-pack/plugins/canvas/common/lib/grammar.js b/x-pack/plugins/canvas/common/lib/grammar.js new file mode 100644 index 0000000000000..c72312321af60 --- /dev/null +++ b/x-pack/plugins/canvas/common/lib/grammar.js @@ -0,0 +1,1034 @@ +/* + * Generated by PEG.js 0.10.0. + * + * http://pegjs.org/ + */ + +"use strict"; + +function peg$subclass(child, parent) { + function ctor() { this.constructor = child; } + ctor.prototype = parent.prototype; + child.prototype = new ctor(); +} + +function peg$SyntaxError(message, expected, found, location) { + this.message = message; + this.expected = expected; + this.found = found; + this.location = location; + this.name = "SyntaxError"; + + if (typeof Error.captureStackTrace === "function") { + Error.captureStackTrace(this, peg$SyntaxError); + } +} + +peg$subclass(peg$SyntaxError, Error); + +peg$SyntaxError.buildMessage = function(expected, found) { + var DESCRIBE_EXPECTATION_FNS = { + literal: function(expectation) { + return "\"" + literalEscape(expectation.text) + "\""; + }, + + "class": function(expectation) { + var escapedParts = "", + i; + + for (i = 0; i < expectation.parts.length; i++) { + escapedParts += expectation.parts[i] instanceof Array + ? classEscape(expectation.parts[i][0]) + "-" + classEscape(expectation.parts[i][1]) + : classEscape(expectation.parts[i]); + } + + return "[" + (expectation.inverted ? "^" : "") + escapedParts + "]"; + }, + + any: function(expectation) { + return "any character"; + }, + + end: function(expectation) { + return "end of input"; + }, + + other: function(expectation) { + return expectation.description; + } + }; + + function hex(ch) { + return ch.charCodeAt(0).toString(16).toUpperCase(); + } + + function literalEscape(s) { + return s + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\0/g, '\\0') + .replace(/\t/g, '\\t') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/[\x00-\x0F]/g, function(ch) { return '\\x0' + hex(ch); }) + .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return '\\x' + hex(ch); }); + } + + function classEscape(s) { + return s + .replace(/\\/g, '\\\\') + .replace(/\]/g, '\\]') + .replace(/\^/g, '\\^') + .replace(/-/g, '\\-') + .replace(/\0/g, '\\0') + .replace(/\t/g, '\\t') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/[\x00-\x0F]/g, function(ch) { return '\\x0' + hex(ch); }) + .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return '\\x' + hex(ch); }); + } + + function describeExpectation(expectation) { + return DESCRIBE_EXPECTATION_FNS[expectation.type](expectation); + } + + function describeExpected(expected) { + var descriptions = new Array(expected.length), + i, j; + + for (i = 0; i < expected.length; i++) { + descriptions[i] = describeExpectation(expected[i]); + } + + descriptions.sort(); + + if (descriptions.length > 0) { + for (i = 1, j = 1; i < descriptions.length; i++) { + if (descriptions[i - 1] !== descriptions[i]) { + descriptions[j] = descriptions[i]; + j++; + } + } + descriptions.length = j; + } + + switch (descriptions.length) { + case 1: + return descriptions[0]; + + case 2: + return descriptions[0] + " or " + descriptions[1]; + + default: + return descriptions.slice(0, -1).join(", ") + + ", or " + + descriptions[descriptions.length - 1]; + } + } + + function describeFound(found) { + return found ? "\"" + literalEscape(found) + "\"" : "end of input"; + } + + return "Expected " + describeExpected(expected) + " but " + describeFound(found) + " found."; +}; + +function peg$parse(input, options) { + options = options !== void 0 ? options : {}; + + var peg$FAILED = {}, + + peg$startRuleFunctions = { expression: peg$parseexpression, argument: peg$parseargument }, + peg$startRuleFunction = peg$parseexpression, + + peg$c0 = "|", + peg$c1 = peg$literalExpectation("|", false), + peg$c2 = function(first, fn) { return fn; }, + peg$c3 = function(first, rest) { + return { + type: 'expression', + chain: [first].concat(rest) + }; + }, + peg$c4 = peg$otherExpectation("function"), + peg$c5 = function(name, arg_list) { + return { + type: 'function', + function: name, + arguments: arg_list + }; + }, + peg$c6 = "=", + peg$c7 = peg$literalExpectation("=", false), + peg$c8 = function(name, value) { + return { name, value }; + }, + peg$c9 = function(value) { + return { name: '_', value }; + }, + peg$c10 = "$", + peg$c11 = peg$literalExpectation("$", false), + peg$c12 = "{", + peg$c13 = peg$literalExpectation("{", false), + peg$c14 = "}", + peg$c15 = peg$literalExpectation("}", false), + peg$c16 = function(expression) { return expression; }, + peg$c17 = function(arg) { return arg; }, + peg$c18 = function(args) { + return args.reduce((accumulator, { name, value }) => ({ + ...accumulator, + [name]: (accumulator[name] || []).concat(value) + }), {}); + }, + peg$c19 = /^[a-zA-Z0-9_\-]/, + peg$c20 = peg$classExpectation([["a", "z"], ["A", "Z"], ["0", "9"], "_", "-"], false, false), + peg$c21 = function(name) { + return name.join(''); + }, + peg$c22 = peg$otherExpectation("literal"), + peg$c23 = "\"", + peg$c24 = peg$literalExpectation("\"", false), + peg$c25 = function(chars) { return chars.join(''); }, + peg$c26 = "'", + peg$c27 = peg$literalExpectation("'", false), + peg$c28 = function(string) { // this also matches nulls, booleans, and numbers + var result = string.join(''); + // Sort of hacky, but PEG doesn't have backtracking so + // a null/boolean/number rule is hard to read, and performs worse + if (result === 'null') return null; + if (result === 'true') return true; + if (result === 'false') return false; + if (isNaN(Number(result))) return result; // 5bears + return Number(result); + }, + peg$c29 = /^[ \t\r\n]/, + peg$c30 = peg$classExpectation([" ", "\t", "\r", "\n"], false, false), + peg$c31 = "\\", + peg$c32 = peg$literalExpectation("\\", false), + peg$c33 = /^["'(){}<>[\]$`|= \t\n\r]/, + peg$c34 = peg$classExpectation(["\"", "'", "(", ")", "{", "}", "<", ">", "[", "]", "$", "`", "|", "=", " ", "\t", "\n", "\r"], false, false), + peg$c35 = function(sequence) { return sequence; }, + peg$c36 = /^[^"'(){}<>[\]$`|= \t\n\r]/, + peg$c37 = peg$classExpectation(["\"", "'", "(", ")", "{", "}", "<", ">", "[", "]", "$", "`", "|", "=", " ", "\t", "\n", "\r"], true, false), + peg$c38 = /^[^"]/, + peg$c39 = peg$classExpectation(["\""], true, false), + peg$c40 = /^[^']/, + peg$c41 = peg$classExpectation(["'"], true, false), + + peg$currPos = 0, + peg$savedPos = 0, + peg$posDetailsCache = [{ line: 1, column: 1 }], + peg$maxFailPos = 0, + peg$maxFailExpected = [], + peg$silentFails = 0, + + peg$result; + + if ("startRule" in options) { + if (!(options.startRule in peg$startRuleFunctions)) { + throw new Error("Can't start parsing from rule \"" + options.startRule + "\"."); + } + + peg$startRuleFunction = peg$startRuleFunctions[options.startRule]; + } + + function text() { + return input.substring(peg$savedPos, peg$currPos); + } + + function location() { + return peg$computeLocation(peg$savedPos, peg$currPos); + } + + function expected(description, location) { + location = location !== void 0 ? location : peg$computeLocation(peg$savedPos, peg$currPos) + + throw peg$buildStructuredError( + [peg$otherExpectation(description)], + input.substring(peg$savedPos, peg$currPos), + location + ); + } + + function error(message, location) { + location = location !== void 0 ? location : peg$computeLocation(peg$savedPos, peg$currPos) + + throw peg$buildSimpleError(message, location); + } + + function peg$literalExpectation(text, ignoreCase) { + return { type: "literal", text: text, ignoreCase: ignoreCase }; + } + + function peg$classExpectation(parts, inverted, ignoreCase) { + return { type: "class", parts: parts, inverted: inverted, ignoreCase: ignoreCase }; + } + + function peg$anyExpectation() { + return { type: "any" }; + } + + function peg$endExpectation() { + return { type: "end" }; + } + + function peg$otherExpectation(description) { + return { type: "other", description: description }; + } + + function peg$computePosDetails(pos) { + var details = peg$posDetailsCache[pos], p; + + if (details) { + return details; + } else { + p = pos - 1; + while (!peg$posDetailsCache[p]) { + p--; + } + + details = peg$posDetailsCache[p]; + details = { + line: details.line, + column: details.column + }; + + while (p < pos) { + if (input.charCodeAt(p) === 10) { + details.line++; + details.column = 1; + } else { + details.column++; + } + + p++; + } + + peg$posDetailsCache[pos] = details; + return details; + } + } + + function peg$computeLocation(startPos, endPos) { + var startPosDetails = peg$computePosDetails(startPos), + endPosDetails = peg$computePosDetails(endPos); + + return { + start: { + offset: startPos, + line: startPosDetails.line, + column: startPosDetails.column + }, + end: { + offset: endPos, + line: endPosDetails.line, + column: endPosDetails.column + } + }; + } + + function peg$fail(expected) { + if (peg$currPos < peg$maxFailPos) { return; } + + if (peg$currPos > peg$maxFailPos) { + peg$maxFailPos = peg$currPos; + peg$maxFailExpected = []; + } + + peg$maxFailExpected.push(expected); + } + + function peg$buildSimpleError(message, location) { + return new peg$SyntaxError(message, null, null, location); + } + + function peg$buildStructuredError(expected, found, location) { + return new peg$SyntaxError( + peg$SyntaxError.buildMessage(expected, found), + expected, + found, + location + ); + } + + function peg$parseexpression() { + var s0, s1, s2, s3, s4, s5; + + s0 = peg$currPos; + s1 = peg$parsefunction(); + if (s1 !== peg$FAILED) { + s2 = []; + s3 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 124) { + s4 = peg$c0; + peg$currPos++; + } else { + s4 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c1); } + } + if (s4 !== peg$FAILED) { + s5 = peg$parsefunction(); + if (s5 !== peg$FAILED) { + peg$savedPos = s3; + s4 = peg$c2(s1, s5); + s3 = s4; + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + while (s3 !== peg$FAILED) { + s2.push(s3); + s3 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 124) { + s4 = peg$c0; + peg$currPos++; + } else { + s4 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c1); } + } + if (s4 !== peg$FAILED) { + s5 = peg$parsefunction(); + if (s5 !== peg$FAILED) { + peg$savedPos = s3; + s4 = peg$c2(s1, s5); + s3 = s4; + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + } + if (s2 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$c3(s1, s2); + s0 = s1; + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parsefunction() { + var s0, s1, s2, s3; + + peg$silentFails++; + s0 = peg$currPos; + s1 = peg$parsespace(); + if (s1 === peg$FAILED) { + s1 = null; + } + if (s1 !== peg$FAILED) { + s2 = peg$parseidentifier(); + if (s2 !== peg$FAILED) { + s3 = peg$parsearg_list(); + if (s3 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$c5(s2, s3); + s0 = s1; + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + peg$silentFails--; + if (s0 === peg$FAILED) { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c4); } + } + + return s0; + } + + function peg$parseargument_assignment() { + var s0, s1, s2, s3, s4, s5; + + s0 = peg$currPos; + s1 = peg$parseidentifier(); + if (s1 !== peg$FAILED) { + s2 = peg$parsespace(); + if (s2 === peg$FAILED) { + s2 = null; + } + if (s2 !== peg$FAILED) { + if (input.charCodeAt(peg$currPos) === 61) { + s3 = peg$c6; + peg$currPos++; + } else { + s3 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c7); } + } + if (s3 !== peg$FAILED) { + s4 = peg$parsespace(); + if (s4 === peg$FAILED) { + s4 = null; + } + if (s4 !== peg$FAILED) { + s5 = peg$parseargument(); + if (s5 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$c8(s1, s5); + s0 = s1; + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + if (s0 === peg$FAILED) { + s0 = peg$currPos; + s1 = peg$parseargument(); + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$c9(s1); + } + s0 = s1; + } + + return s0; + } + + function peg$parseargument() { + var s0, s1, s2, s3, s4, s5, s6; + + s0 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 36) { + s1 = peg$c10; + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c11); } + } + if (s1 === peg$FAILED) { + s1 = null; + } + if (s1 !== peg$FAILED) { + if (input.charCodeAt(peg$currPos) === 123) { + s2 = peg$c12; + peg$currPos++; + } else { + s2 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c13); } + } + if (s2 !== peg$FAILED) { + s3 = peg$parsespace(); + if (s3 === peg$FAILED) { + s3 = null; + } + if (s3 !== peg$FAILED) { + s4 = peg$parseexpression(); + if (s4 !== peg$FAILED) { + s5 = peg$parsespace(); + if (s5 === peg$FAILED) { + s5 = null; + } + if (s5 !== peg$FAILED) { + if (input.charCodeAt(peg$currPos) === 125) { + s6 = peg$c14; + peg$currPos++; + } else { + s6 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c15); } + } + if (s6 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$c16(s4); + s0 = s1; + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + if (s0 === peg$FAILED) { + s0 = peg$parseliteral(); + } + + return s0; + } + + function peg$parsearg_list() { + var s0, s1, s2, s3, s4; + + s0 = peg$currPos; + s1 = []; + s2 = peg$currPos; + s3 = peg$parsespace(); + if (s3 !== peg$FAILED) { + s4 = peg$parseargument_assignment(); + if (s4 !== peg$FAILED) { + peg$savedPos = s2; + s3 = peg$c17(s4); + s2 = s3; + } else { + peg$currPos = s2; + s2 = peg$FAILED; + } + } else { + peg$currPos = s2; + s2 = peg$FAILED; + } + while (s2 !== peg$FAILED) { + s1.push(s2); + s2 = peg$currPos; + s3 = peg$parsespace(); + if (s3 !== peg$FAILED) { + s4 = peg$parseargument_assignment(); + if (s4 !== peg$FAILED) { + peg$savedPos = s2; + s3 = peg$c17(s4); + s2 = s3; + } else { + peg$currPos = s2; + s2 = peg$FAILED; + } + } else { + peg$currPos = s2; + s2 = peg$FAILED; + } + } + if (s1 !== peg$FAILED) { + s2 = peg$parsespace(); + if (s2 === peg$FAILED) { + s2 = null; + } + if (s2 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$c18(s1); + s0 = s1; + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parseidentifier() { + var s0, s1, s2; + + s0 = peg$currPos; + s1 = []; + if (peg$c19.test(input.charAt(peg$currPos))) { + s2 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s2 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c20); } + } + if (s2 !== peg$FAILED) { + while (s2 !== peg$FAILED) { + s1.push(s2); + if (peg$c19.test(input.charAt(peg$currPos))) { + s2 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s2 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c20); } + } + } + } else { + s1 = peg$FAILED; + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$c21(s1); + } + s0 = s1; + + return s0; + } + + function peg$parseliteral() { + var s0, s1; + + peg$silentFails++; + s0 = peg$parsephrase(); + if (s0 === peg$FAILED) { + s0 = peg$parseunquoted_string_or_number(); + } + peg$silentFails--; + if (s0 === peg$FAILED) { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c22); } + } + + return s0; + } + + function peg$parsephrase() { + var s0, s1, s2, s3; + + s0 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 34) { + s1 = peg$c23; + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c24); } + } + if (s1 !== peg$FAILED) { + s2 = []; + s3 = peg$parsedq_char(); + while (s3 !== peg$FAILED) { + s2.push(s3); + s3 = peg$parsedq_char(); + } + if (s2 !== peg$FAILED) { + if (input.charCodeAt(peg$currPos) === 34) { + s3 = peg$c23; + peg$currPos++; + } else { + s3 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c24); } + } + if (s3 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$c25(s2); + s0 = s1; + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 39) { + s1 = peg$c26; + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c27); } + } + if (s1 !== peg$FAILED) { + s2 = []; + s3 = peg$parsesq_char(); + while (s3 !== peg$FAILED) { + s2.push(s3); + s3 = peg$parsesq_char(); + } + if (s2 !== peg$FAILED) { + if (input.charCodeAt(peg$currPos) === 39) { + s3 = peg$c26; + peg$currPos++; + } else { + s3 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c27); } + } + if (s3 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$c25(s2); + s0 = s1; + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } + + return s0; + } + + function peg$parseunquoted_string_or_number() { + var s0, s1, s2; + + s0 = peg$currPos; + s1 = []; + s2 = peg$parseunquoted(); + if (s2 !== peg$FAILED) { + while (s2 !== peg$FAILED) { + s1.push(s2); + s2 = peg$parseunquoted(); + } + } else { + s1 = peg$FAILED; + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$c28(s1); + } + s0 = s1; + + return s0; + } + + function peg$parsespace() { + var s0, s1; + + s0 = []; + if (peg$c29.test(input.charAt(peg$currPos))) { + s1 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c30); } + } + if (s1 !== peg$FAILED) { + while (s1 !== peg$FAILED) { + s0.push(s1); + if (peg$c29.test(input.charAt(peg$currPos))) { + s1 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c30); } + } + } + } else { + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parseunquoted() { + var s0, s1, s2; + + s0 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 92) { + s1 = peg$c31; + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c32); } + } + if (s1 !== peg$FAILED) { + if (peg$c33.test(input.charAt(peg$currPos))) { + s2 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s2 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c34); } + } + if (s2 === peg$FAILED) { + if (input.charCodeAt(peg$currPos) === 92) { + s2 = peg$c31; + peg$currPos++; + } else { + s2 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c32); } + } + } + if (s2 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$c35(s2); + s0 = s1; + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + if (s0 === peg$FAILED) { + if (peg$c36.test(input.charAt(peg$currPos))) { + s0 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s0 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c37); } + } + } + + return s0; + } + + function peg$parsedq_char() { + var s0, s1, s2; + + s0 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 92) { + s1 = peg$c31; + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c32); } + } + if (s1 !== peg$FAILED) { + if (input.charCodeAt(peg$currPos) === 34) { + s2 = peg$c23; + peg$currPos++; + } else { + s2 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c24); } + } + if (s2 === peg$FAILED) { + if (input.charCodeAt(peg$currPos) === 92) { + s2 = peg$c31; + peg$currPos++; + } else { + s2 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c32); } + } + } + if (s2 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$c35(s2); + s0 = s1; + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + if (s0 === peg$FAILED) { + if (peg$c38.test(input.charAt(peg$currPos))) { + s0 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s0 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c39); } + } + } + + return s0; + } + + function peg$parsesq_char() { + var s0, s1, s2; + + s0 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 92) { + s1 = peg$c31; + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c32); } + } + if (s1 !== peg$FAILED) { + if (input.charCodeAt(peg$currPos) === 39) { + s2 = peg$c26; + peg$currPos++; + } else { + s2 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c27); } + } + if (s2 === peg$FAILED) { + if (input.charCodeAt(peg$currPos) === 92) { + s2 = peg$c31; + peg$currPos++; + } else { + s2 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c32); } + } + } + if (s2 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$c35(s2); + s0 = s1; + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + if (s0 === peg$FAILED) { + if (peg$c40.test(input.charAt(peg$currPos))) { + s0 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s0 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c41); } + } + } + + return s0; + } + + peg$result = peg$startRuleFunction(); + + if (peg$result !== peg$FAILED && peg$currPos === input.length) { + return peg$result; + } else { + if (peg$result !== peg$FAILED && peg$currPos < input.length) { + peg$fail(peg$endExpectation()); + } + + throw peg$buildStructuredError( + peg$maxFailExpected, + peg$maxFailPos < input.length ? input.charAt(peg$maxFailPos) : null, + peg$maxFailPos < input.length + ? peg$computeLocation(peg$maxFailPos, peg$maxFailPos + 1) + : peg$computeLocation(peg$maxFailPos, peg$maxFailPos) + ); + } +} + +module.exports = { + SyntaxError: peg$SyntaxError, + parse: peg$parse +}; diff --git a/x-pack/plugins/canvas/common/lib/grammar.peg b/x-pack/plugins/canvas/common/lib/grammar.peg new file mode 100644 index 0000000000000..9701bec23e2b8 --- /dev/null +++ b/x-pack/plugins/canvas/common/lib/grammar.peg @@ -0,0 +1,97 @@ +/* +* Canvas syntax parser +* +* You MUST use PegJS to generate a .js file to use this. +* Yes, technically you can load this and build the parser in real time, but this makes it annoying +* to share the grammar between the front end and back, so, you know, just generate the parser. +* You shouldn't be futzing around in the grammar very often anyway. +*/ + +/* ----- Expressions ----- */ + +start + = expression + +expression + = first:function rest:('|' fn:function { return fn; })* { + return { + type: 'expression', + chain: [first].concat(rest) + }; + } + +/* ----- Functions ----- */ + +function "function" + = space? name:identifier arg_list:arg_list { + return { + type: 'function', + function: name, + arguments: arg_list + }; + } + +/* ----- Arguments ----- */ + +argument_assignment + = name:identifier space? '=' space? value:argument { + return { name, value }; + } + / value:argument { + return { name: '_', value }; + } + +argument + = '$'? '{' space? expression:expression space? '}' { return expression; } + / literal + +arg_list + = args:(space arg:argument_assignment { return arg; })* space? { + return args.reduce((accumulator, { name, value }) => ({ + ...accumulator, + [name]: (accumulator[name] || []).concat(value) + }), {}); + } + +/* ----- Core types ----- */ + +identifier + = name:[a-zA-Z0-9_-]+ { + return name.join(''); + } + +literal "literal" + = phrase + / unquoted_string_or_number + +phrase + = '"' chars:dq_char* '"' { return chars.join(''); } // double quoted string + / "'" chars:sq_char* "'" { return chars.join(''); } // single quoted string + +unquoted_string_or_number + // Make sure we're not matching the beginning of a search + = string:unquoted+ { // this also matches nulls, booleans, and numbers + var result = string.join(''); + // Sort of hacky, but PEG doesn't have backtracking so + // a null/boolean/number rule is hard to read, and performs worse + if (result === 'null') return null; + if (result === 'true') return true; + if (result === 'false') return false; + if (isNaN(Number(result))) return result; // 5bears + return Number(result); + } + +space + = [\ \t\r\n]+ + +unquoted + = "\\" sequence:([\"'(){}<>\[\]$`|=\ \t\n\r] / "\\") { return sequence; } + / [^"'(){}<>\[\]$`|=\ \t\n\r] + +dq_char + = "\\" sequence:('"' / "\\") { return sequence; } + / [^"] // everything except " + +sq_char + = "\\" sequence:("'" / "\\") { return sequence; } + / [^'] // everything except ' diff --git a/x-pack/plugins/canvas/common/lib/handlebars.js b/x-pack/plugins/canvas/common/lib/handlebars.js new file mode 100644 index 0000000000000..ee1b7b40fde59 --- /dev/null +++ b/x-pack/plugins/canvas/common/lib/handlebars.js @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Hbars from 'handlebars/dist/handlebars'; +import { evaluate } from 'tinymath'; +import { pivotObjectArray } from './pivot_object_array'; + +// example use: {{math rows 'mean(price - cost)' 2}} +Hbars.registerHelper('math', (rows, expression, precision) => { + if (!Array.isArray(rows)) return 'MATH ERROR: first argument must be an array'; + const value = evaluate(expression, pivotObjectArray(rows)); + try { + return precision ? value.toFixed(precision) : value; + } catch (e) { + return value; + } +}); + +export const Handlebars = Hbars; diff --git a/x-pack/plugins/canvas/common/lib/httpurl.js b/x-pack/plugins/canvas/common/lib/httpurl.js new file mode 100644 index 0000000000000..524a4caaecf51 --- /dev/null +++ b/x-pack/plugins/canvas/common/lib/httpurl.js @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// A cheap regex to distinguish an HTTP URL string from a data URL string +const httpurlRegex = /^https?:\/\/\S+(?:[0-9]+)?\/\S{1,}/; + +export function isValid(str) { + return httpurlRegex.test(str); +} diff --git a/x-pack/plugins/canvas/common/lib/latest_change.js b/x-pack/plugins/canvas/common/lib/latest_change.js new file mode 100644 index 0000000000000..3839481dcfabf --- /dev/null +++ b/x-pack/plugins/canvas/common/lib/latest_change.js @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export function latestChange(...firstArgs) { + let oldState = firstArgs; + let prevValue = null; + + return (...args) => { + let found = false; + + const newState = oldState.map((oldVal, i) => { + const val = args[i]; + if (!found && oldVal !== val) { + found = true; + prevValue = val; + } + return val; + }); + + oldState = newState; + + return prevValue; + }; +} diff --git a/x-pack/plugins/canvas/common/lib/missing_asset.js b/x-pack/plugins/canvas/common/lib/missing_asset.js new file mode 100644 index 0000000000000..d47648b44059c --- /dev/null +++ b/x-pack/plugins/canvas/common/lib/missing_asset.js @@ -0,0 +1,3 @@ +/* eslint-disable */ +// CC0, source: https://pixabay.com/en/question-mark-confirmation-question-838656/ +export const missingImage = 'data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPSIxLjEiIHZpZXdCb3g9IjAgMCAzMSAzMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZGVmcz48ZmlsdGVyIGlkPSJiIiB4PSItLjM2IiB5PSItLjM2IiB3aWR0aD0iMS43MiIgaGVpZ2h0PSIxLjcyIiBjb2xvci1pbnRlcnBvbGF0aW9uLWZpbHRlcnM9InNSR0IiPjxmZUdhdXNzaWFuQmx1ciBzdGREZXZpYXRpb249IjIuNDY0Ii8+PC9maWx0ZXI+PGxpbmVhckdyYWRpZW50IGlkPSJhIiB4MT0iMTU5LjM0IiB4Mj0iMTg0LjQ4IiB5MT0iNzI3LjM2IiB5Mj0iNzQ5Ljg5IiBncmFkaWVudFRyYW5zZm9ybT0idHJhbnNsYXRlKC0xMTEuNTMgLTU0OS42OCkgc2NhbGUoLjc3MDAyKSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPjxzdG9wIHN0b3AtY29sb3I9IiNlYmYwZWQiIG9mZnNldD0iMCIvPjxzdG9wIHN0b3AtY29sb3I9IiNmYWZhZmEiIG9mZnNldD0iMSIvPjwvbGluZWFyR3JhZGllbnQ+PC9kZWZzPjxwYXRoIGQ9Ik0xNS40MzcgMi42OTVsMTQuNTA2IDI0LjQ3NkgxLjI4N2wxNC4xNS0yNC40NzZ6IiBmaWxsPSJ1cmwoI2EpIiBzdHJva2U9InJlZCIgc3Ryb2tlLWxpbmVjYXA9InNxdWFyZSIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLXdpZHRoPSIyIi8+PHBhdGggdHJhbnNmb3JtPSJtYXRyaXgoLjgzMTk3IDAgMCAuNTU0NjYgLTc4LjU4MyAtMzgzLjUxKSIgZD0iTTExMS4zMSA3MzEuMmMtMy4yODMtMy45MjUtMy41OTUtNi4xNDgtMi4wMjQtMTAuNDM4IDMuMzM2LTYuMTQ1IDQuNDk2LTguMDY4IDUuNDEtOS40MDUgMS45MDEgNS4xNjIgMi4xMjYgMTkuMTQtMy4zODYgMTkuODQzeiIgZmlsbD0iI2ZmZiIgZmlsbC1vcGFjaXR5PSIuODc2IiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGZpbHRlcj0idXJsKCNiKSIvPjxnIGZpbGwtb3BhY2l0eT0iLjgyIj48cGF0aCBkPSJNMTUuMDQ2IDIwLjIyN2gtLjQxNWMtLjAxMy0uNzQ4LjAyLTEuMzA4LjEwMS0xLjY3OC4wODgtLjM3MS4zMDctLjg4LjY1OC0xLjUyOC4zNTctLjY1NC41OS0xLjE3Ni42OTctMS41NjcuMTE1LS4zOTguMTcyLS44ODcuMTcyLTEuNDY3IDAtLjg5Ni0uMTc1LTEuNTU3LS41MjYtMS45ODItLjM1LS40MjUtLjc2NS0uNjM3LTEuMjQ0LS42MzctLjM2NCAwLS42Ny4wOTgtLjkyLjI5My0uMTg5LjE0OS0uMjgzLjMwNC0uMjgzLjQ2NiAwIC4xMDcuMDY0LjI3Ni4xOTIuNTA1LjI5LjUyLjQzNS45NjEuNDM1IDEuMzI1IDAgLjMzLS4xMTUuNjA3LS4zNDQuODNhMS4xMzggMS4xMzggMCAwIDEtLjg0LjMzM2MtLjM3NyAwLS42OTQtLjEzMS0uOTUtLjM5NC0uMjU2LS4yNy0uMzg0LS42MjQtLjM4NC0xLjA2MiAwLS43OTYuMzQ0LTEuNDk0IDEuMDMxLTIuMDk0LjY4OC0uNiAxLjY0OS0uOSAyLjg4My0uOSAxLjMwOCAwIDIuMzAyLjMxNCAyLjk4My45NC42ODguNjIxIDEuMDMyIDEuMzczIDEuMDMyIDIuMjU2IDAgLjY0LS4xNzYgMS4yMzQtLjUyNiAxLjc4LS4zNTEuNTQtMS4wMjkgMS4xNC0yLjAzMyAxLjgtLjY3NC40NDUtMS4xMi44NDMtMS4zMzUgMS4xOTQtLjIxLjM0My0uMzM3Ljg3My0uMzg0IDEuNTg3bS0uMTEyIDEuNDc3Yy40NTIgMCAuODM2LjE1OCAxLjE1My40NzUuMzE3LjMxNy40NzYuNzAxLjQ3NiAxLjE1MyAwIC40NTItLjE1OS44NC0uNDc2IDEuMTYzYTEuNTcgMS41NyAwIDAgMS0xLjE1My40NzUgMS41NyAxLjU3IDAgMCAxLTEuMTUzLS40NzUgMS42MDQgMS42MDQgMCAwIDEtLjQ3NS0xLjE2M2MwLS40NTIuMTU5LS44MzYuNDc1LTEuMTUzYTEuNTcgMS41NyAwIDAgMSAxLjE1My0uNDc1IiBmaWxsPSIjZmZmIiBmaWxsLW9wYWNpdHk9Ii40ODYiLz48cGF0aCBkPSJNMTUuMzI3IDIwLjUwOGgtLjQxNWMtLjAxMy0uNzQ4LjAyLTEuMzA4LjEwMS0xLjY3OC4wODgtLjM3MS4zMDctLjg4LjY1OC0xLjUyOC4zNTctLjY1NC41OS0xLjE3Ni42OTctMS41NjcuMTE1LS4zOTguMTcyLS44ODcuMTcyLTEuNDY2IDAtLjg5Ny0uMTc1LTEuNTU4LS41MjYtMS45ODMtLjM1LS40MjQtLjc2NS0uNjM3LTEuMjQzLS42MzctLjM2NSAwLS42NzEuMDk4LS45Mi4yOTMtLjE5LjE0OS0uMjg0LjMwNC0uMjg0LjQ2NiAwIC4xMDguMDY0LjI3Ni4xOTIuNTA1LjI5LjUyLjQzNS45NjEuNDM1IDEuMzI1IDAgLjMzLS4xMTUuNjA3LS4zNDQuODNhMS4xMzggMS4xMzggMCAwIDEtLjg0LjMzM2MtLjM3NyAwLS42OTQtLjEzMS0uOTUtLjM5NC0uMjU2LS4yNy0uMzg0LS42MjQtLjM4NC0xLjA2MiAwLS43OTYuMzQ0LTEuNDkzIDEuMDMxLTIuMDk0LjY4OC0uNiAxLjY0OS0uOSAyLjg4My0uOSAxLjMwOCAwIDIuMzAyLjMxNCAyLjk4My45NC42ODguNjIxIDEuMDMyIDEuMzczIDEuMDMyIDIuMjU2IDAgLjY0LS4xNzYgMS4yMzQtLjUyNiAxLjc4LS4zNS41NC0xLjAyOCAxLjE0LTIuMDMzIDEuOC0uNjc0LjQ0NS0uODUzLjg0My0xLjA2OCAxLjE5NC0uMjEuMzQzLS4zMzcuODczLS4zODUgMS41ODdtLS4zNzggMS40NzdjLjQ1MiAwIC44MzYuMTU4IDEuMTUzLjQ3NS4zMTcuMzE3LjQ3Ni43MDEuNDc2IDEuMTUzIDAgLjQ1Mi0uMTU5Ljg0LS40NzYgMS4xNjNhMS41NyAxLjU3IDAgMCAxLTEuMTUzLjQ3NiAxLjU3IDEuNTcgMCAwIDEtMS4xNTMtLjQ3NiAxLjYwNCAxLjYwNCAwIDAgMS0uNDc1LTEuMTYzYzAtLjQ1Mi4xNTktLjgzNi40NzUtMS4xNTNhMS41NyAxLjU3IDAgMCAxIDEuMTUzLS40NzUiIGZpbGwtb3BhY2l0eT0iLjgyIi8+PC9nPjwvc3ZnPg=='; diff --git a/x-pack/plugins/canvas/common/lib/palettes.js b/x-pack/plugins/canvas/common/lib/palettes.js new file mode 100644 index 0000000000000..3fe977ec3862c --- /dev/null +++ b/x-pack/plugins/canvas/common/lib/palettes.js @@ -0,0 +1,152 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* + This should be pluggable +*/ + +export const palettes = { + paul_tor_14: { + colors: [ + '#882E72', + '#B178A6', + '#D6C1DE', + '#1965B0', + '#5289C7', + '#7BAFDE', + '#4EB265', + '#90C987', + '#CAE0AB', + '#F7EE55', + '#F6C141', + '#F1932D', + '#E8601C', + '#DC050C', + ], + gradient: false, + }, + paul_tor_21: { + colors: [ + '#771155', + '#AA4488', + '#CC99BB', + '#114477', + '#4477AA', + '#77AADD', + '#117777', + '#44AAAA', + '#77CCCC', + '#117744', + '#44AA77', + '#88CCAA', + '#777711', + '#AAAA44', + '#DDDD77', + '#774411', + '#AA7744', + '#DDAA77', + '#771122', + '#AA4455', + '#DD7788', + ], + gradient: false, + }, + earth_tones: { + colors: [ + '#842113', + '#984d23', + '#32221c', + '#739379', + '#dab150', + '#4d2521', + '#716c49', + '#bb3918', + '#7e5436', + '#c27c34', + '#72392e', + '#8f8b7e', + ], + gradient: false, + }, + canvas: { + colors: [ + '#01A4A4', + '#CC6666', + '#D0D102', + '#616161', + '#00A1CB', + '#32742C', + '#F18D05', + '#113F8C', + '#61AE24', + '#D70060', + ], + gradient: false, + }, + color_blind: { + colors: [ + '#1ea593', + '#2b70f7', + '#ce0060', + '#38007e', + '#fca5d3', + '#f37020', + '#e49e29', + '#b0916f', + '#7b000b', + '#34130c', + ], + gradient: false, + }, + elastic_teal: { + colors: ['#C5FAF4', '#0F6259'], + gradient: true, + }, + elastic_blue: { + colors: ['#7ECAE3', '#003A4D'], + gradient: true, + }, + elastic_yellow: { + colors: ['#FFE674', '#4D3F00'], + gradient: true, + }, + elastic_pink: { + colors: ['#FEA8D5', '#531E3A'], + gradient: true, + }, + elastic_green: { + colors: ['#D3FB71', '#131A00'], + gradient: true, + }, + elastic_orange: { + colors: ['#FFC68A', '#7B3F00'], + gradient: true, + }, + elastic_purple: { + colors: ['#CCC7DF', '#130351'], + gradient: true, + }, + green_blue_red: { + colors: ['#D3FB71', '#7ECAE3', '#f03b20'], + gradient: true, + }, + yellow_green: { + colors: ['#f7fcb9', '#addd8e', '#31a354'], + gradient: true, + }, + yellow_blue: { + colors: ['#edf8b1', '#7fcdbb', '#2c7fb8'], + gradient: true, + }, + yellow_red: { + colors: ['#ffeda0', '#feb24c', '#f03b20'], + gradient: true, + }, + instagram: { + colors: ['#833ab4', '#fd1d1d', '#fcb045'], + gradient: true, + }, +}; diff --git a/x-pack/plugins/canvas/common/lib/pivot_object_array.js b/x-pack/plugins/canvas/common/lib/pivot_object_array.js new file mode 100644 index 0000000000000..974e327a46e7b --- /dev/null +++ b/x-pack/plugins/canvas/common/lib/pivot_object_array.js @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { map, zipObject } from 'lodash'; + +const isString = val => typeof val === 'string'; + +export function pivotObjectArray(rows, columns) { + const columnNames = columns || Object.keys(rows[0]); + if (!columnNames.every(isString)) throw new Error('Columns should be an array of strings'); + + const columnValues = map(columnNames, name => map(rows, name)); + return zipObject(columnNames, columnValues); +} diff --git a/x-pack/plugins/canvas/common/lib/registry.js b/x-pack/plugins/canvas/common/lib/registry.js new file mode 100644 index 0000000000000..accabae4bc5eb --- /dev/null +++ b/x-pack/plugins/canvas/common/lib/registry.js @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import clone from 'lodash.clone'; + +export class Registry { + constructor(prop = 'name') { + if (typeof prop !== 'string') throw new Error('Registry property name must be a string'); + this._prop = prop; + this._indexed = new Object(); + } + + wrapper(obj) { + return obj; + } + + register(fn) { + if (typeof fn !== 'function') throw new Error(`Register requires an function`); + + const obj = fn(); + + if (typeof obj !== 'object' || !obj[this._prop]) + throw new Error(`Registered functions must return an object with a ${this._prop} property`); + + this._indexed[obj[this._prop].toLowerCase()] = this.wrapper(obj); + } + + toJS() { + return Object.keys(this._indexed).reduce((acc, key) => { + acc[key] = this.get(key); + return acc; + }, {}); + } + + toArray() { + return Object.keys(this._indexed).map(key => this.get(key)); + } + + get(name) { + if (name === undefined) return null; + const lowerCaseName = name.toLowerCase(); + return this._indexed[lowerCaseName] ? clone(this._indexed[lowerCaseName]) : null; + } + + getProp() { + return this._prop; + } + + reset() { + this._indexed = new Object(); + } +} diff --git a/x-pack/plugins/canvas/common/lib/resolve_dataurl.js b/x-pack/plugins/canvas/common/lib/resolve_dataurl.js new file mode 100644 index 0000000000000..c292fc7deb2c7 --- /dev/null +++ b/x-pack/plugins/canvas/common/lib/resolve_dataurl.js @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get } from 'lodash'; +import { isValid } from '../../common/lib/url'; +import { missingImage } from '../../common/lib/missing_asset'; + +/* + * NOTE: args.dataurl can come as an expression here. + * For example: + * [{"type":"expression","chain":[{"type":"function","function":"asset","arguments":{"_":["..."]}}]}] + */ +export const resolveFromArgs = (args, defaultDataurl = null) => { + const dataurl = get(args, 'dataurl.0', null); + return isValid(dataurl) ? dataurl : defaultDataurl; +}; + +export const resolveWithMissingImage = (img, alt = null) => { + if (isValid(img)) return img; + if (img === null) return alt; + return missingImage; +}; diff --git a/x-pack/plugins/canvas/common/lib/serialize.js b/x-pack/plugins/canvas/common/lib/serialize.js new file mode 100644 index 0000000000000..0786f6f06b3a3 --- /dev/null +++ b/x-pack/plugins/canvas/common/lib/serialize.js @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get, identity } from 'lodash'; +import { getType } from '../lib/get_type'; + +export function serializeProvider(types) { + return { + serialize: provider('serialize'), + deserialize: provider('deserialize'), + }; + + function provider(key) { + return context => { + const type = getType(context); + const typeDef = types[type]; + const fn = get(typeDef, key) || identity; + return fn(context); + }; + } +} diff --git a/x-pack/plugins/canvas/common/lib/type.js b/x-pack/plugins/canvas/common/lib/type.js new file mode 100644 index 0000000000000..d917750e3848e --- /dev/null +++ b/x-pack/plugins/canvas/common/lib/type.js @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// All types must be universal and be castable on the client or on the server +import { get } from 'lodash'; +import { getType } from '../lib/get_type'; + +// TODO: Currently all casting functions must be syncronous. + +export function Type(config) { + // Required + this.name = config.name; + + // Optional + this.help = config.help || ''; // A short help text + + // Optional type validation, useful for checking function output + this.validate = config.validate || function validate() {}; + + // Optional + this.create = config.create; + + // Optional serialization (used when passing context around client/server) + this.serialize = config.serialize; + this.deserialize = config.deserialize; + + const getToFn = type => get(config, ['to', type]) || get(config, ['to', '*']); + const getFromFn = type => get(config, ['from', type]) || get(config, ['from', '*']); + + this.castsTo = type => typeof getToFn(type) === 'function'; + this.castsFrom = type => typeof getFromFn(type) === 'function'; + + this.to = (node, toTypeName, types) => { + const typeName = getType(node); + if (typeName !== this.name) + throw new Error(`Can not cast object of type '${typeName}' using '${this.name}'`); + else if (!this.castsTo(toTypeName)) + throw new Error(`Can not cast '${typeName}' to '${toTypeName}'`); + + return getToFn(toTypeName)(node, types); + }; + + this.from = (node, types) => { + const typeName = getType(node); + if (!this.castsFrom(typeName)) throw new Error(`Can not cast '${this.name}' from ${typeName}`); + + return getFromFn(typeName)(node, types); + }; +} diff --git a/x-pack/plugins/canvas/common/lib/types_registry.js b/x-pack/plugins/canvas/common/lib/types_registry.js new file mode 100644 index 0000000000000..3d2bb65e9fa0f --- /dev/null +++ b/x-pack/plugins/canvas/common/lib/types_registry.js @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Registry } from '../../common/lib/registry'; +import { Type } from '../../common/lib/type'; + +class TypesRegistry extends Registry { + wrapper(obj) { + return new Type(obj); + } +} + +export const typesRegistry = new TypesRegistry(); diff --git a/x-pack/plugins/canvas/common/lib/unquote_string.js b/x-pack/plugins/canvas/common/lib/unquote_string.js new file mode 100644 index 0000000000000..0760960c53cb2 --- /dev/null +++ b/x-pack/plugins/canvas/common/lib/unquote_string.js @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const unquoteString = str => { + if (/^"/.test(str)) return str.replace(/^"(.+(?="$))"$/, '$1'); + if (/^'/.test(str)) return str.replace(/^'(.+(?='$))'$/, '$1'); + return str; +}; diff --git a/x-pack/plugins/canvas/common/lib/url.js b/x-pack/plugins/canvas/common/lib/url.js new file mode 100644 index 0000000000000..b5efb09f8711d --- /dev/null +++ b/x-pack/plugins/canvas/common/lib/url.js @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { isValid as isValidDataUrl } from '../../common/lib/dataurl'; +import { isValid as isValidHttpUrl } from '../../common/lib/httpurl'; + +export function isValid(url) { + return isValidDataUrl(url) || isValidHttpUrl(url); +} diff --git a/x-pack/plugins/canvas/images/canvas.png b/x-pack/plugins/canvas/images/canvas.png new file mode 100644 index 0000000000000..7dd9ec3c63249 Binary files /dev/null and b/x-pack/plugins/canvas/images/canvas.png differ diff --git a/x-pack/plugins/canvas/images/canvas.svg b/x-pack/plugins/canvas/images/canvas.svg new file mode 100644 index 0000000000000..3c2d2ddf9eda1 --- /dev/null +++ b/x-pack/plugins/canvas/images/canvas.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/x-pack/plugins/canvas/images/canvas_blank.svg b/x-pack/plugins/canvas/images/canvas_blank.svg new file mode 100644 index 0000000000000..10f1651ba2804 --- /dev/null +++ b/x-pack/plugins/canvas/images/canvas_blank.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/x-pack/plugins/canvas/images/icon_black.svg b/x-pack/plugins/canvas/images/icon_black.svg new file mode 100644 index 0000000000000..37a07aa39986a --- /dev/null +++ b/x-pack/plugins/canvas/images/icon_black.svg @@ -0,0 +1,106 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + diff --git a/x-pack/plugins/canvas/images/logo.gif b/x-pack/plugins/canvas/images/logo.gif new file mode 100644 index 0000000000000..1cd3b57e84871 Binary files /dev/null and b/x-pack/plugins/canvas/images/logo.gif differ diff --git a/x-pack/plugins/canvas/index.js b/x-pack/plugins/canvas/index.js new file mode 100644 index 0000000000000..3404f8c573788 --- /dev/null +++ b/x-pack/plugins/canvas/index.js @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { resolve } from 'path'; +import init from './init'; +import { mappings } from './server/mappings'; +import { CANVAS_APP } from './common/lib/constants'; + +export function canvas(kibana) { + return new kibana.Plugin({ + id: CANVAS_APP, + configPrefix: 'xpack.canvas', + require: ['kibana', 'elasticsearch', 'xpack_main'], + publicDir: resolve(__dirname, 'public'), + uiExports: { + app: { + title: 'Canvas', + description: 'Data driven workpads', + icon: 'plugins/canvas/icon.svg', + main: 'plugins/canvas/app', + styleSheetPath: `${__dirname}/public/style/index.scss`, + }, + hacks: [ + // window.onerror override + 'plugins/canvas/lib/window_error_handler.js', + + // Client side plugins go here + 'plugins/canvas/lib/load_expression_types.js', + 'plugins/canvas/lib/load_transitions.js', + ], + mappings, + }, + + config: Joi => { + return Joi.object({ + enabled: Joi.boolean().default(true), + indexPrefix: Joi.string().default('.canvas'), + }).default(); + }, + + init, + }); +} diff --git a/x-pack/plugins/canvas/init.js b/x-pack/plugins/canvas/init.js new file mode 100644 index 0000000000000..bc41ff43c8ebb --- /dev/null +++ b/x-pack/plugins/canvas/init.js @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { routes } from './server/routes'; +import { functionsRegistry } from './common/lib/functions_registry'; +import { commonFunctions } from './common/functions'; +import { loadServerPlugins } from './server/lib/load_server_plugins'; +import { registerCanvasUsageCollector } from './server/usage'; + +export default function(server /*options*/) { + server.injectUiAppVars('canvas', () => { + const config = server.config(); + const basePath = config.get('server.basePath'); + const reportingBrowserType = config.get('xpack.reporting.capture.browser.type'); + + return { + kbnIndex: config.get('kibana.index'), + esShardTimeout: config.get('elasticsearch.shardTimeout'), + esApiVersion: config.get('elasticsearch.apiVersion'), + serverFunctions: functionsRegistry.toArray(), + basePath, + reportingBrowserType, + }; + }); + + // There are some common functions that use private APIs, load them here + commonFunctions.forEach(func => functionsRegistry.register(func)); + + loadServerPlugins().then(() => routes(server)); + registerCanvasUsageCollector(server); +} diff --git a/x-pack/plugins/canvas/package.json b/x-pack/plugins/canvas/package.json new file mode 100644 index 0000000000000..f6b55e0340779 --- /dev/null +++ b/x-pack/plugins/canvas/package.json @@ -0,0 +1,24 @@ +{ + "name": "canvas", + "version": "7.0.0-alpha1", + "description": "Data driven workpads for kibana", + "main": "index.js", + "kibana": { + "version": "kibana" + }, + "scripts": { + "kbn": "node ../../../scripts/kbn", + "start": "../../node_modules/.bin/gulp canvas:dev", + "lint": "node ../../../scripts/eslint '*.js' '__tests__/**/*.js' 'tasks/**/*.js' 'server/**/*.js' 'common/**/*.js' 'public/**/*.{js,jsx}' 'canvas_plugin_src/**/*.{js,jsx}' --ignore-pattern 'canvas_plugin_src/lib/flot-charts/**/*' --ignore-pattern 'common/lib/grammar.js' --ignore-pattern 'canvas_plugin/**/*'", + "test": "../../node_modules/.bin/gulp canvas:test", + "test:common": "../../node_modules/.bin/gulp canvas:test:common", + "test:server": "../../node_modules/.bin/gulp canvas:test:server", + "test:browser": "../../node_modules/.bin/gulp canvas:test:browser", + "test:plugins": "../../node_modules/.bin/gulp canvas:test:plugins", + "test:dev": "../../node_modules/.bin/gulp canvas:test:dev", + "peg:build": "../../node_modules/.bin/gulp canvas:peg:build", + "peg:dev": "../../node_modules/.bin/gulp canvas:peg:dev", + "build": "echo Run build from x-pack root; exit 1", + "build:plugins": "../../node_modules/.bin/gulp canvas:plugins:build" + } +} diff --git a/x-pack/plugins/canvas/public/.eslintrc b/x-pack/plugins/canvas/public/.eslintrc new file mode 100644 index 0000000000000..5c9fc4c255341 --- /dev/null +++ b/x-pack/plugins/canvas/public/.eslintrc @@ -0,0 +1,2 @@ +env: + browser: true diff --git a/x-pack/plugins/canvas/public/__tests__/setup.js b/x-pack/plugins/canvas/public/__tests__/setup.js new file mode 100644 index 0000000000000..1792397d0614e --- /dev/null +++ b/x-pack/plugins/canvas/public/__tests__/setup.js @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import enzyme from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; + +// this will run before any code that's inside a describe block +// so we can use it to set up whatever we need for our browser tests +before(() => { + // configure enzume + enzyme.configure({ adapter: new Adapter() }); +}); diff --git a/x-pack/plugins/canvas/public/angular/config/index.js b/x-pack/plugins/canvas/public/angular/config/index.js new file mode 100644 index 0000000000000..8a8f3e3cfce51 --- /dev/null +++ b/x-pack/plugins/canvas/public/angular/config/index.js @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import './state_management'; // Requires 6.2.0+ +import './location_provider'; diff --git a/x-pack/plugins/canvas/public/angular/config/location_provider.js b/x-pack/plugins/canvas/public/angular/config/location_provider.js new file mode 100644 index 0000000000000..40b2081ad007d --- /dev/null +++ b/x-pack/plugins/canvas/public/angular/config/location_provider.js @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { uiModules } from 'ui/modules'; + +// disable angular's location provider +const app = uiModules.get('apps/canvas'); +app.config($locationProvider => { + $locationProvider.html5Mode({ + enabled: false, + requireBase: false, + rewriteLinks: false, + }); +}); diff --git a/x-pack/plugins/canvas/public/angular/config/state_management.js b/x-pack/plugins/canvas/public/angular/config/state_management.js new file mode 100644 index 0000000000000..e6c1da418b6fc --- /dev/null +++ b/x-pack/plugins/canvas/public/angular/config/state_management.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { uiModules } from 'ui/modules'; + +// disable the kibana state management +const app = uiModules.get('apps/canvas'); +app.config(stateManagementConfigProvider => { + stateManagementConfigProvider.disable(); +}); diff --git a/x-pack/plugins/canvas/public/angular/controllers/canvas.js b/x-pack/plugins/canvas/public/angular/controllers/canvas.js new file mode 100644 index 0000000000000..1a484b6f294a4 --- /dev/null +++ b/x-pack/plugins/canvas/public/angular/controllers/canvas.js @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { Provider } from 'react-redux'; +import { App } from '../../components/app'; + +export function CanvasRootController(canvasStore, $scope, $element) { + const domNode = $element[0]; + + render( + + + , + domNode + ); + + $scope.$on('$destroy', () => { + unmountComponentAtNode(domNode); + }); +} diff --git a/x-pack/plugins/canvas/public/angular/controllers/index.js b/x-pack/plugins/canvas/public/angular/controllers/index.js new file mode 100644 index 0000000000000..0d14bb4d9d4b5 --- /dev/null +++ b/x-pack/plugins/canvas/public/angular/controllers/index.js @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { CanvasRootController } from './canvas'; diff --git a/x-pack/plugins/canvas/public/angular/services/index.js b/x-pack/plugins/canvas/public/angular/services/index.js new file mode 100644 index 0000000000000..f15372a7a483a --- /dev/null +++ b/x-pack/plugins/canvas/public/angular/services/index.js @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import './store'; diff --git a/x-pack/plugins/canvas/public/angular/services/store.js b/x-pack/plugins/canvas/public/angular/services/store.js new file mode 100644 index 0000000000000..181f9bf75cbb0 --- /dev/null +++ b/x-pack/plugins/canvas/public/angular/services/store.js @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { uiModules } from 'ui/modules'; +import uniqBy from 'lodash.uniqby'; +import { createStore } from '../../state/store'; +import { getInitialState } from '../../state/initial_state'; +import { functionsRegistry } from '../../../common/lib/functions_registry'; + +const app = uiModules.get('apps/canvas'); +app.service('canvasStore', (kbnVersion, basePath, reportingBrowserType, serverFunctions) => { + // this is effectively what happens to serverFunctions + const clientFunctionsPOJO = JSON.parse(JSON.stringify(functionsRegistry.toArray())); + const functionDefinitions = uniqBy(serverFunctions.concat(clientFunctionsPOJO), 'name'); + + const initialState = getInitialState(); + + // Set the defaults from Kibana plugin + initialState.app = { + kbnVersion, + basePath, + reportingBrowserType, + functionDefinitions, + ready: false, + }; + + const store = createStore(initialState); + + // TODO: debugging, remove this + window.canvasStore = store; + + return store; +}); diff --git a/x-pack/plugins/canvas/public/app.js b/x-pack/plugins/canvas/public/app.js new file mode 100644 index 0000000000000..0880e01e4a523 --- /dev/null +++ b/x-pack/plugins/canvas/public/app.js @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import 'ui/autoload/all'; +import chrome from 'ui/chrome'; +import './angular/config'; +import './angular/services'; +import { CanvasRootController } from './angular/controllers'; + +// Import the uiExports that the application uses +import 'uiExports/visTypes'; +import 'uiExports/visResponseHandlers'; +import 'uiExports/visRequestHandlers'; +import 'uiExports/visEditorTypes'; +import 'uiExports/savedObjectTypes'; +import 'uiExports/spyModes'; +import 'uiExports/fieldFormats'; + +// load the application +chrome.setRootController('canvas', CanvasRootController); diff --git a/x-pack/plugins/canvas/public/apps/export/export/export_app.js b/x-pack/plugins/canvas/public/apps/export/export/export_app.js new file mode 100644 index 0000000000000..ebbcf130b94b8 --- /dev/null +++ b/x-pack/plugins/canvas/public/apps/export/export/export_app.js @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { WorkpadPage } from '../../../components/workpad_page'; +import { Link } from '../../../components/link'; + +export class ExportApp extends React.PureComponent { + static propTypes = { + workpad: PropTypes.shape({ + id: PropTypes.string.isRequired, + pages: PropTypes.array.isRequired, + }).isRequired, + selectedPageId: PropTypes.string.isRequired, + initializeWorkpad: PropTypes.func.isRequired, + }; + + componentDidMount() { + this.props.initializeWorkpad(); + } + + render() { + const { workpad, selectedPageId } = this.props; + const { pages, height, width } = workpad; + const activePage = pages.find(page => page.id === selectedPageId); + + return ( +
+
+
+ + Edit Workpad + +
+
+ +
+
+
+ ); + } +} diff --git a/x-pack/plugins/canvas/public/apps/export/export/export_app.scss b/x-pack/plugins/canvas/public/apps/export/export/export_app.scss new file mode 100644 index 0000000000000..6b78c858bf636 --- /dev/null +++ b/x-pack/plugins/canvas/public/apps/export/export/export_app.scss @@ -0,0 +1,7 @@ +.canvasExport { + .canvasPage { + position: relative; + overflow: hidden; + box-shadow: none; + } +} diff --git a/x-pack/plugins/canvas/public/apps/export/export/index.js b/x-pack/plugins/canvas/public/apps/export/export/index.js new file mode 100644 index 0000000000000..16ca644160c0a --- /dev/null +++ b/x-pack/plugins/canvas/public/apps/export/export/index.js @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { connect } from 'react-redux'; +import { compose, branch, renderComponent } from 'recompose'; +import { initializeWorkpad } from '../../../state/actions/workpad'; +import { getWorkpad, getSelectedPage } from '../../../state/selectors/workpad'; +import { LoadWorkpad } from './load_workpad'; +import { ExportApp as Component } from './export_app'; + +const mapStateToProps = state => ({ + workpad: getWorkpad(state), + selectedPageId: getSelectedPage(state), +}); + +const mapDispatchToProps = dispatch => ({ + initializeWorkpad() { + dispatch(initializeWorkpad()); + }, +}); + +const branches = [branch(({ workpad }) => workpad == null, renderComponent(LoadWorkpad))]; + +export const ExportApp = compose( + connect( + mapStateToProps, + mapDispatchToProps + ), + ...branches +)(Component); diff --git a/x-pack/plugins/canvas/public/apps/export/export/load_workpad.js b/x-pack/plugins/canvas/public/apps/export/export/load_workpad.js new file mode 100644 index 0000000000000..388bf00723f82 --- /dev/null +++ b/x-pack/plugins/canvas/public/apps/export/export/load_workpad.js @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +export const LoadWorkpad = () =>
Load a workpad...
; diff --git a/x-pack/plugins/canvas/public/apps/export/index.js b/x-pack/plugins/canvas/public/apps/export/index.js new file mode 100644 index 0000000000000..c60db89aa8fc8 --- /dev/null +++ b/x-pack/plugins/canvas/public/apps/export/index.js @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { routes } from './routes'; +export { ExportApp } from './export'; diff --git a/x-pack/plugins/canvas/public/apps/export/routes.js b/x-pack/plugins/canvas/public/apps/export/routes.js new file mode 100644 index 0000000000000..7ec6a59c7ecbe --- /dev/null +++ b/x-pack/plugins/canvas/public/apps/export/routes.js @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as workpadService from '../../lib/workpad_service'; +import { setWorkpad } from '../../state/actions/workpad'; +import { fetchAllRenderables } from '../../state/actions/elements'; +import { setPage } from '../../state/actions/pages'; +import { setAssets } from '../../state/actions/assets'; +import { ExportApp } from './export'; + +export const routes = [ + { + path: '/export/workpad', + children: [ + { + name: 'exportWorkpad', + path: '/pdf/:id/page/:page', + action: dispatch => async ({ params, router }) => { + // load workpad if given a new id via url param + const fetchedWorkpad = await workpadService.get(params.id); + const pageNumber = parseInt(params.page, 10); + + // redirect to home app on invalid workpad id or page number + if (fetchedWorkpad == null && isNaN(pageNumber)) return router.redirectTo('home'); + + const { assets, ...workpad } = fetchedWorkpad; + dispatch(setAssets(assets)); + dispatch(setWorkpad(workpad, { loadPages: false })); + dispatch(setPage(pageNumber - 1)); + dispatch(fetchAllRenderables({ onlyActivePage: true })); + }, + meta: { + component: ExportApp, + }, + }, + ], + }, +]; diff --git a/x-pack/plugins/canvas/public/apps/home/home_app.js b/x-pack/plugins/canvas/public/apps/home/home_app.js new file mode 100644 index 0000000000000..8c9b52da932c2 --- /dev/null +++ b/x-pack/plugins/canvas/public/apps/home/home_app.js @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiPage, EuiPageBody, EuiPageContent } from '@elastic/eui'; +import { WorkpadLoader } from '../../components/workpad_loader'; + +export const HomeApp = () => ( + + + + {}} /> + + + +); diff --git a/x-pack/plugins/canvas/public/apps/home/home_app.scss b/x-pack/plugins/canvas/public/apps/home/home_app.scss new file mode 100644 index 0000000000000..63dbc18cec247 --- /dev/null +++ b/x-pack/plugins/canvas/public/apps/home/home_app.scss @@ -0,0 +1,3 @@ +.canvasHomeApp__modalHeader { + padding-right: 24px; +} diff --git a/x-pack/plugins/canvas/public/apps/home/index.js b/x-pack/plugins/canvas/public/apps/home/index.js new file mode 100644 index 0000000000000..ecc9d28730ac6 --- /dev/null +++ b/x-pack/plugins/canvas/public/apps/home/index.js @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { routes } from './routes'; +export { HomeApp } from './home_app'; diff --git a/x-pack/plugins/canvas/public/apps/home/routes.js b/x-pack/plugins/canvas/public/apps/home/routes.js new file mode 100644 index 0000000000000..b713d0823dea4 --- /dev/null +++ b/x-pack/plugins/canvas/public/apps/home/routes.js @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { HomeApp } from './home_app'; + +export const routes = [ + { + name: 'home', + path: '/', + meta: { + component: HomeApp, + }, + }, +]; diff --git a/x-pack/plugins/canvas/public/apps/index.js b/x-pack/plugins/canvas/public/apps/index.js new file mode 100644 index 0000000000000..c014349ca18da --- /dev/null +++ b/x-pack/plugins/canvas/public/apps/index.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as home from './home'; +import * as workpad from './workpad'; +import * as exp from './export'; + +export const routes = [].concat(workpad.routes, home.routes, exp.routes); + +export const apps = [workpad.WorkpadApp, home.HomeApp, exp.ExportApp]; diff --git a/x-pack/plugins/canvas/public/apps/workpad/index.js b/x-pack/plugins/canvas/public/apps/workpad/index.js new file mode 100644 index 0000000000000..0601199f9112e --- /dev/null +++ b/x-pack/plugins/canvas/public/apps/workpad/index.js @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { routes } from './routes'; +export { WorkpadApp } from './workpad_app'; diff --git a/x-pack/plugins/canvas/public/apps/workpad/routes.js b/x-pack/plugins/canvas/public/apps/workpad/routes.js new file mode 100644 index 0000000000000..98607304ba1ab --- /dev/null +++ b/x-pack/plugins/canvas/public/apps/workpad/routes.js @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as workpadService from '../../lib/workpad_service'; +import { notify } from '../../lib/notify'; +import { getDefaultWorkpad } from '../../state/defaults'; +import { setWorkpad } from '../../state/actions/workpad'; +import { setAssets, resetAssets } from '../../state/actions/assets'; +import { gotoPage } from '../../state/actions/pages'; +import { getWorkpad } from '../../state/selectors/workpad'; +import { WorkpadApp } from './workpad_app'; + +export const routes = [ + { + path: '/workpad', + children: [ + { + name: 'createWorkpad', + path: '/create', + action: dispatch => async ({ router }) => { + const newWorkpad = getDefaultWorkpad(); + try { + await workpadService.create(newWorkpad); + dispatch(setWorkpad(newWorkpad)); + dispatch(resetAssets()); + router.redirectTo('loadWorkpad', { id: newWorkpad.id, page: 1 }); + } catch (err) { + notify.error(err, { title: `Couldn't create workpad` }); + } + }, + meta: { + component: WorkpadApp, + }, + }, + { + name: 'loadWorkpad', + path: '/:id(/page/:page)', + action: (dispatch, getState) => async ({ params, router }) => { + // load workpad if given a new id via url param + const currentWorkpad = getWorkpad(getState()); + if (params.id !== currentWorkpad.id) { + try { + const fetchedWorkpad = await workpadService.get(params.id); + + const { assets, ...workpad } = fetchedWorkpad; + dispatch(setWorkpad(workpad)); + dispatch(setAssets(assets)); + } catch (err) { + notify.error(err, { title: `Couldn't load workpad with ID` }); + return router.redirectTo('home'); + } + } + + // fetch the workpad again, to get changes + const workpad = getWorkpad(getState()); + const pageNumber = parseInt(params.page, 10); + + // no page provided, append current page to url + if (isNaN(pageNumber)) + return router.redirectTo('loadWorkpad', { id: workpad.id, page: workpad.page + 1 }); + + // set the active page using the number provided in the url + const pageIndex = pageNumber - 1; + if (pageIndex !== workpad.page) dispatch(gotoPage(pageIndex)); + }, + meta: { + component: WorkpadApp, + }, + }, + ], + }, +]; diff --git a/x-pack/plugins/canvas/public/apps/workpad/workpad_app/index.js b/x-pack/plugins/canvas/public/apps/workpad/workpad_app/index.js new file mode 100644 index 0000000000000..90db6af85154e --- /dev/null +++ b/x-pack/plugins/canvas/public/apps/workpad/workpad_app/index.js @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { connect } from 'react-redux'; +import { compose, branch, renderComponent } from 'recompose'; +import { initializeWorkpad } from '../../../state/actions/workpad'; +import { selectElement } from '../../../state/actions/transient'; +import { getEditing, getAppReady } from '../../../state/selectors/app'; +import { getWorkpad } from '../../../state/selectors/workpad'; +import { LoadWorkpad } from './load_workpad'; +import { WorkpadApp as Component } from './workpad_app'; + +const mapStateToProps = state => { + const appReady = getAppReady(state); + + return { + editing: getEditing(state), + appReady: typeof appReady === 'object' ? appReady : { ready: appReady }, + workpad: getWorkpad(state), + }; +}; + +const mapDispatchToProps = dispatch => ({ + initializeWorkpad() { + dispatch(initializeWorkpad()); + }, + deselectElement(ev) { + ev && ev.stopPropagation(); + dispatch(selectElement(null)); + }, +}); + +const branches = [branch(({ workpad }) => workpad == null, renderComponent(LoadWorkpad))]; + +export const WorkpadApp = compose( + connect( + mapStateToProps, + mapDispatchToProps + ), + ...branches +)(Component); diff --git a/x-pack/plugins/canvas/public/apps/workpad/workpad_app/load_workpad.js b/x-pack/plugins/canvas/public/apps/workpad/workpad_app/load_workpad.js new file mode 100644 index 0000000000000..388bf00723f82 --- /dev/null +++ b/x-pack/plugins/canvas/public/apps/workpad/workpad_app/load_workpad.js @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +export const LoadWorkpad = () =>
Load a workpad...
; diff --git a/x-pack/plugins/canvas/public/apps/workpad/workpad_app/workpad_app.js b/x-pack/plugins/canvas/public/apps/workpad/workpad_app/workpad_app.js new file mode 100644 index 0000000000000..1d5dcc5d20553 --- /dev/null +++ b/x-pack/plugins/canvas/public/apps/workpad/workpad_app/workpad_app.js @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { Sidebar } from '../../../components/sidebar'; +import { Toolbar } from '../../../components/toolbar'; +import { Workpad } from '../../../components/workpad'; +import { WorkpadHeader } from '../../../components/workpad_header'; + +export class WorkpadApp extends React.PureComponent { + static propTypes = { + editing: PropTypes.bool, + deselectElement: PropTypes.func, + initializeWorkpad: PropTypes.func.isRequired, + }; + + componentDidMount() { + this.props.initializeWorkpad(); + } + + render() { + const { editing, deselectElement } = this.props; + + return ( +
+
+
+
+
+ +
+ +
+ {/* NOTE: canvasWorkpadContainer is used for exporting */} +
+ +
+
+
+ + {editing && ( +
+ +
+ )} +
+ + {editing ? ( +
+ +
+ ) : null} +
+
+ ); + } +} diff --git a/x-pack/plugins/canvas/public/apps/workpad/workpad_app/workpad_app.scss b/x-pack/plugins/canvas/public/apps/workpad/workpad_app/workpad_app.scss new file mode 100644 index 0000000000000..f0e62358b3405 --- /dev/null +++ b/x-pack/plugins/canvas/public/apps/workpad/workpad_app/workpad_app.scss @@ -0,0 +1,71 @@ +.canvasLayout { + display: flex; + background: $euiColorLightestShade; + flex-grow: 1; + overflow: hidden; +} + +.canvasLayout__rows { + display: flex; + flex-direction: column; + flex-grow: 1; + max-height: 100vh; +} + +.canvasLayout__cols { + display: flex; + align-items: stretch; + flex-grow: 1; +} + +.canvasLayout__stage { + flex-grow: 1; + flex-basis: 0%; + display: flex; + flex-direction: column; +} + +.canvasLayout__stageHeader { + flex-grow: 0; + flex-basis: auto; + padding: $euiSizeM $euiSize $euiSizeS $euiSize; +} + +.canvasLayout__stageContent { + flex-grow: 1; + flex-basis: 0%; + position: relative; +} + +.canvasLayout__stageContentOverflow { + @include euiScrollBar; + + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + overflow: auto; + display: flex; + align-items: center; +} + +.canvasLayout__sidebar { + flex-grow: 0; + flex-basis: auto; + width: 350px; + background: darken($euiColorLightestShade, 2%); + display: flex; + + .euiPanel { + margin-bottom: $euiSizeS; + } +} + +.canvasLayout__footer { + flex-grow: 0; + flex-basis: auto; + width: 100%; + background-color: $euiColorLightestShade; + z-index: $euiZNavigation; +} diff --git a/x-pack/plugins/canvas/public/components/alignment_guide/alignment_guide.js b/x-pack/plugins/canvas/public/components/alignment_guide/alignment_guide.js new file mode 100644 index 0000000000000..2ef7e2d59b48b --- /dev/null +++ b/x-pack/plugins/canvas/public/components/alignment_guide/alignment_guide.js @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import aero from '../../lib/aeroelastic'; + +export const AlignmentGuide = ({ transformMatrix, width, height }) => { + const newStyle = { + width, + height, + marginLeft: -width / 2, + marginTop: -height / 2, + background: 'magenta', + position: 'absolute', + transform: aero.dom.matrixToCSS(transformMatrix), + }; + return ( +
+ ); +}; + +AlignmentGuide.propTypes = { + transformMatrix: PropTypes.arrayOf(PropTypes.number).isRequired, + width: PropTypes.number.isRequired, + height: PropTypes.number.isRequired, +}; diff --git a/x-pack/plugins/canvas/public/components/alignment_guide/alignment_guide.scss b/x-pack/plugins/canvas/public/components/alignment_guide/alignment_guide.scss new file mode 100644 index 0000000000000..27f06b42df453 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/alignment_guide/alignment_guide.scss @@ -0,0 +1,4 @@ +.canvasAlignmentGuide { + transform-origin: center center; /* the default, only for clarity */ + transform-style: preserve-3d; +} diff --git a/x-pack/plugins/canvas/public/components/alignment_guide/index.js b/x-pack/plugins/canvas/public/components/alignment_guide/index.js new file mode 100644 index 0000000000000..6793e0151759b --- /dev/null +++ b/x-pack/plugins/canvas/public/components/alignment_guide/index.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pure } from 'recompose'; +import { AlignmentGuide as Component } from './alignment_guide'; + +export const AlignmentGuide = pure(Component); diff --git a/x-pack/plugins/canvas/public/components/app/app.js b/x-pack/plugins/canvas/public/components/app/app.js new file mode 100644 index 0000000000000..10c7e9dd1b111 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/app/app.js @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { routes } from '../../apps'; +import { shortcutManager } from '../../lib/shortcut_manager'; +import { Router } from '../router'; + +export class App extends React.PureComponent { + static childContextTypes = { + shortcuts: PropTypes.object.isRequired, + }; + + static propTypes = { + appState: PropTypes.object.isRequired, + setAppReady: PropTypes.func.isRequired, + setAppError: PropTypes.func.isRequired, + onRouteChange: PropTypes.func.isRequired, + }; + + getChildContext() { + return { shortcuts: shortcutManager }; + } + + renderError = () => { + console.error(this.props.appState); + + return ( +
+
Canvas failed to load :(
+
Message: {this.props.appState.message}
+
+ ); + }; + + render() { + if (this.props.appState instanceof Error) return this.renderError(); + + return ( +
+ this.props.setAppReady(true)} + onError={err => this.props.setAppError(err)} + /> +
+ ); + } +} diff --git a/x-pack/plugins/canvas/public/components/app/index.js b/x-pack/plugins/canvas/public/components/app/index.js new file mode 100644 index 0000000000000..60b77f3e749c4 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/app/index.js @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { connect } from 'react-redux'; +import { compose, withProps } from 'recompose'; +import { getAppReady } from '../../state/selectors/app'; +import { appReady, appError } from '../../state/actions/app'; +import { trackRouteChange } from './track_route_change'; +import { App as Component } from './app'; + +const mapStateToProps = state => { + // appReady could be an error object + const appState = getAppReady(state); + + return { + appState: typeof appState === 'object' ? appState : { ready: appState }, + }; +}; + +const mapDispatchToProps = { + setAppReady: appReady, + setAppError: appError, +}; + +export const App = compose( + connect( + mapStateToProps, + mapDispatchToProps + ), + withProps(() => ({ + onRouteChange: trackRouteChange, + })) +)(Component); diff --git a/x-pack/plugins/canvas/public/components/app/track_route_change.js b/x-pack/plugins/canvas/public/components/app/track_route_change.js new file mode 100644 index 0000000000000..3930a09f7645f --- /dev/null +++ b/x-pack/plugins/canvas/public/components/app/track_route_change.js @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import chrome from 'ui/chrome'; +import { absoluteToParsedUrl } from 'ui/url/absolute_to_parsed_url'; +import { get } from 'lodash'; +import { getWindow } from '../../lib/get_window'; +import { CANVAS_APP } from '../../../common/lib/constants'; + +export function trackRouteChange() { + const basePath = chrome.getBasePath(); + // storage.set(LOCALSTORAGE_LASTPAGE, pathname); + chrome.trackSubUrlForApp( + CANVAS_APP, + absoluteToParsedUrl(get(getWindow(), 'location.href'), basePath) + ); +} diff --git a/x-pack/plugins/canvas/public/components/arg_add/arg_add.js b/x-pack/plugins/canvas/public/components/arg_add/arg_add.js new file mode 100644 index 0000000000000..2d6d7d1046fdd --- /dev/null +++ b/x-pack/plugins/canvas/public/components/arg_add/arg_add.js @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { + EuiDescriptionList, + EuiDescriptionListTitle, + EuiDescriptionListDescription, +} from '@elastic/eui'; + +export const ArgAdd = ({ onValueAdd, displayName, help }) => { + return ( + + ); +}; + +ArgAdd.propTypes = { + displayName: PropTypes.string, + help: PropTypes.string, + onValueAdd: PropTypes.func, +}; diff --git a/x-pack/plugins/canvas/public/components/arg_add/arg_add.scss b/x-pack/plugins/canvas/public/components/arg_add/arg_add.scss new file mode 100644 index 0000000000000..39dd0fdd735e8 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/arg_add/arg_add.scss @@ -0,0 +1,17 @@ +.canvasArg__add { + padding: $euiSizeM; + text-align: left; + width: 100%; + + &:not(:last-child) { + border-bottom: $euiBorderThin; + } + + &:hover { + background-color: $euiColorLightestShade; + + label { + color: $euiColorDarkestShade; + } + } +} diff --git a/x-pack/plugins/canvas/public/components/arg_add/index.js b/x-pack/plugins/canvas/public/components/arg_add/index.js new file mode 100644 index 0000000000000..9a89a48edcef6 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/arg_add/index.js @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pure } from 'recompose'; + +import { ArgAdd as Component } from './arg_add'; + +export const ArgAdd = pure(Component); diff --git a/x-pack/plugins/canvas/public/components/arg_add_popover/arg_add_popover.js b/x-pack/plugins/canvas/public/components/arg_add_popover/arg_add_popover.js new file mode 100644 index 0000000000000..535afdb2be1e3 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/arg_add_popover/arg_add_popover.js @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { PropTypes } from 'prop-types'; +import { EuiButtonIcon } from '@elastic/eui'; +import { Popover } from '../popover'; +import { ArgAdd } from '../arg_add'; + +export const ArgAddPopover = ({ options }) => { + const button = handleClick => ( + + ); + + return ( + + {({ closePopover }) => + options.map(opt => ( + { + opt.onValueAdd(); + closePopover(); + }} + /> + )) + } + + ); +}; + +ArgAddPopover.propTypes = { + options: PropTypes.array.isRequired, +}; diff --git a/x-pack/plugins/canvas/public/components/arg_add_popover/arg_add_popover.scss b/x-pack/plugins/canvas/public/components/arg_add_popover/arg_add_popover.scss new file mode 100644 index 0000000000000..f2cdb1444ef23 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/arg_add_popover/arg_add_popover.scss @@ -0,0 +1,3 @@ +.canvasArg__addPopover { + width: 250px; +} diff --git a/x-pack/plugins/canvas/public/components/arg_add_popover/index.js b/x-pack/plugins/canvas/public/components/arg_add_popover/index.js new file mode 100644 index 0000000000000..7321c366ab94e --- /dev/null +++ b/x-pack/plugins/canvas/public/components/arg_add_popover/index.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pure } from 'recompose'; +import { ArgAddPopover as Component } from './arg_add_popover'; + +export const ArgAddPopover = pure(Component); diff --git a/x-pack/plugins/canvas/public/components/arg_form/advanced_failure.js b/x-pack/plugins/canvas/public/components/arg_form/advanced_failure.js new file mode 100644 index 0000000000000..2bd779de759d9 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/arg_form/advanced_failure.js @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { compose, withProps, withPropsOnChange } from 'recompose'; +import { EuiForm, EuiTextArea, EuiButton, EuiButtonEmpty, EuiFormRow } from '@elastic/eui'; +import { createStatefulPropHoc } from '../../components/enhance/stateful_prop'; +import { fromExpression, toExpression } from '../../../common/lib/ast'; + +export const AdvancedFailureComponent = props => { + const { + onValueChange, + defaultValue, + argExpression, + updateArgExpression, + resetErrorState, + valid, + argId, + } = props; + + const valueChange = ev => { + ev.preventDefault(); + + resetErrorState(); // when setting a new value, attempt to reset the error state + + if (valid) return onValueChange(fromExpression(argExpression.trim(), 'argument')); + }; + + const confirmReset = ev => { + ev.preventDefault(); + resetErrorState(); // when setting a new value, attempt to reset the error state + onValueChange(fromExpression(defaultValue, 'argument')); + }; + + return ( + + + + +
+ valueChange(e)} size="s" type="submit"> + Apply + + {defaultValue && + defaultValue.length && ( + + Reset + + )} +
+
+ ); +}; + +AdvancedFailureComponent.propTypes = { + defaultValue: PropTypes.string, + onValueChange: PropTypes.func.isRequired, + argExpression: PropTypes.string.isRequired, + updateArgExpression: PropTypes.func.isRequired, + resetErrorState: PropTypes.func.isRequired, + valid: PropTypes.bool.isRequired, + argId: PropTypes.string.isRequired, +}; + +export const AdvancedFailure = compose( + withProps(({ argValue }) => ({ + argExpression: toExpression(argValue, 'argument'), + })), + createStatefulPropHoc('argExpression', 'updateArgExpression'), + withPropsOnChange(['argExpression'], ({ argExpression }) => ({ + valid: (function() { + try { + fromExpression(argExpression, 'argument'); + return true; + } catch (e) { + return false; + } + })(), + })) +)(AdvancedFailureComponent); + +AdvancedFailure.propTypes = { + argValue: PropTypes.any.isRequired, +}; diff --git a/x-pack/plugins/canvas/public/components/arg_form/arg_form.js b/x-pack/plugins/canvas/public/components/arg_form/arg_form.js new file mode 100644 index 0000000000000..c7b4582d6c766 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/arg_form/arg_form.js @@ -0,0 +1,149 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { compose, branch, renderComponent } from 'recompose'; +import { ErrorBoundary } from '../enhance/error_boundary'; +import { ArgSimpleForm } from './arg_simple_form'; +import { ArgTemplateForm } from './arg_template_form'; +import { SimpleFailure } from './simple_failure'; +import { AdvancedFailure } from './advanced_failure'; +import { ArgLabel } from './arg_label'; +import { PendingArgValue } from './pending_arg_value'; + +const branches = [ + // rendered argType args should be resolved, but are not + branch(({ argTypeInstance, resolvedArgValue }) => { + const { argType } = argTypeInstance; + + // arg does not need to be resolved, no need to branch + if (!argType.resolveArgValue) return false; + + // arg needs to be resolved, render pending if the value is not defined + return typeof resolvedArgValue === 'undefined'; + }, renderComponent(PendingArgValue)), +]; + +// This is what is being generated by render() from the Arg class. It is called in FunctionForm +const ArgFormComponent = props => { + const { + argId, + argTypeInstance, + templateProps, + valueMissing, + label, + setLabel, + onValueRemove, + workpad, + renderError, + setRenderError, + resolvedArgValue, + } = props; + + return ( + + {({ error, resetErrorState }) => { + const { template, simpleTemplate } = argTypeInstance.argType; + const hasError = Boolean(error) || renderError; + + const argumentProps = { + ...templateProps, + resolvedArgValue, + defaultValue: argTypeInstance.default, + + renderError: () => { + // TODO: don't do this + // It's an ugly hack to avoid React's render cycle and ensure the error happens on the next tick + // This is important; Otherwise we end up updating state in the middle of a render cycle + Promise.resolve().then(() => { + // Provide templates with a renderError method, and wrap the error in a known error type + // to stop Kibana's window.error from being called + // see window_error_handler.js for details, + setRenderError(true); + }); + }, + error: hasError, + setLabel, + resetErrorState: () => { + resetErrorState(); + setRenderError(false); + }, + label, + workpad, + argId, + }; + + const expandableLabel = Boolean(hasError || template); + + const simpleArg = ( + + + + ); + + const extendedArg = ( +
+ +
+ ); + + return ( +
+ + {extendedArg} + +
+ ); + }} +
+ ); +}; + +ArgFormComponent.propTypes = { + argId: PropTypes.string.isRequired, + workpad: PropTypes.object.isRequired, + argTypeInstance: PropTypes.shape({ + argType: PropTypes.object.isRequired, + help: PropTypes.string.isRequired, + required: PropTypes.bool, + default: PropTypes.any, + }).isRequired, + templateProps: PropTypes.object, + valueMissing: PropTypes.bool, + label: PropTypes.string, + setLabel: PropTypes.func.isRequired, + expand: PropTypes.bool, + setExpand: PropTypes.func, + onValueRemove: PropTypes.func, + renderError: PropTypes.bool.isRequired, + setRenderError: PropTypes.func.isRequired, + resolvedArgValue: PropTypes.any, +}; + +export const ArgForm = compose(...branches)(ArgFormComponent); diff --git a/x-pack/plugins/canvas/public/components/arg_form/arg_form.scss b/x-pack/plugins/canvas/public/components/arg_form/arg_form.scss new file mode 100644 index 0000000000000..5e8b25f024b3a --- /dev/null +++ b/x-pack/plugins/canvas/public/components/arg_form/arg_form.scss @@ -0,0 +1,97 @@ +.canvasArg--expandable + .canvasArg--expandable { + margin-top: 0; + + .canvasArg__accordion:before { + display: none; + } +} + +.canvasSidebar__panel { + .canvasArg--expandable:last-child { + .canvasArg__accordion.euiAccordion-isOpen:after { + display: none; + } + } +} + +.canvasArg { + margin-top: $euiSize; + + + .canvasArg--remove { + visibility: hidden; + } +} + +.canvasArg__content { + padding-top: $euiSizeS; +} + +.canvasArg__form { + position: relative; + +} + +.canvasArg__form, .canvasArg__accordion { + &:hover { + .canvasArg__remove { + opacity: 1; + visibility: visible; + } + } +} + +.canvasArg__tooltip { + margin-left: -$euiSizeXL; +} + +.canvasArg__remove { + position: absolute; + right: -$euiSizeL; + top: $euiSizeS - 2px; + border-radius: $euiBorderRadius; + border: $euiBorderThin; + background: $euiColorEmptyShade; + opacity: 0; + visibility: hidden; + transition: opacity $euiAnimSpeedNormal $euiAnimSlightResistance; + transition-delay: $euiAnimSpeedSlow; +} + +.canvasArg__accordion { + padding: $euiSizeS $euiSize; + margin: 0 (-$euiSize); + background: $euiColorLightestShade; + position: relative; + + // different spacing means leff shift + .canvasArg__remove { + right: -$euiSizeM; + } + + // don't let remove button position here if this is nested in an accordion + .canvasArg__form { + position: static; + } + + &.euiAccordion-isOpen { + background: transparent; + } + + &:before, &:after { + content: ""; + height: 1px; + position: absolute; + left: 0; + width: 100%; + background: $euiColorLightShade; + } + + &:before { + top: 0; + } + + &:after { + bottom: 0; + } +} diff --git a/x-pack/plugins/canvas/public/components/arg_form/arg_label.js b/x-pack/plugins/canvas/public/components/arg_form/arg_label.js new file mode 100644 index 0000000000000..d2c1440c53540 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/arg_form/arg_label.js @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { EuiFormRow, EuiAccordion, EuiText, EuiToolTip } from '@elastic/eui'; +// This is what is being generated by render() from the Arg class. It is called in FunctionForm + +export const ArgLabel = props => { + const { argId, className, label, help, expandable, children, simpleArg, initialIsOpen } = props; + + return ( +
+ {expandable ? ( + + + {label} + + + } + extraAction={simpleArg} + initialIsOpen={initialIsOpen} + > +
{children}
+
+ ) : ( + simpleArg && ( + + {simpleArg} + + ) + )} +
+ ); +}; + +ArgLabel.propTypes = { + argId: PropTypes.string, + label: PropTypes.string, + help: PropTypes.string, + expandable: PropTypes.bool, + initialIsOpen: PropTypes.bool, + simpleArg: PropTypes.object, + children: PropTypes.oneOfType([PropTypes.node, PropTypes.element]).isRequired, + className: PropTypes.string, +}; diff --git a/x-pack/plugins/canvas/public/components/arg_form/arg_simple_form.js b/x-pack/plugins/canvas/public/components/arg_form/arg_simple_form.js new file mode 100644 index 0000000000000..0d50345279a25 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/arg_form/arg_simple_form.js @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { TooltipIcon } from '../tooltip_icon'; + +// This is what is being generated by render() from the Arg class. It is called in FunctionForm +export const ArgSimpleForm = ({ children, required, valueMissing, onRemove }) => { + return ( + + {children} + {valueMissing && ( + + + + )} + + {!required && ( + + )} + + ); +}; + +ArgSimpleForm.propTypes = { + children: PropTypes.node, + required: PropTypes.bool, + valueMissing: PropTypes.bool, + onRemove: PropTypes.func.isRequired, +}; diff --git a/x-pack/plugins/canvas/public/components/arg_form/arg_template_form.js b/x-pack/plugins/canvas/public/components/arg_form/arg_template_form.js new file mode 100644 index 0000000000000..eb11c401b89e6 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/arg_form/arg_template_form.js @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { compose, withPropsOnChange, withProps } from 'recompose'; +import { RenderToDom } from '../render_to_dom'; +import { ExpressionFormHandlers } from '../../../common/lib/expression_form_handlers'; + +class ArgTemplateFormComponent extends React.Component { + static propTypes = { + template: PropTypes.func, + argumentProps: PropTypes.shape({ + valueMissing: PropTypes.bool, + label: PropTypes.string, + setLabel: PropTypes.func.isRequired, + expand: PropTypes.bool, + setExpand: PropTypes.func, + onValueRemove: PropTypes.func, + resetErrorState: PropTypes.func.isRequired, + renderError: PropTypes.func.isRequired, + }), + handlers: PropTypes.object.isRequired, + error: PropTypes.oneOfType([PropTypes.object, PropTypes.bool]).isRequired, + errorTemplate: PropTypes.oneOfType([PropTypes.element, PropTypes.func]).isRequired, + }; + + static domNode = null; + + componentWillUpdate(prevProps) { + //see if error state changed + if (this.props.error !== prevProps.error) this.props.handlers.destroy(); + } + componentDidUpdate() { + if (this.props.error) return this.renderErrorTemplate(); + this.renderTemplate(this.domNode); + } + + componentWillUnmount() { + this.props.handlers.destroy(); + } + + renderTemplate = domNode => { + const { template, argumentProps, handlers } = this.props; + if (template) return template(domNode, argumentProps, handlers); + }; + + renderErrorTemplate = () => { + const { errorTemplate, argumentProps } = this.props; + return React.createElement(errorTemplate, argumentProps); + }; + + render() { + const { template, error } = this.props; + + if (error) return this.renderErrorTemplate(); + + if (!template) return null; + + return ( + { + this.domNode = domNode; + this.renderTemplate(domNode); + }} + /> + ); + } +} + +export const ArgTemplateForm = compose( + withPropsOnChange( + () => false, + () => ({ + expressionFormHandlers: new ExpressionFormHandlers(), + }) + ), + withProps(({ handlers, expressionFormHandlers }) => ({ + handlers: Object.assign(expressionFormHandlers, handlers), + })) +)(ArgTemplateFormComponent); diff --git a/x-pack/plugins/canvas/public/components/arg_form/index.js b/x-pack/plugins/canvas/public/components/arg_form/index.js new file mode 100644 index 0000000000000..3ef98cd13b5d6 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/arg_form/index.js @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { compose, withState, lifecycle } from 'recompose'; +import { getWorkpadInfo } from '../../state/selectors/workpad'; +import { ArgForm as Component } from './arg_form'; + +export const ArgForm = compose( + withState('label', 'setLabel', ({ label, argTypeInstance }) => { + return label || argTypeInstance.displayName || argTypeInstance.name; + }), + withState('resolvedArgValue', 'setResolvedArgValue'), + withState('renderError', 'setRenderError', false), + lifecycle({ + componentDidUpdate(prevProps) { + if (prevProps.templateProps.argValue !== this.props.templateProps.argValue) { + this.props.setRenderError(false); + this.props.setResolvedArgValue(); + } + }, + }), + connect(state => ({ workpad: getWorkpadInfo(state) })) +)(Component); + +ArgForm.propTypes = { + label: PropTypes.string, + argTypeInstance: PropTypes.shape({ + name: PropTypes.string.isRequired, + displayName: PropTypes.string, + expanded: PropTypes.bool, + }).isRequired, +}; diff --git a/x-pack/plugins/canvas/public/components/arg_form/pending_arg_value.js b/x-pack/plugins/canvas/public/components/arg_form/pending_arg_value.js new file mode 100644 index 0000000000000..429072081424e --- /dev/null +++ b/x-pack/plugins/canvas/public/components/arg_form/pending_arg_value.js @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { Loading } from '../loading'; +import { ArgLabel } from './arg_label'; + +export class PendingArgValue extends React.PureComponent { + static propTypes = { + label: PropTypes.string, + argTypeInstance: PropTypes.shape({ + help: PropTypes.string.isRequired, + }).isRequired, + setResolvedArgValue: PropTypes.func.isRequired, + templateProps: PropTypes.shape({ + argResolver: PropTypes.func.isRequired, + argValue: PropTypes.any, + }), + }; + + componentDidMount() { + // on mount, resolve the arg value using the argResolver + const { setResolvedArgValue, templateProps } = this.props; + const { argResolver, argValue } = templateProps; + if (argValue == null) { + setResolvedArgValue(null); + } else { + argResolver(argValue) + .then(val => setResolvedArgValue(val != null ? val : null)) + .catch(() => setResolvedArgValue(null)); // swallow error, it's not important + } + } + + render() { + const { label, argTypeInstance } = this.props; + + return ( +
+ +
+ +
+
+
+ ); + } +} diff --git a/x-pack/plugins/canvas/public/components/arg_form/simple_failure.js b/x-pack/plugins/canvas/public/components/arg_form/simple_failure.js new file mode 100644 index 0000000000000..85cc47daddf1d --- /dev/null +++ b/x-pack/plugins/canvas/public/components/arg_form/simple_failure.js @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { TooltipIcon } from '../tooltip_icon'; + +// This is what is being generated by render() from the Arg class. It is called in FunctionForm +export const SimpleFailure = () => ( +
+ +
+); diff --git a/x-pack/plugins/canvas/public/components/asset_manager/asset_manager.js b/x-pack/plugins/canvas/public/components/asset_manager/asset_manager.js new file mode 100644 index 0000000000000..2f813e7c8688c --- /dev/null +++ b/x-pack/plugins/canvas/public/components/asset_manager/asset_manager.js @@ -0,0 +1,188 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiButtonIcon, + EuiButtonEmpty, + EuiButton, + EuiOverlayMask, + EuiModal, + EuiModalHeader, + EuiModalBody, + EuiText, + EuiImage, + EuiPanel, + EuiModalFooter, + EuiModalHeaderTitle, + EuiFlexGrid, + EuiProgress, + EuiSpacer, + EuiTextColor, +} from '@elastic/eui'; +import { ConfirmModal } from '../confirm_modal'; +import { Clipboard } from '../clipboard'; +import { Download } from '../download'; + +export class AssetManager extends React.PureComponent { + static propTypes = { + assets: PropTypes.array, + removeAsset: PropTypes.func, + copyAsset: PropTypes.func, + }; + + state = { + deleteId: null, + isModalVisible: false, + }; + + showModal = () => this.setState({ isModalVisible: true }); + closeModal = () => this.setState({ isModalVisible: false }); + + doDelete = () => { + this.resetDelete(); + this.props.removeAsset(this.state.deleteId); + }; + + resetDelete = () => this.setState({ deleteId: null }); + + renderAsset = asset => ( + + +
+ +
+ + + + +

+ {asset.id} +
+ + ({Math.round(asset.value.length / 1024)} kb) + +

+
+ + + + + + + + + + + result && this.props.copyAsset(asset.id)} + > + + + + + this.setState({ deleteId: asset.id })} + /> + + +
+
+ ); + + render() { + const { isModalVisible } = this.state; + + const assetMaxLimit = 25000; + + const assetsTotal = Math.round( + this.props.assets.reduce((total, asset) => total + asset.value.length, 0) / 1024 + ); + + const percentageUsed = Math.round((assetsTotal / assetMaxLimit) * 100); + + const assetModal = isModalVisible ? ( + + + + + Manage workpad assets + + + + + + + {percentageUsed}% space used + + + + + +

+ Below are the image assets that you added to this workpad. To reclaim space, delete + assets that you no longer need. Unfortunately, any assets that are actually in use + cannot be determined at this time. +

+
+ + {this.props.assets.map(this.renderAsset)} + +
+ + + Close + + +
+
+ ) : null; + + return ( + + + Manage assets + + + {assetModal} + + + + ); + } +} diff --git a/x-pack/plugins/canvas/public/components/asset_manager/asset_manager.scss b/x-pack/plugins/canvas/public/components/asset_manager/asset_manager.scss new file mode 100644 index 0000000000000..55f569dcddadf --- /dev/null +++ b/x-pack/plugins/canvas/public/components/asset_manager/asset_manager.scss @@ -0,0 +1,56 @@ +.canvasAssetManager { + max-width: 1000px; + + .canvasAssetManager__modalHeader { + flex-wrap: wrap; + } + + .canvasAssetManager__modalHeaderTitle { + @include euiBreakpoint('xs', 's') { + width: 100%; + padding-bottom: $euiSize; + } + } + + .canvasAssetManager__meterWrapper { + flex-grow: 0; + min-width: 40%; + align-items: center; + justify-content: flex-end; + padding-right: $euiSize; + + @include euiBreakpoint('xs', 's') { + flex-grow: 1; + } + } + + .canvasAssetManager__meterLabel { + margin: 0; + } + + // ASSETS LIST + + .canvasAssetManager__asset { + text-align: center; + overflow: hidden; // hides image from outer panel boundaries + } + + .canvasAssetManager__thumb { + margin: -$euiSizeS; + margin-bottom: 0; + font-size: 0; // eliminates any extra space around img + } + + .canvasAssetManager__img { + background-repeat: no-repeat; + background-position: center; + background-size: contain; + + img { + width: auto; + max-width: 100%; + height: 164px; // nice default proportions for typical 4x3 images + opacity: 0; // only show the background image (which will properly keep proportions) + } + } +} diff --git a/x-pack/plugins/canvas/public/components/asset_manager/index.js b/x-pack/plugins/canvas/public/components/asset_manager/index.js new file mode 100644 index 0000000000000..98f13eb4e7de3 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/asset_manager/index.js @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { connect } from 'react-redux'; +import { compose, withProps } from 'recompose'; +import { notify } from '../../lib/notify'; +import { getAssets } from '../../state/selectors/assets'; +import { removeAsset } from '../../state/actions/assets'; +import { AssetManager as Component } from './asset_manager'; + +const mapStateToProps = state => ({ + assets: Object.values(getAssets(state)), // pull values out of assets object +}); + +const mapDispatchToProps = { + removeAsset, +}; + +export const AssetManager = compose( + connect( + mapStateToProps, + mapDispatchToProps + ), + withProps({ copyAsset: assetId => notify.success(`Copied '${assetId}' to clipboard`) }) +)(Component); diff --git a/x-pack/plugins/canvas/public/components/border_connection/border_connection.js b/x-pack/plugins/canvas/public/components/border_connection/border_connection.js new file mode 100644 index 0000000000000..167325940ee87 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/border_connection/border_connection.js @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import aero from '../../lib/aeroelastic'; + +export const BorderConnection = ({ transformMatrix, width, height }) => { + const newStyle = { + width, + height, + marginLeft: -width / 2, + marginTop: -height / 2, + position: 'absolute', + transform: aero.dom.matrixToCSS(transformMatrix), + }; + return
; +}; + +BorderConnection.propTypes = { + width: PropTypes.number.isRequired, + height: PropTypes.number.isRequired, + transformMatrix: PropTypes.arrayOf(PropTypes.number).isRequired, +}; diff --git a/x-pack/plugins/canvas/public/components/border_connection/border_connection.scss b/x-pack/plugins/canvas/public/components/border_connection/border_connection.scss new file mode 100644 index 0000000000000..3171a25182110 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/border_connection/border_connection.scss @@ -0,0 +1,11 @@ +.canvasBorder--connection { + position: absolute; + top: 0; + width: 100%; + height: 100%; + pointer-events: none; + //box-shadow: inset 0 0 1px 2px $euiColorPrimary; + border-top: $euiBorderThin; + border-left: $euiBorderThin; + border-color: #d9d9d9; +} diff --git a/x-pack/plugins/canvas/public/components/border_connection/index.js b/x-pack/plugins/canvas/public/components/border_connection/index.js new file mode 100644 index 0000000000000..b99ab923d52d4 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/border_connection/index.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pure } from 'recompose'; +import { BorderConnection as Component } from './border_connection'; + +export const BorderConnection = pure(Component); diff --git a/x-pack/plugins/canvas/public/components/border_resize_handle/border_resize_handle.js b/x-pack/plugins/canvas/public/components/border_resize_handle/border_resize_handle.js new file mode 100644 index 0000000000000..757079e12b243 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/border_resize_handle/border_resize_handle.js @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import aero from '../../lib/aeroelastic'; + +export const BorderResizeHandle = ({ transformMatrix }) => ( +
+); + +BorderResizeHandle.propTypes = { + transformMatrix: PropTypes.arrayOf(PropTypes.number).isRequired, +}; diff --git a/x-pack/plugins/canvas/public/components/border_resize_handle/border_resize_handle.scss b/x-pack/plugins/canvas/public/components/border_resize_handle/border_resize_handle.scss new file mode 100644 index 0000000000000..db8b926ba8221 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/border_resize_handle/border_resize_handle.scss @@ -0,0 +1,13 @@ +.canvasBorderResizeHandle { + transform-origin: center center; /* the default, only for clarity */ + transform-style: preserve-3d; + display: block; + position: absolute; + height: 8px; + width: 8px; + margin-left: -4px; + margin-top: -4px; + background-color: #fff; + border: 1px solid #666; + box-shadow: 0 2px 2px -1px rgba(153, 153, 153, 0.3), 0 1px 5px -2px rgba(153, 153, 153, 0.3); +} diff --git a/x-pack/plugins/canvas/public/components/border_resize_handle/index.js b/x-pack/plugins/canvas/public/components/border_resize_handle/index.js new file mode 100644 index 0000000000000..c3fea05d60f7e --- /dev/null +++ b/x-pack/plugins/canvas/public/components/border_resize_handle/index.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pure } from 'recompose'; +import { BorderResizeHandle as Component } from './border_resize_handle'; + +export const BorderResizeHandle = pure(Component); diff --git a/x-pack/plugins/canvas/public/components/clipboard/clipboard.js b/x-pack/plugins/canvas/public/components/clipboard/clipboard.js new file mode 100644 index 0000000000000..4be4471eb0d23 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/clipboard/clipboard.js @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import copy from 'copy-to-clipboard'; + +export class Clipboard extends React.PureComponent { + static propTypes = { + children: PropTypes.element.isRequired, + content: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + onCopy: PropTypes.func, + }; + + onClick = ev => { + const { content, onCopy } = this.props; + ev.preventDefault(); + + const result = copy(content, { debug: true }); + + if (typeof onCopy === 'function') onCopy(result); + }; + + render() { + return ( +
+ {this.props.children} +
+ ); + } +} diff --git a/x-pack/plugins/canvas/public/components/clipboard/index.js b/x-pack/plugins/canvas/public/components/clipboard/index.js new file mode 100644 index 0000000000000..e95ea6eb26598 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/clipboard/index.js @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Clipboard as Component } from './clipboard'; + +export const Clipboard = Component; diff --git a/x-pack/plugins/canvas/public/components/color_dot/color_dot.js b/x-pack/plugins/canvas/public/components/color_dot/color_dot.js new file mode 100644 index 0000000000000..fb6ec2eb73c68 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/color_dot/color_dot.js @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; + +export const ColorDot = ({ value, children }) => { + return ( +
+
+
+ {children} +
+
+ ); +}; + +ColorDot.propTypes = { + value: PropTypes.string, + children: PropTypes.node, + handleClick: PropTypes.func, +}; diff --git a/x-pack/plugins/canvas/public/components/color_dot/color_dot.scss b/x-pack/plugins/canvas/public/components/color_dot/color_dot.scss new file mode 100644 index 0000000000000..473497506a893 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/color_dot/color_dot.scss @@ -0,0 +1,28 @@ +.canvasColorDot { + display: inline-block; + position: relative; + height: $euiSizeXL; + width: $euiSizeXL; + + .canvasColorDot__background { + position: absolute; + height: $euiSizeXL; + width: $euiSizeXL; + border-radius: $euiBorderRadius; + top: 0; + left: 0; + } + + .canvasColorDot__foreground { + position: absolute; + border: $euiBorderThin; + height: $euiSizeXL; + width: $euiSizeXL; + border-radius: $euiBorderRadius; + top: 0; + left: 0; + display: flex; + align-items: center; + justify-content: center; + } +} diff --git a/x-pack/plugins/canvas/public/components/color_dot/index.js b/x-pack/plugins/canvas/public/components/color_dot/index.js new file mode 100644 index 0000000000000..f5de0ab250d18 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/color_dot/index.js @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pure } from 'recompose'; + +import { ColorDot as Component } from './color_dot'; + +export const ColorDot = pure(Component); diff --git a/x-pack/plugins/canvas/public/components/color_manager/color_manager.js b/x-pack/plugins/canvas/public/components/color_manager/color_manager.js new file mode 100644 index 0000000000000..e83d1733d7a7f --- /dev/null +++ b/x-pack/plugins/canvas/public/components/color_manager/color_manager.js @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { EuiFieldText, EuiButtonIcon, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { ColorDot } from '../color_dot/color_dot'; + +export const ColorManager = ({ value, addColor, removeColor, onChange }) => ( + + + + + + onChange(e.target.value)} + /> + + {(addColor || removeColor) && ( + + {addColor && ( + addColor(value)} + /> + )} + {removeColor && ( + removeColor(value)} + /> + )} + + )} + +); + +ColorManager.propTypes = { + value: PropTypes.string, + addColor: PropTypes.func, + removeColor: PropTypes.func, + onChange: PropTypes.func.isRequired, +}; diff --git a/x-pack/plugins/canvas/public/components/color_manager/index.js b/x-pack/plugins/canvas/public/components/color_manager/index.js new file mode 100644 index 0000000000000..119859ad0cddd --- /dev/null +++ b/x-pack/plugins/canvas/public/components/color_manager/index.js @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { compose, withState } from 'recompose'; + +import { ColorManager as Component } from './color_manager'; + +export const ColorManager = compose(withState('adding', 'setAdding', false))(Component); diff --git a/x-pack/plugins/canvas/public/components/color_palette/color_palette.js b/x-pack/plugins/canvas/public/components/color_palette/color_palette.js new file mode 100644 index 0000000000000..5df8e6ad21b83 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/color_palette/color_palette.js @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { EuiIcon, EuiLink } from '@elastic/eui'; +import { readableColor } from '../../lib/readable_color'; +import { ColorDot } from '../color_dot'; +import { ItemGrid } from '../item_grid'; + +export const ColorPalette = ({ value, colors, colorsPerRow, onChange }) => ( +
+ + {({ item: color }) => ( + onChange(color)} + className="canvasColorPalette__dot" + > + + {color === value && ( + + )} + + + )} + +
+); + +ColorPalette.propTypes = { + colors: PropTypes.array.isRequired, + onChange: PropTypes.func.isRequired, + value: PropTypes.string, + colorsPerRow: PropTypes.number, +}; diff --git a/x-pack/plugins/canvas/public/components/color_palette/color_palette.scss b/x-pack/plugins/canvas/public/components/color_palette/color_palette.scss new file mode 100644 index 0000000000000..1496db27cb469 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/color_palette/color_palette.scss @@ -0,0 +1,6 @@ +.canvasColorPalette { + .canvasColorPalette__dot { + display: inline-block; + margin: 0px $euiSizeXS $euiSizeXS 0px; + } +} diff --git a/x-pack/plugins/canvas/public/components/color_palette/index.js b/x-pack/plugins/canvas/public/components/color_palette/index.js new file mode 100644 index 0000000000000..eb2e625a70b29 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/color_palette/index.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pure } from 'recompose'; +import { ColorPalette as Component } from './color_palette'; + +export const ColorPalette = pure(Component); diff --git a/x-pack/plugins/canvas/public/components/color_picker/color_picker.js b/x-pack/plugins/canvas/public/components/color_picker/color_picker.js new file mode 100644 index 0000000000000..9415eb400abd8 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/color_picker/color_picker.js @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { ColorPalette } from '../color_palette'; +import { ColorManager } from '../color_manager'; + +export const ColorPicker = ({ onChange, value, colors, addColor, removeColor }) => { + return ( +
+ + +
+ ); +}; + +ColorPicker.propTypes = { + value: PropTypes.string, + onChange: PropTypes.func, + colors: PropTypes.array, + addColor: PropTypes.func, + removeColor: PropTypes.func, +}; diff --git a/x-pack/plugins/canvas/public/components/color_picker/index.js b/x-pack/plugins/canvas/public/components/color_picker/index.js new file mode 100644 index 0000000000000..c4706151070b8 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/color_picker/index.js @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pure } from 'recompose'; + +import { ColorPicker as Component } from './color_picker'; + +export const ColorPicker = pure(Component); diff --git a/x-pack/plugins/canvas/public/components/color_picker_mini/color_picker_mini.js b/x-pack/plugins/canvas/public/components/color_picker_mini/color_picker_mini.js new file mode 100644 index 0000000000000..f3f9b085d1038 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/color_picker_mini/color_picker_mini.js @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { EuiLink } from '@elastic/eui'; +import { Popover } from '../popover'; +import { ColorPicker } from '../color_picker'; +import { ColorDot } from '../color_dot'; +import { WorkpadColorPicker } from '../workpad_color_picker/'; + +export const ColorPickerMini = ({ onChange, value, anchorPosition, colors }) => { + const button = handleClick => ( + + + + ); + + return ( + + {() => + colors ? ( + + ) : ( + + ) + } + + ); +}; + +ColorPickerMini.propTypes = { + colors: PropTypes.array, + value: PropTypes.string, + onChange: PropTypes.func, + anchorPosition: PropTypes.string, +}; diff --git a/x-pack/plugins/canvas/public/components/color_picker_mini/color_picker_mini.scss b/x-pack/plugins/canvas/public/components/color_picker_mini/color_picker_mini.scss new file mode 100644 index 0000000000000..c1aa8d09a1a6b --- /dev/null +++ b/x-pack/plugins/canvas/public/components/color_picker_mini/color_picker_mini.scss @@ -0,0 +1,3 @@ +.canvasColorPickerMini__popover { + width: 250px; +} diff --git a/x-pack/plugins/canvas/public/components/color_picker_mini/index.js b/x-pack/plugins/canvas/public/components/color_picker_mini/index.js new file mode 100644 index 0000000000000..3bd5eb48f03b7 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/color_picker_mini/index.js @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pure } from 'recompose'; + +import { ColorPickerMini as Component } from './color_picker_mini'; + +export const ColorPickerMini = pure(Component); diff --git a/x-pack/plugins/canvas/public/components/confirm_modal/confirm_modal.js b/x-pack/plugins/canvas/public/components/confirm_modal/confirm_modal.js new file mode 100644 index 0000000000000..bf842540e4b51 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/confirm_modal/confirm_modal.js @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint-disable react/forbid-elements */ +import React from 'react'; +import PropTypes from 'prop-types'; +import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; + +export const ConfirmModal = props => { + const { + isOpen, + title, + message, + onConfirm, + onCancel, + confirmButtonText, + cancelButtonText, + className, + ...rest + } = props; + + const confirm = ev => { + onConfirm && onConfirm(ev); + }; + + const cancel = ev => { + onCancel && onCancel(ev); + }; + + // render nothing if this component isn't open + if (!isOpen) return null; + + return ( + + + {message} + + + ); +}; + +ConfirmModal.propTypes = { + isOpen: PropTypes.bool, + title: PropTypes.string, + message: PropTypes.string.isRequired, + onConfirm: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired, + cancelButtonText: PropTypes.string, + confirmButtonText: PropTypes.string, + className: PropTypes.string, +}; + +ConfirmModal.defaultProps = { + title: 'Confirm', + confirmButtonText: 'Confirm', + cancelButtonText: 'Cancel', +}; diff --git a/x-pack/plugins/canvas/public/components/confirm_modal/index.js b/x-pack/plugins/canvas/public/components/confirm_modal/index.js new file mode 100644 index 0000000000000..bdbded906a8b0 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/confirm_modal/index.js @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ConfirmModal as Component } from './confirm_modal'; + +export const ConfirmModal = Component; diff --git a/x-pack/plugins/canvas/public/components/context_menu/context_menu.js b/x-pack/plugins/canvas/public/components/context_menu/context_menu.js new file mode 100644 index 0000000000000..43bbb9288940d --- /dev/null +++ b/x-pack/plugins/canvas/public/components/context_menu/context_menu.js @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; + +export const ContextMenu = ({ + items, + onSelect, + itemsStyle, + itemComponent, + children, + isOpen, + selectedIndex, + setSelectedIndex, + onKeyDown, + onKeyPress, +}) => ( +
+ {children} + {isOpen && items.length ? ( +
+ {items.map((item, i) => ( +
onSelect(item)} + onMouseOver={() => setSelectedIndex(i)} + > + {itemComponent({ item })} +
+ ))} +
+ ) : ( + '' + )} +
+); + +ContextMenu.propTypes = { + items: PropTypes.array, + onSelect: PropTypes.func, + itemsStyle: PropTypes.object, + itemComponent: PropTypes.func, + children: PropTypes.node, + isOpen: PropTypes.bool, + selectedIndex: PropTypes.number, + setSelectedIndex: PropTypes.func, + onKeyDown: PropTypes.func, + onKeyPress: PropTypes.func, +}; diff --git a/x-pack/plugins/canvas/public/components/context_menu/context_menu.scss b/x-pack/plugins/canvas/public/components/context_menu/context_menu.scss new file mode 100644 index 0000000000000..f982f8b3deedd --- /dev/null +++ b/x-pack/plugins/canvas/public/components/context_menu/context_menu.scss @@ -0,0 +1,28 @@ +.contextMenu { + position: relative; + + .contextMenu__items { + position: absolute; + z-index: 1; + width: 100%; + display: flex; + flex-direction: column; + background: $euiColorEmptyShade; + border: $euiBorderThin; + + .contextMenu__item { + padding: $euiSizeS; + background-color: $euiColorEmptyShade; + border: $euiBorderThin; + color: $euiTextColor; + display: flex; + align-self: flex-start; + width: 100%; + + &-isActive { + background-color: $euiColorDarkShade; + color: $euiColorGhost; + } + } + } +} diff --git a/x-pack/plugins/canvas/public/components/context_menu/index.js b/x-pack/plugins/canvas/public/components/context_menu/index.js new file mode 100644 index 0000000000000..d58137cf03c5e --- /dev/null +++ b/x-pack/plugins/canvas/public/components/context_menu/index.js @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { compose, withState, withHandlers } from 'recompose'; +import { ContextMenu as Component } from './context_menu'; +import { onKeyDownProvider, onKeyPressProvider } from './key_handlers'; + +export const ContextMenu = compose( + withState('isOpen', 'setIsOpen', true), + withState('selectedIndex', 'setSelectedIndex', -1), + withHandlers({ + onKeyDown: onKeyDownProvider, + onKeyPress: onKeyPressProvider, + }) +)(Component); diff --git a/x-pack/plugins/canvas/public/components/context_menu/key_handlers.js b/x-pack/plugins/canvas/public/components/context_menu/key_handlers.js new file mode 100644 index 0000000000000..5b40f9920a409 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/context_menu/key_handlers.js @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/** + * Handle key down events for the menu, including selecting the previous and + * next items, making the item selection, closing the menu, etc. + */ +export const onKeyDownProvider = ({ + items, + onSelect, + isOpen, + setIsOpen, + selectedIndex, + setSelectedIndex, +}) => e => { + if (!isOpen || !items.length) return; + const { key } = e; + if (key === 'ArrowUp') { + e.preventDefault(); + setSelectedIndex((selectedIndex - 1 + items.length) % items.length); + } else if (key === 'ArrowDown') { + e.preventDefault(); + setSelectedIndex((selectedIndex + 1) % items.length); + } else if (['Enter', 'Tab'].includes(key) && selectedIndex >= 0) { + e.preventDefault(); + onSelect(items[selectedIndex]); + setSelectedIndex(-1); + } else if (key === 'Escape') { + setIsOpen(false); + } +}; + +/** + * On key press (character keys), show the menu. We don't want to willy nilly + * show the menu whenever ANY key down event happens (like arrow keys) cuz that + * would be just downright annoying. + */ +export const onKeyPressProvider = ({ setIsOpen, setSelectedIndex }) => () => { + setIsOpen(true); + setSelectedIndex(-1); +}; diff --git a/x-pack/plugins/canvas/public/components/datasource/datasource.js b/x-pack/plugins/canvas/public/components/datasource/datasource.js new file mode 100644 index 0000000000000..b2fc2001b4e2c --- /dev/null +++ b/x-pack/plugins/canvas/public/components/datasource/datasource.js @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { compose, branch, renderComponent } from 'recompose'; +import PropTypes from 'prop-types'; +import { NoDatasource } from './no_datasource'; +import { DatasourceComponent } from './datasource_component'; + +const branches = [ + // rendered when there is no datasource in the expression + branch( + ({ datasource, stateDatasource }) => !datasource || !stateDatasource, + renderComponent(NoDatasource) + ), +]; + +export const Datasource = compose(...branches)(DatasourceComponent); + +Datasource.propTypes = { + args: PropTypes.object, + datasource: PropTypes.object, + unknownArgs: PropTypes.array, +}; diff --git a/x-pack/plugins/canvas/public/components/datasource/datasource.scss b/x-pack/plugins/canvas/public/components/datasource/datasource.scss new file mode 100644 index 0000000000000..ee6c082db1217 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/datasource/datasource.scss @@ -0,0 +1,11 @@ +.canvasDataSource { + background-color: $euiColorEmptyShade; + color: $euiTextColor; + border-radius: $euiBorderThin; + margin: $euiSizeS; + padding: 0 $euiSizeS; +} + +.canvasDataSource__card + .canvasDataSource__card { + margin-top: $euiSizeS; +} diff --git a/x-pack/plugins/canvas/public/components/datasource/datasource_component.js b/x-pack/plugins/canvas/public/components/datasource/datasource_component.js new file mode 100644 index 0000000000000..e0240a3a8ef36 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/datasource/datasource_component.js @@ -0,0 +1,155 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { + EuiPanel, + EuiFlexGroup, + EuiFlexItem, + EuiButton, + EuiButtonEmpty, + EuiSpacer, +} from '@elastic/eui'; +import { isEqual } from 'lodash'; +import { DatasourceSelector } from './datasource_selector'; +import { DatasourcePreview } from './datasource_preview'; + +export class DatasourceComponent extends PureComponent { + static propTypes = { + args: PropTypes.object.isRequired, + datasources: PropTypes.array.isRequired, + datasource: PropTypes.object.isRequired, + datasourceDef: PropTypes.object.isRequired, + stateDatasource: PropTypes.shape({ + name: PropTypes.string.isRequired, + render: PropTypes.func.isRequired, + }).isRequired, + selectDatasource: PropTypes.func, + setDatasourceAst: PropTypes.func, + stateArgs: PropTypes.object.isRequired, + updateArgs: PropTypes.func, + resetArgs: PropTypes.func.isRequired, + selecting: PropTypes.bool, + setSelecting: PropTypes.func, + previewing: PropTypes.bool, + setPreviewing: PropTypes.func, + isInvalid: PropTypes.bool, + setInvalid: PropTypes.func, + }; + + componentDidUpdate(prevProps) { + const { args, resetArgs, datasource, selectDatasource } = this.props; + if (!isEqual(prevProps.args, args)) resetArgs(); + + if (!isEqual(prevProps.datasource, datasource)) selectDatasource(datasource); + } + + getDatasourceFunctionNode = (name, args) => ({ + arguments: args, + function: name, + type: 'function', + }); + + setSelectedDatasource = value => { + const { + datasource, + resetArgs, + updateArgs, + selectDatasource, + datasources, + setSelecting, + } = this.props; + + if (datasource.name === value) { + // if selecting the current datasource, reset the arguments + resetArgs && resetArgs(); + } else { + // otherwise, clear the arguments, the form will update them + updateArgs && updateArgs({}); + } + selectDatasource && selectDatasource(datasources.find(d => d.name === value)); + setSelecting(false); + }; + + save = () => { + const { stateDatasource, stateArgs, setDatasourceAst } = this.props; + const datasourceAst = this.getDatasourceFunctionNode(stateDatasource.name, stateArgs); + setDatasourceAst && setDatasourceAst(datasourceAst); + }; + + render() { + const { + datasources, + datasourceDef, + stateDatasource, + stateArgs, + updateArgs, + selecting, + setSelecting, + previewing, + setPreviewing, + isInvalid, + setInvalid, + } = this.props; + + if (selecting) + return ; + + const datasourcePreview = previewing ? ( + setPreviewing(false)} + function={this.getDatasourceFunctionNode(stateDatasource.name, stateArgs)} + /> + ) : null; + + return ( + + + setSelecting(!selecting)} + > + Change your data source + + + {stateDatasource.render({ + args: stateArgs, + updateArgs, + datasourceDef, + isInvalid, + setInvalid, + })} + + + + setPreviewing(true)} icon="check"> + Preview + + + + + Save + + + + + + {datasourcePreview} + + ); + } +} diff --git a/x-pack/plugins/canvas/public/components/datasource/datasource_preview/datasource_preview.js b/x-pack/plugins/canvas/public/components/datasource/datasource_preview/datasource_preview.js new file mode 100644 index 0000000000000..f5c329e8ffe54 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/datasource/datasource_preview/datasource_preview.js @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { + EuiOverlayMask, + EuiModal, + EuiModalBody, + EuiModalHeader, + EuiModalHeaderTitle, + EuiPanel, + EuiText, + EuiEmptyPrompt, +} from '@elastic/eui'; +import { Datatable } from '../../datatable'; +import { Error } from '../../error'; + +export const DatasourcePreview = ({ done, datatable }) => ( + + + + Datasource Preview + + + +

+ Click Save in the sidebar to use this data. +

+
+ {datatable.type === 'error' ? ( + + ) : ( + + {datatable.rows.length > 0 ? ( + + ) : ( + No documents found} + titleSize="s" + body={ +

+ We couldn't find any documents matching your search criteria. +
Check your datasource settings and try again. +

+ } + /> + )} +
+ )} +
+
+
+); + +DatasourcePreview.propTypes = { + datatable: PropTypes.object, + done: PropTypes.func, +}; diff --git a/x-pack/plugins/canvas/public/components/datasource/datasource_preview/datasource_preview.scss b/x-pack/plugins/canvas/public/components/datasource/datasource_preview/datasource_preview.scss new file mode 100644 index 0000000000000..d26c2a9bb50ea --- /dev/null +++ b/x-pack/plugins/canvas/public/components/datasource/datasource_preview/datasource_preview.scss @@ -0,0 +1,9 @@ +.canvasDatasourcePreview { + max-width: 80vw; + max-height: 60vh; + height: 100%; + + .canvasDatasourcePreview__panel { + height: calc(100% - #{$euiSize} * 3); + } +} diff --git a/x-pack/plugins/canvas/public/components/datasource/datasource_preview/index.js b/x-pack/plugins/canvas/public/components/datasource/datasource_preview/index.js new file mode 100644 index 0000000000000..8b21c38a5f6f7 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/datasource/datasource_preview/index.js @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pure, compose, lifecycle, withState, branch, renderComponent } from 'recompose'; +import { PropTypes } from 'prop-types'; +import { Loading } from '../../loading'; +import { interpretAst } from '../../../lib/interpreter'; +import { DatasourcePreview as Component } from './datasource_preview'; + +export const DatasourcePreview = compose( + pure, + withState('datatable', 'setDatatable'), + lifecycle({ + componentDidMount() { + interpretAst({ + type: 'expression', + chain: [this.props.function], + }).then(this.props.setDatatable); + }, + }), + branch(({ datatable }) => !datatable, renderComponent(Loading)) +)(Component); + +DatasourcePreview.propTypes = { + function: PropTypes.object, +}; diff --git a/x-pack/plugins/canvas/public/components/datasource/datasource_selector.js b/x-pack/plugins/canvas/public/components/datasource/datasource_selector.js new file mode 100644 index 0000000000000..07df2a7007c4f --- /dev/null +++ b/x-pack/plugins/canvas/public/components/datasource/datasource_selector.js @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { EuiCard, EuiIcon } from '@elastic/eui'; + +export const DatasourceSelector = ({ onSelect, datasources }) => ( +
+ {datasources.map(d => ( + } + onClick={() => onSelect(d.name)} + description={d.help} + layout="horizontal" + className="canvasDataSource__card" + /> + ))} +
+); + +DatasourceSelector.propTypes = { + onSelect: PropTypes.func.isRequired, + datasources: PropTypes.array.isRequired, +}; diff --git a/x-pack/plugins/canvas/public/components/datasource/index.js b/x-pack/plugins/canvas/public/components/datasource/index.js new file mode 100644 index 0000000000000..c86f9914f3b69 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/datasource/index.js @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PropTypes } from 'prop-types'; +import { connect } from 'react-redux'; +import { withState, withHandlers, compose } from 'recompose'; +import { get } from 'lodash'; +import { datasourceRegistry } from '../../expression_types'; +import { getSelectedElement, getSelectedPage } from '../../state/selectors/workpad'; +import { getFunctionDefinitions } from '../../state/selectors/app'; +import { setArgumentAtIndex, setAstAtIndex, flushContext } from '../../state/actions/elements'; +import { Datasource as Component } from './datasource'; + +const mapStateToProps = state => ({ + element: getSelectedElement(state), + pageId: getSelectedPage(state), + functionDefinitions: getFunctionDefinitions(state), +}); + +const mapDispatchToProps = dispatch => ({ + dispatchArgumentAtIndex: props => arg => dispatch(setArgumentAtIndex({ ...props, arg })), + dispatchAstAtIndex: ({ index, element, pageId }) => ast => { + dispatch(flushContext(element.id)); + dispatch(setAstAtIndex(index, ast, element, pageId)); + }, +}); + +const mergeProps = (stateProps, dispatchProps, ownProps) => { + const { element, pageId, functionDefinitions } = stateProps; + const { dispatchArgumentAtIndex, dispatchAstAtIndex } = dispatchProps; + + const getDataTableFunctionsByName = name => + functionDefinitions.find(fn => fn.name === name && fn.type === 'datatable'); + + // find the matching datasource from the expression AST + const datasourceAst = get(element, 'ast.chain', []) + .map((astDef, i) => { + // if it's not a function, it's can't be a datasource + if (astDef.type !== 'function') return; + const args = astDef.arguments; + + // if there's no matching datasource in the registry, we're done + const datasource = datasourceRegistry.get(astDef.function); + if (!datasource) return; + + const datasourceDef = getDataTableFunctionsByName(datasource.name); + + // keep track of the ast, the ast index2, and the datasource + return { + datasource, + datasourceDef, + args, + expressionIndex: i, + }; + }) + .filter(Boolean)[0]; + + return { + ...ownProps, + ...stateProps, + ...dispatchProps, + ...datasourceAst, + datasources: datasourceRegistry.toArray(), + setDatasourceAst: dispatchAstAtIndex({ + pageId, + element, + index: datasourceAst && datasourceAst.expressionIndex, + }), + setDatasourceArgs: dispatchArgumentAtIndex({ + pageId, + element, + index: datasourceAst && datasourceAst.expressionIndex, + }), + }; +}; + +export const Datasource = compose( + connect( + mapStateToProps, + mapDispatchToProps, + mergeProps + ), + withState('stateArgs', 'updateArgs', ({ args }) => args), + withState('selecting', 'setSelecting', false), + withState('previewing', 'setPreviewing', false), + withState('isInvalid', 'setInvalid', false), + withState('stateDatasource', 'selectDatasource', ({ datasource }) => datasource), + withHandlers({ + resetArgs: ({ updateArgs, args }) => () => updateArgs(args), + }) +)(Component); + +Datasource.propTypes = { + done: PropTypes.func, +}; diff --git a/x-pack/plugins/canvas/public/components/datasource/no_datasource.js b/x-pack/plugins/canvas/public/components/datasource/no_datasource.js new file mode 100644 index 0000000000000..686c7d42d57a7 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/datasource/no_datasource.js @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { EuiPanel, EuiText } from '@elastic/eui'; + +export const NoDatasource = () => ( + + +

No data source present

+

+ This element does not have an attached data source. This is usually because the element is + an image or other static asset. If that's not the case you might want to check your + expression to make sure it is not malformed. +

+
+
+); + +NoDatasource.propTypes = { + done: PropTypes.func, +}; diff --git a/x-pack/plugins/canvas/public/components/datatable/datatable.js b/x-pack/plugins/canvas/public/components/datatable/datatable.js new file mode 100644 index 0000000000000..4381ae9f699b1 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/datatable/datatable.js @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiIcon, EuiPagination } from '@elastic/eui'; +import PropTypes from 'prop-types'; +import moment from 'moment'; +import { Paginate } from '../paginate'; + +const getIcon = type => { + if (type === null) return; + + let icon; + switch (type) { + case 'string': + icon = 'string'; + break; + case 'number': + icon = 'number'; + break; + case 'date': + icon = 'calendar'; + break; + case 'boolean': + icon = 'invert'; + break; + default: + icon = 'questionInCircle'; + } + + return ; +}; + +const getColumnName = col => (typeof col === 'string' ? col : col.name); + +const getColumnType = col => col.type || null; + +const getFormattedValue = (val, type) => { + if (type === 'date') return moment(val).format(); + return String(val); +}; + +export const Datatable = ({ datatable, perPage, paginate, showHeader }) => ( + + {({ rows, setPage, pageNumber, totalPages }) => ( +
+
+ + {!showHeader ? null : ( + + + {datatable.columns.map(col => ( + + ))} + + + )} + + {rows.map((row, i) => ( + + {datatable.columns.map(col => ( + + ))} + + ))} + +
+ {getColumnName(col)} +   + {getIcon(getColumnType(col))} +
+ {getFormattedValue(row[getColumnName(col)], getColumnType(col))} +
+
+ {paginate && ( +
+ +
+ )} +
+ )} +
+); + +Datatable.propTypes = { + datatable: PropTypes.object.isRequired, + perPage: PropTypes.number, + paginate: PropTypes.bool, + showHeader: PropTypes.bool, +}; diff --git a/x-pack/plugins/canvas/public/components/datatable/datatable.scss b/x-pack/plugins/canvas/public/components/datatable/datatable.scss new file mode 100644 index 0000000000000..1a34d59c3291e --- /dev/null +++ b/x-pack/plugins/canvas/public/components/datatable/datatable.scss @@ -0,0 +1,46 @@ +.canvasDataTable { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + justify-content: space-between; + + .canvasDataTable__tableWrapper { + @include euiScrollBar; + width: 100%; + height: 100%; + overflow: auto; + + // removes white square in the scrollbar corner + &::-webkit-scrollbar-corner { + background: transparent; + } + } + + .canvasDataTable__footer { + width: 100%; + display: flex; + justify-content: space-around; + } + + .canvasDataTable__tbody { + .canvasDataTable__tr:hover { + background-color: transparentize($euiColorLightShade, 0.5); + } + } + + .canvasDataTable__table { + min-width: 100%; + } + + .canvasDataTable__th, + .canvasDataTable__td { + text-align: left; + padding: $euiSizeS; + } + + .canvasDataTable__th { + white-space: nowrap; + font-weight: bold; + } +} diff --git a/x-pack/plugins/canvas/public/components/datatable/index.js b/x-pack/plugins/canvas/public/components/datatable/index.js new file mode 100644 index 0000000000000..c7837005368e5 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/datatable/index.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pure } from 'recompose'; +import { Datatable as Component } from './datatable'; + +export const Datatable = pure(Component); diff --git a/x-pack/plugins/canvas/public/components/debug/debug.js b/x-pack/plugins/canvas/public/components/debug/debug.js new file mode 100644 index 0000000000000..56faebe078ce5 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/debug/debug.js @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { EuiCode } from '@elastic/eui'; +export const Debug = ({ payload }) => ( + +
{JSON.stringify(payload, null, 2)}
+
+); + +Debug.propTypes = { + payload: PropTypes.object.isRequired, +}; diff --git a/x-pack/plugins/canvas/public/components/debug/debug.scss b/x-pack/plugins/canvas/public/components/debug/debug.scss new file mode 100644 index 0000000000000..fb59852ea8ae9 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/debug/debug.scss @@ -0,0 +1,13 @@ +.canvasDebug.euiCodeBlock { + padding: 0; + width: 100%; + height: 100%; + + .canvasDebug__content { + @include euiScrollBar; + width: 100%; + height: 100%; + overflow: auto; + padding: $euiSize; + } +} diff --git a/x-pack/plugins/canvas/public/components/debug/index.js b/x-pack/plugins/canvas/public/components/debug/index.js new file mode 100644 index 0000000000000..17ff1e1dbc150 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/debug/index.js @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { Debug } from './debug'; diff --git a/x-pack/plugins/canvas/public/components/dom_preview/dom_preview.js b/x-pack/plugins/canvas/public/components/dom_preview/dom_preview.js new file mode 100644 index 0000000000000..754a67482ee6a --- /dev/null +++ b/x-pack/plugins/canvas/public/components/dom_preview/dom_preview.js @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { debounce } from 'lodash'; + +export class DomPreview extends React.Component { + static container = null; + static content = null; + static observer = null; + + static propTypes = { + elementId: PropTypes.string.isRequired, + height: PropTypes.number.isRequired, + }; + + componentDidMount() { + const original = document.querySelector(`#${this.props.elementId}`); + + const update = this.update(original); + update(); + + const slowUpdate = debounce(update, 250); + + this.observer = new MutationObserver(slowUpdate); + // configuration of the observer + const config = { attributes: true, childList: true, subtree: true }; + // pass in the target node, as well as the observer options + this.observer.observe(original, config); + } + + componentWillUnmount() { + this.observer.disconnect(); + } + + update = original => () => { + const thumb = original.cloneNode(true); + + const originalStyle = window.getComputedStyle(original, null); + const originalWidth = parseInt(originalStyle.getPropertyValue('width'), 10); + const originalHeight = parseInt(originalStyle.getPropertyValue('height'), 10); + + const thumbHeight = this.props.height; + const scale = thumbHeight / originalHeight; + const thumbWidth = originalWidth * scale; + + if (this.content) { + if (this.content.hasChildNodes()) this.content.removeChild(this.content.firstChild); + this.content.appendChild(thumb); + } + + // Copy canvas data + const originalCanvas = original.querySelectorAll('canvas'); + const thumbCanvas = thumb.querySelectorAll('canvas'); + + // Cloned canvas elements are blank and need to be explicitly redrawn + if (originalCanvas.length > 0) { + Array.from(originalCanvas).map((img, i) => + thumbCanvas[i].getContext('2d').drawImage(img, 0, 0) + ); + } + + this.container.style.cssText = `width: ${thumbWidth}px; height: ${thumbHeight}px;`; + this.content.style.cssText = `transform: scale(${scale}); transform-origin: top left;`; + }; + + render() { + return ( +
{ + this.container = container; + }} + className="dom-preview" + > +
{ + this.content = content; + }} + /> +
+ ); + } +} diff --git a/x-pack/plugins/canvas/public/components/dom_preview/dom_preview.scss b/x-pack/plugins/canvas/public/components/dom_preview/dom_preview.scss new file mode 100644 index 0000000000000..1918fe80fe07c --- /dev/null +++ b/x-pack/plugins/canvas/public/components/dom_preview/dom_preview.scss @@ -0,0 +1,7 @@ +.dom-preview { + pointer-events: none; + + .canvasLayoutAnnotation { + display: none; + } +} diff --git a/x-pack/plugins/canvas/public/components/dom_preview/index.js b/x-pack/plugins/canvas/public/components/dom_preview/index.js new file mode 100644 index 0000000000000..283f92c7ecd9b --- /dev/null +++ b/x-pack/plugins/canvas/public/components/dom_preview/index.js @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { DomPreview as Component } from './dom_preview'; + +export const DomPreview = Component; diff --git a/x-pack/plugins/canvas/public/components/download/__tests__/download.js b/x-pack/plugins/canvas/public/components/download/__tests__/download.js new file mode 100644 index 0000000000000..2d2b40236fdf4 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/download/__tests__/download.js @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import expect from 'expect.js'; +import { render } from 'enzyme'; +import { Download } from '../'; + +describe('', () => { + it('has canvas_download class', () => { + const wrapper = render( + + + + ); + + expect(wrapper.hasClass('canvas_download')).to.be.ok; + }); +}); diff --git a/x-pack/plugins/canvas/public/components/download/download.js b/x-pack/plugins/canvas/public/components/download/download.js new file mode 100644 index 0000000000000..29b33f20e2105 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/download/download.js @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import fileSaver from 'file-saver'; +import { toByteArray } from 'base64-js'; +import { parse } from '../../../common/lib/dataurl'; + +export class Download extends React.PureComponent { + static propTypes = { + children: PropTypes.element.isRequired, + fileName: PropTypes.string, + content: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + onCopy: PropTypes.func, + }; + + onClick = () => { + const { fileName, content } = this.props; + const asset = parse(content, true); + const assetBlob = new Blob([toByteArray(asset.data)], { type: asset.mimetype }); + const ext = asset.extension ? `.${asset.extension}` : ''; + fileSaver.saveAs(assetBlob, `canvas-${fileName}${ext}`); + }; + + render() { + return ( +
+ {this.props.children} +
+ ); + } +} diff --git a/x-pack/plugins/canvas/public/components/download/index.js b/x-pack/plugins/canvas/public/components/download/index.js new file mode 100644 index 0000000000000..d8bc32ab98183 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/download/index.js @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Download as Component } from './download'; + +export const Download = Component; diff --git a/x-pack/plugins/canvas/public/components/element_content/element_content.js b/x-pack/plugins/canvas/public/components/element_content/element_content.js new file mode 100644 index 0000000000000..8eba2aa2ba438 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/element_content/element_content.js @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { pure, compose, branch, renderComponent } from 'recompose'; +import Style from 'style-it'; +import { getType } from '../../../common/lib/get_type'; +import { Loading } from '../loading'; +import { RenderWithFn } from '../render_with_fn'; +import { ElementShareContainer } from '../element_share_container'; +import { InvalidExpression } from './invalid_expression'; +import { InvalidElementType } from './invalid_element_type'; + +/* + Branches + Short circut rendering of the element if the element isn't ready or isn't valid. +*/ +const branches = [ + // no renderable or renderable config value, render loading + branch(({ renderable, state }) => { + return !state || !renderable; + }, renderComponent(Loading)), + + // renderable is available, but no matching element is found, render invalid + branch(({ renderable, renderFunction }) => { + return renderable && getType(renderable) !== 'render' && !renderFunction; + }, renderComponent(InvalidElementType)), + + // error state, render invalid expression notice + branch(({ renderable, renderFunction, state }) => { + return ( + state === 'error' || // The renderable has an error + getType(renderable) !== 'render' || // The renderable isn't, well, renderable + !renderFunction // We can't find an element in the registry for this + ); + }, renderComponent(InvalidExpression)), +]; + +export const ElementContent = compose( + pure, + ...branches +)(({ renderable, renderFunction, size, handlers }) => { + const { getFilter, setFilter, done, onComplete } = handlers; + + return Style.it( + renderable.css, +
+ + + +
+ ); +}); + +ElementContent.propTypes = { + renderable: PropTypes.shape({ + css: PropTypes.string, + value: PropTypes.object, + }), + renderFunction: PropTypes.shape({ + name: PropTypes.string, + render: PropTypes.func, + reuseDomNode: PropTypes.bool, + }), + size: PropTypes.object, + handlers: PropTypes.shape({ + setFilter: PropTypes.func.isRequired, + getFilter: PropTypes.func.isRequired, + done: PropTypes.func.isRequired, + onComplete: PropTypes.func.isRequired, // local, not passed through + }).isRequired, + state: PropTypes.string, +}; diff --git a/x-pack/plugins/canvas/public/components/element_content/element_content.scss b/x-pack/plugins/canvas/public/components/element_content/element_content.scss new file mode 100644 index 0000000000000..d27e759c63ea1 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/element_content/element_content.scss @@ -0,0 +1,10 @@ +.canvasElement { + height: 100%; + width: 100%; + overflow: hidden; +} + +.canvasElement__content { + height: 100%; + width: 100%; +} diff --git a/x-pack/plugins/canvas/public/components/element_content/index.js b/x-pack/plugins/canvas/public/components/element_content/index.js new file mode 100644 index 0000000000000..969e96a994a3b --- /dev/null +++ b/x-pack/plugins/canvas/public/components/element_content/index.js @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import PropTypes from 'prop-types'; +import { compose, withProps } from 'recompose'; +import { get } from 'lodash'; +import { renderFunctionsRegistry } from '../../lib/render_functions_registry'; +import { ElementContent as Component } from './element_content'; + +export const ElementContent = compose( + withProps(({ renderable }) => ({ + renderFunction: renderFunctionsRegistry.get(get(renderable, 'as')), + })) +)(Component); + +ElementContent.propTypes = { + renderable: PropTypes.shape({ + as: PropTypes.string, + }), +}; diff --git a/x-pack/plugins/canvas/public/components/element_content/invalid_element_type.js b/x-pack/plugins/canvas/public/components/element_content/invalid_element_type.js new file mode 100644 index 0000000000000..08d8a5294e9a4 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/element_content/invalid_element_type.js @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; + +export const InvalidElementType = ({ renderableType, selectElement }) => ( +

Element not found: {renderableType}

+); + +InvalidElementType.propTypes = { + renderableType: PropTypes.string, + selectElement: PropTypes.func, +}; diff --git a/x-pack/plugins/canvas/public/components/element_content/invalid_expression.js b/x-pack/plugins/canvas/public/components/element_content/invalid_expression.js new file mode 100644 index 0000000000000..0c17f9ac51732 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/element_content/invalid_expression.js @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; + +export const InvalidExpression = ({ selectElement }) => ( +

Invalid expression

+); + +InvalidExpression.propTypes = { + selectElement: PropTypes.func, +}; diff --git a/x-pack/plugins/canvas/public/components/element_share_container/element_share_container.js b/x-pack/plugins/canvas/public/components/element_share_container/element_share_container.js new file mode 100644 index 0000000000000..be52438f635fe --- /dev/null +++ b/x-pack/plugins/canvas/public/components/element_share_container/element_share_container.js @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; + +export class ElementShareContainer extends React.PureComponent { + static propTypes = { + functionName: PropTypes.string.isRequired, + onComplete: PropTypes.func.isRequired, + className: PropTypes.string, + children: PropTypes.node.isRequired, + }; + + state = { + renderComplete: false, + }; + + componentDidMount() { + const { functionName, onComplete } = this.props; + const isDevelopment = process.env.NODE_ENV !== 'production'; + let t; + + // check that the done event is called within a certain time + if (isDevelopment) { + const timeout = 15; // timeout, in seconds + t = setTimeout(() => { + // TODO: show this message in a proper notification + console.warn( + `done handler not called in render function after ${timeout} seconds: ${functionName}` + ); + }, timeout * 1000); + } + + // dispatches a custom DOM event on the container when the element is complete + onComplete(() => { + clearTimeout(t); + if (!this.sharedItemRef) return; // without this, crazy fast forward/backward paging leads to an error + const ev = new Event('renderComplete'); + this.sharedItemRef.dispatchEvent(ev); + + // if the element is finished before reporting is listening for then + // renderComplete event, the report never completes. to get around that + // issue, track the completed state locally and set the + // [data-render-complete] value accordingly. + // this is similar to renderComplete directive in Kibana, + // see: src/ui/public/render_complete/directive.js + this.setState({ renderComplete: true }); + }); + } + + render() { + // NOTE: the data-shared-item and data-render-complete attributes are used for reporting + return ( +
(this.sharedItemRef = ref)} + > + {this.props.children} +
+ ); + } +} diff --git a/x-pack/plugins/canvas/public/components/element_share_container/index.js b/x-pack/plugins/canvas/public/components/element_share_container/index.js new file mode 100644 index 0000000000000..e88ed641f3a8a --- /dev/null +++ b/x-pack/plugins/canvas/public/components/element_share_container/index.js @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { ElementShareContainer } from './element_share_container'; diff --git a/x-pack/plugins/canvas/public/components/element_types/element_types.js b/x-pack/plugins/canvas/public/components/element_types/element_types.js new file mode 100644 index 0000000000000..dde2d5c51f56f --- /dev/null +++ b/x-pack/plugins/canvas/public/components/element_types/element_types.js @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { + EuiFieldSearch, + EuiCard, + EuiFlexGroup, + EuiFlexGrid, + EuiFlexItem, + EuiModalHeader, + EuiModalBody, +} from '@elastic/eui'; +import lowerCase from 'lodash.lowercase'; +import { map, includes, sortBy } from 'lodash'; + +export const ElementTypes = ({ elements, onClick, search, setSearch }) => { + search = lowerCase(search); + elements = sortBy(map(elements, (element, name) => ({ name, ...element })), 'displayName'); + const elementList = map(elements, (element, name) => { + const { help, displayName, expression, filter, width, height, image } = element; + const whenClicked = () => onClick({ expression, filter, width, height }); + + // Add back in icon={image} to this when Design has a full icon set + const card = ( + + + + ); + + if (!search) return card; + if (includes(lowerCase(name), search)) return card; + if (includes(lowerCase(displayName), search)) return card; + if (includes(lowerCase(help), search)) return card; + return null; + }); + + return ( + + + + + setSearch(e.target.value)} + value={search} + /> + + + + + + {elementList} + + + + ); +}; + +ElementTypes.propTypes = { + elements: PropTypes.object, + onClick: PropTypes.func, + search: PropTypes.string, + setSearch: PropTypes.func, +}; diff --git a/x-pack/plugins/canvas/public/components/element_types/index.js b/x-pack/plugins/canvas/public/components/element_types/index.js new file mode 100644 index 0000000000000..19ad1358b2e06 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/element_types/index.js @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pure, compose, withProps, withState } from 'recompose'; +import { elementsRegistry } from '../../lib/elements_registry'; + +import { ElementTypes as Component } from './element_types'; + +const elementTypesState = withState('search', 'setSearch'); +const elementTypeProps = withProps(() => ({ elements: elementsRegistry.toJS() })); + +export const ElementTypes = compose( + pure, + elementTypesState, + elementTypeProps +)(Component); diff --git a/x-pack/plugins/canvas/public/components/element_wrapper/element_wrapper.js b/x-pack/plugins/canvas/public/components/element_wrapper/element_wrapper.js new file mode 100644 index 0000000000000..6f6452a954980 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/element_wrapper/element_wrapper.js @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { Positionable } from '../positionable'; +import { ElementContent } from '../element_content'; + +export class ElementWrapper extends React.PureComponent { + static propTypes = { + renderable: PropTypes.object, + transformMatrix: PropTypes.arrayOf(PropTypes.number).isRequired, + width: PropTypes.number.isRequired, + height: PropTypes.number.isRequired, + state: PropTypes.string, + createHandlers: PropTypes.func.isRequired, + }; + + state = { + handlers: null, + }; + + componentDidMount() { + // create handlers when component mounts, so it only creates one instance + const { createHandlers } = this.props; + // eslint-disable-next-line react/no-did-mount-set-state + this.setState({ handlers: createHandlers() }); + } + + render() { + // wait until the handlers have been created + if (!this.state.handlers) return null; + + const { renderable, transformMatrix, width, height, state } = this.props; + + return ( + + + + ); + } +} diff --git a/x-pack/plugins/canvas/public/components/element_wrapper/index.js b/x-pack/plugins/canvas/public/components/element_wrapper/index.js new file mode 100644 index 0000000000000..b932b3495db98 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/element_wrapper/index.js @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { getEditing } from '../../state/selectors/app'; +import { getResolvedArgs, getSelectedPage } from '../../state/selectors/workpad'; +import { getState, getValue, getError } from '../../lib/resolved_arg'; +import { ElementWrapper as Component } from './element_wrapper'; +import { createHandlers as createHandlersWithDispatch } from './lib/handlers'; + +const mapStateToProps = (state, { element }) => ({ + isEditing: getEditing(state), + resolvedArg: getResolvedArgs(state, element.id, 'expressionRenderable'), + selectedPage: getSelectedPage(state), +}); + +const mapDispatchToProps = (dispatch, { element }) => ({ + createHandlers: pageId => () => createHandlersWithDispatch(element, pageId, dispatch), +}); + +const mergeProps = (stateProps, dispatchProps, ownProps) => { + const { resolvedArg, selectedPage } = stateProps; + const { element, restProps } = ownProps; + const { id, transformMatrix, width, height } = element; + + return { + ...restProps, // pass through unused props + id, //pass through useful parts of the element object + transformMatrix, + width, + height, + state: getState(resolvedArg), + error: getError(resolvedArg), + renderable: getValue(resolvedArg), + createHandlers: dispatchProps.createHandlers(selectedPage), + }; +}; + +export const ElementWrapper = connect( + mapStateToProps, + mapDispatchToProps, + mergeProps +)(Component); + +ElementWrapper.propTypes = { + element: PropTypes.shape({ + id: PropTypes.string.isRequired, + transformMatrix: PropTypes.arrayOf(PropTypes.number).isRequired, + width: PropTypes.number.isRequired, + height: PropTypes.number.isRequired, + }).isRequired, +}; diff --git a/x-pack/plugins/canvas/public/components/element_wrapper/lib/handlers.js b/x-pack/plugins/canvas/public/components/element_wrapper/lib/handlers.js new file mode 100644 index 0000000000000..52b4041ccb074 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/element_wrapper/lib/handlers.js @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { setFilter } from '../../../state/actions/elements'; + +export function createHandlers(element, pageId, dispatch) { + let isComplete = false; + let completeFn = () => {}; + + return { + setFilter(text) { + dispatch(setFilter(text, element.id, pageId, true)); + }, + + getFilter() { + return element.filter; + }, + + onComplete(fn) { + completeFn = fn; + }, + + done() { + if (isComplete) return; // don't emit if the element is already done + isComplete = true; + completeFn(); + }, + }; +} diff --git a/x-pack/plugins/canvas/public/components/enhance/error_boundary.js b/x-pack/plugins/canvas/public/components/enhance/error_boundary.js new file mode 100644 index 0000000000000..61941143c4d47 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/enhance/error_boundary.js @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment } from 'react'; +import { withState, withHandlers, lifecycle, mapProps, compose } from 'recompose'; +import PropTypes from 'prop-types'; +import { omit } from 'lodash'; + +export const errorBoundaryHoc = compose( + withState('error', 'setError', null), + withState('errorInfo', 'setErrorInfo', null), + withHandlers({ + resetErrorState: ({ setError, setErrorInfo }) => () => { + setError(null); + setErrorInfo(null); + }, + }), + lifecycle({ + componentDidCatch(error, errorInfo) { + this.props.setError(error); + this.props.setErrorInfo(errorInfo); + }, + }), + mapProps(props => omit(props, ['setError', 'setErrorInfo'])) +); + +const ErrorBoundaryComponent = props => ( + + {props.children({ + error: props.error, + errorInfo: props.errorInfo, + resetErrorState: props.resetErrorState, + })} + +); + +ErrorBoundaryComponent.propTypes = { + children: PropTypes.func.isRequired, + error: PropTypes.object, + errorInfo: PropTypes.object, + resetErrorState: PropTypes.func.isRequired, +}; + +export const ErrorBoundary = errorBoundaryHoc(ErrorBoundaryComponent); diff --git a/x-pack/plugins/canvas/public/components/enhance/stateful_prop.js b/x-pack/plugins/canvas/public/components/enhance/stateful_prop.js new file mode 100644 index 0000000000000..ba679c4a3940c --- /dev/null +++ b/x-pack/plugins/canvas/public/components/enhance/stateful_prop.js @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; + +const getDisplayName = Comp => Comp.displayName || Comp.name || 'UnnamedComponent'; + +export function createStatefulPropHoc(fieldname, updater = 'updateValue') { + return Comp => { + class WrappedControlledInput extends React.PureComponent { + constructor(props) { + super(props); + + this.state = { + value: props[fieldname], + }; + } + + componentWillReceiveProps(nextProps) { + this.setState({ value: nextProps[fieldname] }); + } + + handleChange = ev => { + if (ev.target) this.setState({ value: ev.target.value }); + else this.setState({ value: ev }); + }; + + render() { + const passedProps = { + ...this.props, + [fieldname]: this.state.value, + [updater]: this.handleChange, + }; + + return ; + } + } + + WrappedControlledInput.propTypes = { + [fieldname]: PropTypes.any, + }; + + // set the display name of the wrapped component, for easier debugging + WrappedControlledInput.displayName = `statefulProp(${getDisplayName(Comp)})`; + + return WrappedControlledInput; + }; +} diff --git a/x-pack/plugins/canvas/public/components/error/error.js b/x-pack/plugins/canvas/public/components/error/error.js new file mode 100644 index 0000000000000..c37780657ba29 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/error/error.js @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { EuiCallOut } from '@elastic/eui'; +import { get } from 'lodash'; +import { ShowDebugging } from './show_debugging'; + +export const Error = ({ payload }) => { + const functionName = get(payload, 'info.functionName'); + const message = get(payload, 'error.message'); + + return ( + +

+ The function "{functionName}" failed + {message ? ' with the following message:' : '.'} +

+ {message &&

{message}

} + + +
+ ); +}; + +Error.propTypes = { + payload: PropTypes.shape({ + info: PropTypes.object.isRequired, + error: PropTypes.object.isRequired, + }).isRequired, +}; diff --git a/x-pack/plugins/canvas/public/components/error/index.js b/x-pack/plugins/canvas/public/components/error/index.js new file mode 100644 index 0000000000000..08d4b9911f556 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/error/index.js @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { Error } from './error'; diff --git a/x-pack/plugins/canvas/public/components/error/show_debugging.js b/x-pack/plugins/canvas/public/components/error/show_debugging.js new file mode 100644 index 0000000000000..102ebc3f8a7e8 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/error/show_debugging.js @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { withState } from 'recompose'; +import { EuiButtonEmpty } from '@elastic/eui'; +import { Debug } from '../debug'; + +const ShowDebuggingComponent = ({ payload, expanded, setExpanded }) => + process.env.NODE_ENV === 'production' ? null : ( +
+ setExpanded(!expanded)} + > + See Details + + {expanded && ( +
+ +
+ )} +
+ ); + +ShowDebuggingComponent.propTypes = { + expanded: PropTypes.bool.isRequired, + setExpanded: PropTypes.func.isRequired, + payload: PropTypes.object.isRequired, +}; + +export const ShowDebugging = withState('expanded', 'setExpanded', false)(ShowDebuggingComponent); diff --git a/x-pack/plugins/canvas/public/components/es_field_select/es_field_select.js b/x-pack/plugins/canvas/public/components/es_field_select/es_field_select.js new file mode 100644 index 0000000000000..3f936b3aca637 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/es_field_select/es_field_select.js @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { EuiComboBox } from '@elastic/eui'; +import { get } from 'lodash'; + +export const ESFieldSelect = ({ value, fields = [], onChange, onFocus, onBlur }) => { + const selectedOption = value ? [{ label: value }] : []; + const options = fields.map(field => ({ label: field })); + + return ( + onChange(get(field, 'label', null))} + onSearchChange={searchValue => { + // resets input when user starts typing + if (searchValue) onChange(null); + }} + onFocus={onFocus} + onBlur={onBlur} + singleSelection + isClearable={false} + /> + ); +}; + +ESFieldSelect.propTypes = { + onChange: PropTypes.func, + onFocus: PropTypes.func, + onBlur: PropTypes.func, + value: PropTypes.string, + fields: PropTypes.array, +}; + +ESFieldSelect.defaultProps = { + fields: [], +}; diff --git a/x-pack/plugins/canvas/public/components/es_field_select/index.js b/x-pack/plugins/canvas/public/components/es_field_select/index.js new file mode 100644 index 0000000000000..fc39b626d62cd --- /dev/null +++ b/x-pack/plugins/canvas/public/components/es_field_select/index.js @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { compose, withState, lifecycle } from 'recompose'; +import { getFields } from '../../lib/es_service'; +import { ESFieldSelect as Component } from './es_field_select'; + +export const ESFieldSelect = compose( + withState('fields', 'setFields', []), + lifecycle({ + componentDidMount() { + if (this.props.index) getFields(this.props.index).then(this.props.setFields); + }, + componentDidUpdate({ index }) { + const { value, onChange, setFields } = this.props; + if (this.props.index !== index) { + getFields(this.props.index).then(fields => { + setFields(fields); + }); + } + + if (value && !this.props.fields.includes(value)) onChange(null); + }, + }) +)(Component); diff --git a/x-pack/plugins/canvas/public/components/es_fields_select/es_fields_select.js b/x-pack/plugins/canvas/public/components/es_fields_select/es_fields_select.js new file mode 100644 index 0000000000000..fedb4aba7d3d0 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/es_fields_select/es_fields_select.js @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { EuiComboBox } from '@elastic/eui'; + +export const ESFieldsSelect = ({ selected, fields, onChange, onFocus, onBlur }) => { + const options = fields.map(value => ({ + label: value, + })); + + const selectedOptions = selected.map(value => ({ + label: value, + })); + + return ( + onChange(values.map(({ label }) => label))} + className="canvasFieldsSelect" + onFocus={onFocus} + onBlur={onBlur} + /> + ); +}; + +ESFieldsSelect.propTypes = { + onChange: PropTypes.func, + selected: PropTypes.array, + fields: PropTypes.array, + onFocus: PropTypes.func, + onBlur: PropTypes.func, +}; + +ESFieldsSelect.defaultProps = { + selected: [], + fields: [], +}; diff --git a/x-pack/plugins/canvas/public/components/es_fields_select/index.js b/x-pack/plugins/canvas/public/components/es_fields_select/index.js new file mode 100644 index 0000000000000..8a73666dc7865 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/es_fields_select/index.js @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { compose, withState, lifecycle } from 'recompose'; +import { getFields } from '../../lib/es_service'; +import { ESFieldsSelect as Component } from './es_fields_select'; + +export const ESFieldsSelect = compose( + withState('fields', 'setFields', []), + lifecycle({ + componentDidMount() { + if (this.props.index) + getFields(this.props.index).then((fields = []) => this.props.setFields(fields)); + }, + componentDidUpdate({ index }) { + const { setFields, onChange, selected } = this.props; + if (this.props.index !== index) { + getFields(this.props.index).then((fields = []) => { + setFields(fields); + onChange(selected.filter(option => fields.includes(option))); + }); + } + }, + }) +)(Component); diff --git a/x-pack/plugins/canvas/public/components/es_index_select/es_index_select.js b/x-pack/plugins/canvas/public/components/es_index_select/es_index_select.js new file mode 100644 index 0000000000000..d871ba1b7d9b1 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/es_index_select/es_index_select.js @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { EuiComboBox } from '@elastic/eui'; +import { get } from 'lodash'; + +const defaultIndex = '_all'; + +export const ESIndexSelect = ({ value, loading, indices, onChange, onFocus, onBlur }) => { + const selectedOption = value !== defaultIndex ? [{ label: value }] : []; + const options = indices.map(index => ({ label: index })); + + return ( + onChange(get(index, 'label', defaultIndex).toLowerCase())} + onSearchChange={searchValue => { + // resets input when user starts typing + if (searchValue) onChange(defaultIndex); + }} + onBlur={onBlur} + onFocus={onFocus} + disabled={loading} + options={options} + singleSelection + isClearable={false} + onCreateOption={input => onChange(input || defaultIndex)} + /> + ); +}; + +ESIndexSelect.propTypes = { + value: PropTypes.string, + onChange: PropTypes.func.isRequired, + onFocus: PropTypes.func, + onBlur: PropTypes.func, + indices: PropTypes.array.isRequired, + loading: PropTypes.bool.isRequired, +}; + +ESIndexSelect.defaultProps = { + value: defaultIndex, +}; diff --git a/x-pack/plugins/canvas/public/components/es_index_select/index.js b/x-pack/plugins/canvas/public/components/es_index_select/index.js new file mode 100644 index 0000000000000..ae85b562d006c --- /dev/null +++ b/x-pack/plugins/canvas/public/components/es_index_select/index.js @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { compose, withState, lifecycle } from 'recompose'; +import { getIndices } from '../../lib/es_service'; +import { ESIndexSelect as Component } from './es_index_select'; + +export const ESIndexSelect = compose( + withState('loading', 'setLoading', true), + withState('indices', 'setIndices', []), + lifecycle({ + componentDidMount() { + getIndices().then((indices = []) => { + const { setLoading, setIndices, value, onChange } = this.props; + setLoading(false); + setIndices(indices.sort()); + if (!value && indices.length) onChange(indices[0]); + }); + }, + }) +)(Component); diff --git a/x-pack/plugins/canvas/public/components/expression/element_not_selected.js b/x-pack/plugins/canvas/public/components/expression/element_not_selected.js new file mode 100644 index 0000000000000..a9858264fab31 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/expression/element_not_selected.js @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { EuiButton } from '@elastic/eui'; + +export const ElementNotSelected = ({ done }) => ( +
+
Select an element to show expression input
+ {done && ( + + {' '} + Close + + )} +
+); + +ElementNotSelected.propTypes = { + done: PropTypes.func, +}; diff --git a/x-pack/plugins/canvas/public/components/expression/expression.js b/x-pack/plugins/canvas/public/components/expression/expression.js new file mode 100644 index 0000000000000..d901529616abc --- /dev/null +++ b/x-pack/plugins/canvas/public/components/expression/expression.js @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { + EuiPanel, + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, +} from '@elastic/eui'; +import { ExpressionInput } from '../expression_input'; + +export const Expression = ({ formState, updateValue, setExpression, done, error }) => { + return ( + + + + + + + {formState.dirty ? 'Cancel' : 'Close'} + + + + setExpression(formState.expression)} + size="s" + > + Run + + + + + ); +}; + +Expression.propTypes = { + formState: PropTypes.object, + updateValue: PropTypes.func, + setExpression: PropTypes.func, + done: PropTypes.func, + error: PropTypes.string, +}; diff --git a/x-pack/plugins/canvas/public/components/expression/index.js b/x-pack/plugins/canvas/public/components/expression/index.js new file mode 100644 index 0000000000000..d5f706765380f --- /dev/null +++ b/x-pack/plugins/canvas/public/components/expression/index.js @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { connect } from 'react-redux'; +import { + compose, + withState, + withHandlers, + lifecycle, + withPropsOnChange, + branch, + renderComponent, +} from 'recompose'; +import { getSelectedPage, getSelectedElement } from '../../state/selectors/workpad'; +import { setExpression, flushContext } from '../../state/actions/elements'; +import { fromExpression } from '../../../common/lib/ast'; +import { ElementNotSelected } from './element_not_selected'; +import { Expression as Component } from './expression'; + +const mapStateToProps = state => ({ + pageId: getSelectedPage(state), + element: getSelectedElement(state), +}); + +const mapDispatchToProps = dispatch => ({ + setExpression: (elementId, pageId) => expression => { + // destroy the context cache + dispatch(flushContext(elementId)); + + // update the element's expression + dispatch(setExpression(expression, elementId, pageId)); + }, +}); + +const mergeProps = (stateProps, dispatchProps, ownProps) => { + const { element, pageId } = stateProps; + const allProps = { ...ownProps, ...stateProps, ...dispatchProps }; + + if (!element) return allProps; + + const { expression } = element; + + return { + ...allProps, + expression, + setExpression: dispatchProps.setExpression(element.id, pageId), + }; +}; + +const expressionLifecycle = lifecycle({ + componentWillReceiveProps({ formState, setFormState, expression }) { + if (this.props.expression !== expression && expression !== formState.expression) { + setFormState({ + expression, + dirty: false, + }); + } + }, +}); + +export const Expression = compose( + connect( + mapStateToProps, + mapDispatchToProps, + mergeProps + ), + withState('formState', 'setFormState', ({ expression }) => ({ + expression, + dirty: false, + })), + withHandlers({ + updateValue: ({ setFormState }) => expression => { + setFormState({ + expression, + dirty: true, + }); + }, + setExpression: ({ setExpression, setFormState }) => exp => { + setFormState(prev => ({ + ...prev, + dirty: false, + })); + setExpression(exp); + }, + }), + expressionLifecycle, + withPropsOnChange(['formState'], ({ formState }) => ({ + error: (function() { + try { + // TODO: We should merge the advanced UI input and this into a single validated expression input. + fromExpression(formState.expression); + return null; + } catch (e) { + return e.message; + } + })(), + })), + branch(props => !props.element, renderComponent(ElementNotSelected)) +)(Component); diff --git a/x-pack/plugins/canvas/public/components/expression_input/expression_input.js b/x-pack/plugins/canvas/public/components/expression_input/expression_input.js new file mode 100644 index 0000000000000..1f9a8a9c71453 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/expression_input/expression_input.js @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { EuiTextArea, EuiFormRow } from '@elastic/eui'; +import { ContextMenu } from '../context_menu'; +import { matchPairsProvider } from './match_pairs'; +import { Suggestion } from './suggestion'; + +export class ExpressionInput extends React.Component { + constructor({ value, onChange }) { + super(); + this.state = { + selection: { + start: value.length, + end: value.length, + }, + suggestions: [], + }; + + this.matchPairs = matchPairsProvider({ + setValue: onChange, + setSelection: selection => this.setState({ selection }), + }); + } + + componentDidUpdate() { + if (!this.ref) return; + const { selection } = this.state; + const { start, end } = selection; + this.ref.setSelectionRange(start, end); + } + + onChange = e => { + const { target } = e; + const { value, selectionStart, selectionEnd } = target; + const selection = { + start: selectionStart, + end: selectionEnd, + }; + this.updateState({ value, selection }); + }; + + onSuggestionSelect = suggestion => { + const value = + this.props.value.substr(0, suggestion.location.start) + + suggestion.value + + this.props.value.substr(suggestion.location.end); + const selection = { + start: suggestion.location.start + suggestion.value.length, + end: suggestion.location.start + suggestion.value.length, + }; + this.updateState({ value, selection }); + }; + + updateState = ({ value, selection }) => { + const suggestions = []; + this.props.onChange(value); + this.setState({ selection, suggestions }); + }; + + // TODO: Use a hidden div and measure it rather than using hardcoded values + getContextMenuItemsStyle = () => { + const { value } = this.props; + const { + selection: { end }, + } = this.state; + const numberOfNewlines = value.substr(0, end).split('\n').length; + const padding = 12; + const lineHeight = 22; + const textareaHeight = 200; + const top = Math.min(padding + numberOfNewlines * lineHeight, textareaHeight) + 'px'; + return { top }; + }; + + render() { + const { value, error } = this.props; + const { suggestions } = this.state; + + const helpText = error + ? null + : 'This is the coded expression that backs this element. You better know what you are doing here.'; + return ( +
+ + + (this.ref = ref)} + /> + + +
+ ); + } +} + +ExpressionInput.propTypes = { + value: PropTypes.string, + onChange: PropTypes.func, + error: PropTypes.string, +}; diff --git a/x-pack/plugins/canvas/public/components/expression_input/index.js b/x-pack/plugins/canvas/public/components/expression_input/index.js new file mode 100644 index 0000000000000..4c27768b5a81b --- /dev/null +++ b/x-pack/plugins/canvas/public/components/expression_input/index.js @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { ExpressionInput } from './expression_input'; diff --git a/x-pack/plugins/canvas/public/components/expression_input/match_pairs.js b/x-pack/plugins/canvas/public/components/expression_input/match_pairs.js new file mode 100644 index 0000000000000..01d96f7304b75 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/expression_input/match_pairs.js @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/** + * Provides an `onKeyDown` handler that automatically inserts matching pairs. + * Specifically, it does the following: + * + * 1. If we don't have a multi-character selection, and the key is a closer, + * and the character in front of the cursor is the same, simply move the + * cursor forward. + * 2. If the key is an opener, insert the opener at the beginning of the + * selection, and the closer at the end of the selection, and move the + * selection forward. + * 3. If we don't have a multi-character selection, and the backspace is hit, + * and the characters before and after the cursor correspond to a pair, + * remove both characters and move the cursor backward. + */ +export const matchPairsProvider = ({ + pairs = ['()', '[]', '{}', `''`, '""'], + setValue, + setSelection, +}) => { + const openers = pairs.map(pair => pair[0]); + const closers = pairs.map(pair => pair[1]); + return e => { + const { target, key } = e; + const { value, selectionStart, selectionEnd } = target; + if ( + selectionStart === selectionEnd && + closers.includes(key) && + value.charAt(selectionEnd) === key + ) { + // 1. (See above) + e.preventDefault(); + setSelection({ start: selectionStart + 1, end: selectionEnd + 1 }); + } else if (openers.includes(key)) { + // 2. (See above) + e.preventDefault(); + setValue( + value.substr(0, selectionStart) + + key + + value.substring(selectionStart, selectionEnd) + + closers[openers.indexOf(key)] + + value.substr(selectionEnd) + ); + setSelection({ start: selectionStart + 1, end: selectionEnd + 1 }); + } else if ( + selectionStart === selectionEnd && + key === 'Backspace' && + !e.metaKey && + pairs.includes(value.substr(selectionEnd - 1, 2)) + ) { + // 3. (See above) + e.preventDefault(); + setValue(value.substr(0, selectionEnd - 1) + value.substr(selectionEnd + 1)); + setSelection({ start: selectionStart - 1, end: selectionEnd - 1 }); + } + }; +}; diff --git a/x-pack/plugins/canvas/public/components/expression_input/suggestion.js b/x-pack/plugins/canvas/public/components/expression_input/suggestion.js new file mode 100644 index 0000000000000..ec8b6467a6ad9 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/expression_input/suggestion.js @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; + +export const Suggestion = ({ item }) => ( +
+
{item.name}
+
{item.description}
+
+); + +Suggestion.propTypes = { + item: PropTypes.object, +}; diff --git a/x-pack/plugins/canvas/public/components/expression_input/suggestion.scss b/x-pack/plugins/canvas/public/components/expression_input/suggestion.scss new file mode 100644 index 0000000000000..f1748578d1ffa --- /dev/null +++ b/x-pack/plugins/canvas/public/components/expression_input/suggestion.scss @@ -0,0 +1,12 @@ +.canvasExpressionSuggestion { + display: flex; + + .canvasExpressionSuggestion__name { + width: 120px; + font-weight: bold; + } + + .canvasExpressionSuggestion__desc { + width: calc(100% - 120px); + } +} diff --git a/x-pack/plugins/canvas/public/components/faux_select/faux_select.js b/x-pack/plugins/canvas/public/components/faux_select/faux_select.js new file mode 100644 index 0000000000000..87b7f3fddb27e --- /dev/null +++ b/x-pack/plugins/canvas/public/components/faux_select/faux_select.js @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { PropTypes } from 'prop-types'; +import { EuiIcon, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +//TODO: remove this when EUI has a better select component +export const FauxSelect = ({ handleClick, children }) => ( + + {children} + + + + +); + +FauxSelect.propTypes = { + handleClick: PropTypes.func, + children: PropTypes.node, +}; diff --git a/x-pack/plugins/canvas/public/components/faux_select/index.js b/x-pack/plugins/canvas/public/components/faux_select/index.js new file mode 100644 index 0000000000000..a4c41927ec423 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/faux_select/index.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pure } from 'recompose'; +import { FauxSelect as Component } from './faux_select'; + +export const FauxSelect = pure(Component); diff --git a/x-pack/plugins/canvas/public/components/file_upload/file_upload.js b/x-pack/plugins/canvas/public/components/file_upload/file_upload.js new file mode 100644 index 0000000000000..9640ab01bd158 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/file_upload/file_upload.js @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { EuiFilePicker } from '@elastic/eui'; + +export const FileUpload = ({ id = '', className = 'canvasFileUpload', onUpload }) => ( + +); + +FileUpload.propTypes = { + id: PropTypes.string, + className: PropTypes.string, + onUpload: PropTypes.func.isRequired, +}; diff --git a/x-pack/plugins/canvas/public/components/file_upload/index.js b/x-pack/plugins/canvas/public/components/file_upload/index.js new file mode 100644 index 0000000000000..68c0f6150521b --- /dev/null +++ b/x-pack/plugins/canvas/public/components/file_upload/index.js @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { FileUpload } from './file_upload'; diff --git a/x-pack/plugins/canvas/public/components/font_picker/font_picker.js b/x-pack/plugins/canvas/public/components/font_picker/font_picker.js new file mode 100644 index 0000000000000..ac49383f466ef --- /dev/null +++ b/x-pack/plugins/canvas/public/components/font_picker/font_picker.js @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { EuiLink } from '@elastic/eui'; +import { fonts } from '../../../common/lib/fonts'; +import { Popover } from '../popover'; +import { FauxSelect } from '../faux_select'; + +export const FontPicker = ({ onSelect, value, anchorPosition }) => { + const selected = fonts.find(font => font.value === value) || { label: value, value }; + + // TODO: replace faux select with better EUI custom select or dropdown when it becomes available + const popoverButton = handleClick => ( + +
{selected.label}
+
+ ); + + return ( + + {() => ( +
+ {fonts.map(font => ( + onSelect(font.value)} + > + {font.label} + + ))} +
+ )} +
+ ); +}; + +FontPicker.propTypes = { + value: PropTypes.string, + onSelect: PropTypes.func, + anchorPosition: PropTypes.string, +}; diff --git a/x-pack/plugins/canvas/public/components/font_picker/font_picker.scss b/x-pack/plugins/canvas/public/components/font_picker/font_picker.scss new file mode 100644 index 0000000000000..a777f74217f89 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/font_picker/font_picker.scss @@ -0,0 +1,20 @@ +.canvasFontPicker__wrapper { + // hacky fix until we can use EUI super select + > .euiPopover__anchor { + width: 100%; + } +} + +.canvasFontPicker { + @include euiScrollBar; + + height: 200px; + overflow-y: scroll; + + .canvasFontPicker__font { + display: block; + width: 100%; + padding: $euiSizeXS $euiSize; + color: black; + } +} diff --git a/x-pack/plugins/canvas/public/components/font_picker/index.js b/x-pack/plugins/canvas/public/components/font_picker/index.js new file mode 100644 index 0000000000000..5ccb7846b7a77 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/font_picker/index.js @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pure } from 'recompose'; + +import { FontPicker as Component } from './font_picker'; + +export const FontPicker = pure(Component); diff --git a/x-pack/plugins/canvas/public/components/fullscreen/fullscreen.js b/x-pack/plugins/canvas/public/components/fullscreen/fullscreen.js new file mode 100644 index 0000000000000..0c0fd11a842b2 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/fullscreen/fullscreen.js @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { debounce } from 'lodash'; +import { getWindow } from '../../lib/get_window'; + +export class Fullscreen extends React.Component { + static propTypes = { + isFullscreen: PropTypes.bool, + children: PropTypes.func, + }; + + state = { + width: 0, + height: 0, + }; + + componentWillMount() { + this.win = getWindow(); + this.setState({ + width: this.win.innerWidth, + height: this.win.innerHeight, + }); + } + + componentDidMount() { + this.win.addEventListener('resize', this.onWindowResize); + } + + componentWillUnmount() { + this.win.removeEventListener('resize', this.onWindowResize); + } + + getWindowSize = () => ({ + width: this.win.innerWidth, + height: this.win.innerHeight, + }); + + onWindowResize = debounce(() => { + const { width, height } = this.getWindowSize(); + this.setState({ width, height }); + }, 100); + + render() { + const { isFullscreen, children } = this.props; + const windowSize = { + width: this.state.width, + height: this.state.height, + }; + + return children({ isFullscreen, windowSize }); + } +} diff --git a/x-pack/plugins/canvas/public/components/fullscreen/fullscreen.scss b/x-pack/plugins/canvas/public/components/fullscreen/fullscreen.scss new file mode 100644 index 0000000000000..dfc392163cb6a --- /dev/null +++ b/x-pack/plugins/canvas/public/components/fullscreen/fullscreen.scss @@ -0,0 +1,51 @@ +body.canvas-isFullscreen { + // this is a hack that overwrites Kibana's core chrome + .global-nav.is-global-nav-open + .app-wrapper, + .app-wrapper { + left: 0; + } + + // set the background color + .canvasLayout { + background: #000; // This hex is OK, we always want it black + } + + // hide all the interface parts + nav.global-nav, + .canvasLayout__stageHeader, + .canvasLayout__sidebar, + .canvasLayout__footer, + .canvasGrid { + display: none; + } + + .canvasLayout__stageContentOverflow { + overflow: visible; + position: static; + top: auto; + left: auto; + right: auto; + bottom: auto; + + .canvasWorkpad__buffer { + padding: 0; + margin: 0; + } + } + + .canvasLayout__rows, + .canvasLayout__cols { + align-items: center; + justify-content: center; + } + + .canvasCheckered { + display: flex; + background: none; + } + + .canvasPage { + box-shadow: none; + overflow: hidden; + } +} diff --git a/x-pack/plugins/canvas/public/components/fullscreen/index.js b/x-pack/plugins/canvas/public/components/fullscreen/index.js new file mode 100644 index 0000000000000..8087644b51655 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/fullscreen/index.js @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { connect } from 'react-redux'; +import { getFullscreen } from '../../state/selectors/app'; +import { Fullscreen as Component } from './fullscreen'; + +const mapStateToProps = state => ({ + isFullscreen: getFullscreen(state), +}); + +export const Fullscreen = connect(mapStateToProps)(Component); diff --git a/x-pack/plugins/canvas/public/components/fullscreen_control/fullscreen_control.js b/x-pack/plugins/canvas/public/components/fullscreen_control/fullscreen_control.js new file mode 100644 index 0000000000000..3b56129c27417 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/fullscreen_control/fullscreen_control.js @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { Shortcuts } from 'react-shortcuts'; + +export class FullscreenControl extends React.PureComponent { + toggleFullscreen = () => { + const { setFullscreen, isFullscreen } = this.props; + setFullscreen(!isFullscreen); + }; + + render() { + const { children, isFullscreen } = this.props; + + const keyHandler = action => { + if (action === 'FULLSCREEN' || (isFullscreen && action === 'FULLSCREEN_EXIT')) + this.toggleFullscreen(); + }; + + return ( + + + {children({ isFullscreen, toggleFullscreen: this.toggleFullscreen })} + + ); + } +} + +FullscreenControl.propTypes = { + setFullscreen: PropTypes.func.isRequired, + isFullscreen: PropTypes.bool.isRequired, + children: PropTypes.func.isRequired, +}; diff --git a/x-pack/plugins/canvas/public/components/fullscreen_control/index.js b/x-pack/plugins/canvas/public/components/fullscreen_control/index.js new file mode 100644 index 0000000000000..2daaaf0425345 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/fullscreen_control/index.js @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { connect } from 'react-redux'; +import { setFullscreen, selectElement } from '../../state/actions/transient'; +import { getFullscreen } from '../../state/selectors/app'; +import { FullscreenControl as Component } from './fullscreen_control'; + +const mapStateToProps = state => ({ + isFullscreen: getFullscreen(state), +}); + +const mapDispatchToProps = dispatch => ({ + setFullscreen: value => { + dispatch(setFullscreen(value)); + value && dispatch(selectElement(null)); + }, +}); + +export const FullscreenControl = connect( + mapStateToProps, + mapDispatchToProps +)(Component); diff --git a/x-pack/plugins/canvas/public/components/function_form/function_form.js b/x-pack/plugins/canvas/public/components/function_form/function_form.js new file mode 100644 index 0000000000000..5c8b745d17bd7 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/function_form/function_form.js @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import PropTypes from 'prop-types'; +import { compose, branch, renderComponent } from 'recompose'; +import { FunctionFormComponent } from './function_form_component'; +import { FunctionUnknown } from './function_unknown'; +import { FunctionFormContextPending } from './function_form_context_pending'; +import { FunctionFormContextError } from './function_form_context_error'; + +// helper to check the state of the passed in expression type +function checkState(state) { + return ({ context, expressionType }) => { + const matchState = !context || context.state === state; + return expressionType && expressionType.requiresContext && matchState; + }; +} + +// alternate render paths based on expression state +const branches = [ + // if no expressionType was provided, render the ArgTypeUnknown component + branch(props => !props.expressionType, renderComponent(FunctionUnknown)), + // if the expressionType is in a pending state, render ArgTypeContextPending + branch(checkState('pending'), renderComponent(FunctionFormContextPending)), + // if the expressionType is in an error state, render ArgTypeContextError + branch(checkState('error'), renderComponent(FunctionFormContextError)), +]; + +export const FunctionForm = compose(...branches)(FunctionFormComponent); + +FunctionForm.propTypes = { + expressionType: PropTypes.object, + context: PropTypes.object, + expressionType: PropTypes.object, +}; diff --git a/x-pack/plugins/canvas/public/components/function_form/function_form.scss b/x-pack/plugins/canvas/public/components/function_form/function_form.scss new file mode 100644 index 0000000000000..1c0443228d230 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/function_form/function_form.scss @@ -0,0 +1,6 @@ +.canvasFunctionForm { + & > .canvasLoading { + text-align: center; + font-size: 20px; + } +} diff --git a/x-pack/plugins/canvas/public/components/function_form/function_form_component.js b/x-pack/plugins/canvas/public/components/function_form/function_form_component.js new file mode 100644 index 0000000000000..4d4132bd2fc25 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/function_form/function_form_component.js @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; + +export const FunctionFormComponent = props => { + const passedProps = { + argResolver: props.argResolver, + args: props.args, + argType: props.argType, + argTypeDef: props.argTypeDef, + context: props.context, + expressionIndex: props.expressionIndex, + expressionType: props.expressionType, + nextArgType: props.nextArgType, + nextExpressionType: props.nextExpressionType, + onAssetAdd: props.onAssetAdd, + onValueAdd: props.onValueAdd, + onValueChange: props.onValueChange, + onValueRemove: props.onValueRemove, + }; + + return
{props.expressionType.render(passedProps)}
; +}; + +FunctionFormComponent.propTypes = { + // props passed into expression type render functions + argResolver: PropTypes.func.isRequired, + args: PropTypes.object.isRequired, + argType: PropTypes.string.isRequired, + argTypeDef: PropTypes.object.isRequired, + context: PropTypes.object, + expressionIndex: PropTypes.number.isRequired, + expressionType: PropTypes.object.isRequired, + nextArgType: PropTypes.string, + nextExpressionType: PropTypes.object, + onAssetAdd: PropTypes.func.isRequired, + onValueAdd: PropTypes.func.isRequired, + onValueChange: PropTypes.func.isRequired, + onValueChange: PropTypes.func.isRequired, + onValueRemove: PropTypes.func.isRequired, + onValueRemove: PropTypes.func.isRequired, +}; diff --git a/x-pack/plugins/canvas/public/components/function_form/function_form_context_error.js b/x-pack/plugins/canvas/public/components/function_form/function_form_context_error.js new file mode 100644 index 0000000000000..513d5a4138ad8 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/function_form/function_form_context_error.js @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; + +export const FunctionFormContextError = ({ context }) => ( +
ERROR: {context.error}
+); + +FunctionFormContextError.propTypes = { + context: PropTypes.object, +}; diff --git a/x-pack/plugins/canvas/public/components/function_form/function_form_context_pending.js b/x-pack/plugins/canvas/public/components/function_form/function_form_context_pending.js new file mode 100644 index 0000000000000..f70d39e511924 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/function_form/function_form_context_pending.js @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { Loading } from '../loading'; + +export class FunctionFormContextPending extends React.PureComponent { + static propTypes = { + context: PropTypes.object, + contextExpression: PropTypes.string, + expressionType: PropTypes.object.isRequired, + updateContext: PropTypes.func.isRequired, + }; + + componentDidMount() { + this.fetchContext(this.props); + } + + componentWillReceiveProps(newProps) { + const oldContext = this.props.contextExpression; + const newContext = newProps.contextExpression; + const forceUpdate = newProps.expressionType.requiresContext && oldContext !== newContext; + this.fetchContext(newProps, forceUpdate); + } + + fetchContext = (props, force = false) => { + // dispatch context update if none is provided + const { expressionType, context, updateContext } = props; + if (force || (context == null && expressionType.requiresContext)) updateContext(); + }; + + render() { + return ( +
+ +
+ ); + } +} diff --git a/x-pack/plugins/canvas/public/components/function_form/function_unknown.js b/x-pack/plugins/canvas/public/components/function_form/function_unknown.js new file mode 100644 index 0000000000000..462fce9f1c90a --- /dev/null +++ b/x-pack/plugins/canvas/public/components/function_form/function_unknown.js @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; + +export const FunctionUnknown = ({ argType }) => ( +
+ Unknown expression type "{argType}" +
+); + +FunctionUnknown.propTypes = { + argType: PropTypes.string, +}; diff --git a/x-pack/plugins/canvas/public/components/function_form/index.js b/x-pack/plugins/canvas/public/components/function_form/index.js new file mode 100644 index 0000000000000..0f7b348eea78a --- /dev/null +++ b/x-pack/plugins/canvas/public/components/function_form/index.js @@ -0,0 +1,115 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { findExpressionType } from '../../lib/find_expression_type'; +import { getId } from '../../lib/get_id'; +import { createAsset } from '../../state/actions/assets'; +import { + fetchContext, + setArgumentAtIndex, + addArgumentValueAtIndex, + deleteArgumentAtIndex, +} from '../../state/actions/elements'; +import { + getSelectedElement, + getSelectedPage, + getContextForIndex, +} from '../../state/selectors/workpad'; +import { getAssets } from '../../state/selectors/assets'; +import { FunctionForm as Component } from './function_form'; + +const mapStateToProps = (state, { expressionIndex }) => ({ + context: getContextForIndex(state, expressionIndex), + element: getSelectedElement(state), + pageId: getSelectedPage(state), + assets: getAssets(state), +}); + +const mapDispatchToProps = (dispatch, { expressionIndex }) => ({ + addArgument: (element, pageId) => (argName, argValue) => () => { + dispatch( + addArgumentValueAtIndex({ index: expressionIndex, element, pageId, argName, value: argValue }) + ); + }, + updateContext: element => () => dispatch(fetchContext(expressionIndex, element)), + setArgument: (element, pageId) => (argName, valueIndex) => value => { + dispatch( + setArgumentAtIndex({ + index: expressionIndex, + element, + pageId, + argName, + value, + valueIndex, + }) + ); + }, + deleteArgument: (element, pageId) => (argName, argIndex) => () => { + dispatch( + deleteArgumentAtIndex({ + index: expressionIndex, + element, + pageId, + argName, + argIndex, + }) + ); + }, + onAssetAdd: (type, content) => { + // make the ID here and pass it into the action + const assetId = getId('asset'); + dispatch(createAsset(type, content, assetId)); + + // then return the id, so the caller knows the id that will be created + return assetId; + }, +}); + +const mergeProps = (stateProps, dispatchProps, ownProps) => { + const { element, pageId, assets } = stateProps; + const { argType, nextArgType } = ownProps; + const { + updateContext, + setArgument, + addArgument, + deleteArgument, + onAssetAdd, + ...dispatchers + } = dispatchProps; + + return { + ...stateProps, + ...dispatchers, + ...ownProps, + updateContext: updateContext(element), + expressionType: findExpressionType(argType), + nextExpressionType: nextArgType ? findExpressionType(nextArgType) : nextArgType, + onValueChange: setArgument(element, pageId), + onValueAdd: addArgument(element, pageId), + onValueRemove: deleteArgument(element, pageId), + onAssetAdd: (type, content) => { + const existingId = Object.keys(assets).find( + assetId => assets[assetId].type === type && assets[assetId].value === content + ); + if (existingId) return existingId; + return onAssetAdd(type, content); + }, + }; +}; + +export const FunctionForm = connect( + mapStateToProps, + mapDispatchToProps, + mergeProps +)(Component); + +FunctionForm.propTypes = { + expressionIndex: PropTypes.number, + argType: PropTypes.string, + nextArgType: PropTypes.string, +}; diff --git a/x-pack/plugins/canvas/public/components/function_form_list/function_form_list.js b/x-pack/plugins/canvas/public/components/function_form_list/function_form_list.js new file mode 100644 index 0000000000000..ae2e6404f0bb8 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/function_form_list/function_form_list.js @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { FunctionForm } from '../function_form'; + +export const FunctionFormList = ({ functionFormItems }) => { + const argTypeComponents = functionFormItems.map(functionFormProps => { + return ( + + ); + }); + + return
{argTypeComponents}
; +}; + +FunctionFormList.propTypes = { + functionFormItems: PropTypes.array.isRequired, +}; diff --git a/x-pack/plugins/canvas/public/components/function_form_list/index.js b/x-pack/plugins/canvas/public/components/function_form_list/index.js new file mode 100644 index 0000000000000..8b6702d94340f --- /dev/null +++ b/x-pack/plugins/canvas/public/components/function_form_list/index.js @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { compose, withProps } from 'recompose'; +import { get } from 'lodash'; +import { modelRegistry, viewRegistry, transformRegistry } from '../../expression_types'; +import { interpretAst } from '../../lib/interpreter'; +import { toExpression } from '../../../common/lib/ast'; +import { FunctionFormList as Component } from './function_form_list'; + +function normalizeContext(chain) { + if (!Array.isArray(chain) || !chain.length) return null; + return { + type: 'expression', + chain, + }; +} + +function getExpression(ast) { + return ast != null && ast.type === 'expression' ? toExpression(ast) : ast; +} + +function getArgTypeDef(fn) { + return modelRegistry.get(fn) || viewRegistry.get(fn) || transformRegistry.get(fn); +} + +const functionFormItems = withProps(props => { + const selectedElement = props.element; + const FunctionFormChain = get(selectedElement, 'ast.chain', []); + + // map argTypes from AST, attaching nextArgType if one exists + const FunctionFormListItems = FunctionFormChain.reduce( + (acc, argType, i) => { + const argTypeDef = getArgTypeDef(argType.function); + const prevContext = normalizeContext(acc.context); + const nextArg = FunctionFormChain[i + 1] || null; + + // filter out argTypes that shouldn't be in the sidebar + if (argTypeDef) { + // wrap each part of the chain in ArgType, passing in the previous context + const component = { + args: argType.arguments, + argType: argType.function, + argTypeDef: argTypeDef, + argResolver: argAst => interpretAst(argAst, prevContext), + contextExpression: getExpression(prevContext), + expressionIndex: i, // preserve the index in the AST + nextArgType: nextArg && nextArg.function, + }; + + acc.mapped.push(component); + } + + acc.context = acc.context.concat(argType); + return acc; + }, + { mapped: [], context: [] } + ); + + return { + functionFormItems: FunctionFormListItems.mapped, + }; +}); + +export const FunctionFormList = compose(functionFormItems)(Component); diff --git a/x-pack/plugins/canvas/public/components/hover_annotation/hover_annotation.js b/x-pack/plugins/canvas/public/components/hover_annotation/hover_annotation.js new file mode 100644 index 0000000000000..3c372e313b934 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/hover_annotation/hover_annotation.js @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import aero from '../../lib/aeroelastic'; + +export const HoverAnnotation = ({ transformMatrix, width, height }) => { + const newStyle = { + width, + height, + marginLeft: -width / 2, + marginTop: -height / 2, + transform: aero.dom.matrixToCSS(transformMatrix), + }; + return
; +}; + +HoverAnnotation.propTypes = { + transformMatrix: PropTypes.arrayOf(PropTypes.number).isRequired, + width: PropTypes.number.isRequired, + height: PropTypes.number.isRequired, +}; diff --git a/x-pack/plugins/canvas/public/components/hover_annotation/hover_annotation.scss b/x-pack/plugins/canvas/public/components/hover_annotation/hover_annotation.scss new file mode 100644 index 0000000000000..4771bbb6134be --- /dev/null +++ b/x-pack/plugins/canvas/public/components/hover_annotation/hover_annotation.scss @@ -0,0 +1,8 @@ +.canvasHoverAnnotation { + position: absolute; + background: none; + transform-origin: center center; /* the default, only for clarity */ + transform-style: preserve-3d; + outline: solid 1px $euiColorVis0; + pointer-events: none; +} diff --git a/x-pack/plugins/canvas/public/components/hover_annotation/index.js b/x-pack/plugins/canvas/public/components/hover_annotation/index.js new file mode 100644 index 0000000000000..71c57a25d7960 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/hover_annotation/index.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pure } from 'recompose'; +import { HoverAnnotation as Component } from './hover_annotation'; + +export const HoverAnnotation = pure(Component); diff --git a/x-pack/plugins/canvas/public/components/item_grid/index.js b/x-pack/plugins/canvas/public/components/item_grid/index.js new file mode 100644 index 0000000000000..bb9c04f01326d --- /dev/null +++ b/x-pack/plugins/canvas/public/components/item_grid/index.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pure } from 'recompose'; +import { ItemGrid as Component } from './item_grid'; + +export const ItemGrid = pure(Component); diff --git a/x-pack/plugins/canvas/public/components/item_grid/item_grid.js b/x-pack/plugins/canvas/public/components/item_grid/item_grid.js new file mode 100644 index 0000000000000..063ee849013d9 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/item_grid/item_grid.js @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { last } from 'lodash'; + +const defaultPerRow = 6; + +export const ItemGrid = ({ items, itemsPerRow, children }) => { + if (!items) return null; + + const rows = items.reduce( + (rows, item) => { + if (last(rows).length >= (itemsPerRow || defaultPerRow)) rows.push([]); + + last(rows).push(children({ item })); + + return rows; + }, + [[]] + ); + + return rows.map((row, i) => ( +
+ {row} +
+ )); +}; + +ItemGrid.propTypes = { + items: PropTypes.array.isRequired, + itemsPerRow: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + children: PropTypes.func.isRequired, +}; diff --git a/x-pack/plugins/canvas/public/components/link/index.js b/x-pack/plugins/canvas/public/components/link/index.js new file mode 100644 index 0000000000000..fd726d9c75f42 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/link/index.js @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { Link } from './link'; diff --git a/x-pack/plugins/canvas/public/components/link/link.js b/x-pack/plugins/canvas/public/components/link/link.js new file mode 100644 index 0000000000000..5e7810e0db7ab --- /dev/null +++ b/x-pack/plugins/canvas/public/components/link/link.js @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { EuiLink } from '@elastic/eui'; + +const isModifiedEvent = ev => !!(ev.metaKey || ev.altKey || ev.ctrlKey || ev.shiftKey); + +export class Link extends React.PureComponent { + static propTypes = { + target: PropTypes.string, + onClick: PropTypes.func, + name: PropTypes.string.isRequired, + params: PropTypes.object, + children: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.node]).isRequired, + }; + + static contextTypes = { + router: PropTypes.object, + }; + + navigateTo = (name, params) => ev => { + if (this.props.onClick) this.props.onClick(ev); + + if ( + !ev.defaultPrevented && // onClick prevented default + ev.button === 0 && // ignore everything but left clicks + !this.props.target && // let browser handle "target=_blank" etc. + !isModifiedEvent(ev) // ignore clicks with modifier keys + ) { + ev.preventDefault(); + this.context.router.navigateTo(name, params); + } + }; + + render() { + try { + const { name, params, children, ...linkArgs } = this.props; + const { router } = this.context; + const href = router.getFullPath(router.create(name, params)); + const props = { + ...linkArgs, + href, + onClick: this.navigateTo(name, params), + }; + + return {children}; + } catch (e) { + console.error(e); + return
LINK ERROR: {e.message}
; + } + } +} diff --git a/x-pack/plugins/canvas/public/components/loading/__tests__/loading.js b/x-pack/plugins/canvas/public/components/loading/__tests__/loading.js new file mode 100644 index 0000000000000..6393730a277d0 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/loading/__tests__/loading.js @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import expect from 'expect.js'; +import { shallow } from 'enzyme'; +import { EuiLoadingSpinner, EuiIcon } from '@elastic/eui'; +import { Loading } from '../'; + +describe('', () => { + it('uses EuiIcon by default', () => { + const wrapper = shallow(); + expect(wrapper.contains()).to.be.ok; + expect(wrapper.contains()).to.not.be.ok; + }); + + it('uses EuiLoadingSpinner when animating', () => { + const wrapper = shallow(); + expect(wrapper.contains()).to.not.be.ok; + expect(wrapper.contains()).to.be.ok; + }); +}); diff --git a/x-pack/plugins/canvas/public/components/loading/index.js b/x-pack/plugins/canvas/public/components/loading/index.js new file mode 100644 index 0000000000000..81fedf3287184 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/loading/index.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pure } from 'recompose'; +import { Loading as Component } from './loading'; + +export const Loading = pure(Component); diff --git a/x-pack/plugins/canvas/public/components/loading/loading.js b/x-pack/plugins/canvas/public/components/loading/loading.js new file mode 100644 index 0000000000000..d18766244d1a1 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/loading/loading.js @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { EuiLoadingSpinner, EuiIcon } from '@elastic/eui'; + +export const Loading = ({ animated, text }) => { + if (animated) { + return ( +
+ {text && ( + + {text} +   + + )} + +
+ ); + } + + return ( +
+ {text && ( + + {text} +   + + )} + +
+ ); +}; + +Loading.propTypes = { + animated: PropTypes.bool, + text: PropTypes.string, +}; + +Loading.defaultProps = { + animated: false, + text: '', +}; diff --git a/x-pack/plugins/canvas/public/components/loading/loading.scss b/x-pack/plugins/canvas/public/components/loading/loading.scss new file mode 100644 index 0000000000000..1e8dd282f09a9 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/loading/loading.scss @@ -0,0 +1,5 @@ +.canvasLoading { + display: flex; + color: $euiColorLightShade; + align-items: center; +} diff --git a/x-pack/plugins/canvas/public/components/navbar/index.js b/x-pack/plugins/canvas/public/components/navbar/index.js new file mode 100644 index 0000000000000..6948ada93155d --- /dev/null +++ b/x-pack/plugins/canvas/public/components/navbar/index.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pure } from 'recompose'; +import { Navbar as Component } from './navbar'; + +export const Navbar = pure(Component); diff --git a/x-pack/plugins/canvas/public/components/navbar/navbar.js b/x-pack/plugins/canvas/public/components/navbar/navbar.js new file mode 100644 index 0000000000000..dcf6389acd4a3 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/navbar/navbar.js @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; + +export const Navbar = ({ children }) => { + return
{children}
; +}; + +Navbar.propTypes = { + children: PropTypes.node, +}; diff --git a/x-pack/plugins/canvas/public/components/navbar/navbar.scss b/x-pack/plugins/canvas/public/components/navbar/navbar.scss new file mode 100644 index 0000000000000..7b490822763d2 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/navbar/navbar.scss @@ -0,0 +1,7 @@ +.canvasNavbar { + width: 100%; + height: $euiSizeXL * 2; + background-color: darken($euiColorLightestShade, 5%); + position: relative; + z-index: 200; +} diff --git a/x-pack/plugins/canvas/public/components/page_config/index.js b/x-pack/plugins/canvas/public/components/page_config/index.js new file mode 100644 index 0000000000000..fb216af667631 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/page_config/index.js @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { connect } from 'react-redux'; +import { get } from 'lodash'; +import { transitionsRegistry } from '../../lib/transitions_registry'; +import { getSelectedPageIndex, getPages } from '../../state/selectors/workpad'; +import { stylePage, setPageTransition } from '../../state/actions/pages'; +import { PageConfig as Component } from './page_config'; + +const mapStateToProps = state => { + const pageIndex = getSelectedPageIndex(state); + const page = getPages(state)[pageIndex]; + return { page, pageIndex }; +}; + +const mapDispatchToProps = { stylePage, setPageTransition }; + +const mergeProps = (stateProps, dispatchProps) => { + return { + pageIndex: stateProps.pageIndex, + setBackground: background => { + const itsTheNewStyle = { ...stateProps.page.style, background }; + dispatchProps.stylePage(stateProps.page.id, itsTheNewStyle); + }, + background: get(stateProps, 'page.style.background'), + transition: transitionsRegistry.get(get(stateProps, 'page.transition.name')), + transitions: [{ value: '', text: 'None' }].concat( + transitionsRegistry.toArray().map(({ name, displayName }) => ({ + value: name, + text: displayName, + })) + ), + setTransition: name => { + dispatchProps.setPageTransition(stateProps.page.id, { name }); + }, + }; +}; + +export const PageConfig = connect( + mapStateToProps, + mapDispatchToProps, + mergeProps +)(Component); diff --git a/x-pack/plugins/canvas/public/components/page_config/page_config.js b/x-pack/plugins/canvas/public/components/page_config/page_config.js new file mode 100644 index 0000000000000..cbd6cf556ef97 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/page_config/page_config.js @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { EuiCard, EuiFormRow, EuiTitle, EuiSpacer, EuiSelect } from '@elastic/eui'; +import { ColorPickerMini } from '../color_picker_mini'; + +export const PageConfig = ({ + pageIndex, + setBackground, + background, + transition, + transitions, + setTransition, +}) => { + return ( + + +

Page

+
+ + + + + {/* No need to show the transition for the first page because transitions occur when + switching between pages (for example, when moving from the first page to the second + page, we use the second page's transition) */} + {pageIndex > 0 ? ( +
+ + setTransition(e.target.value)} + /> + + {transition ? ( + + + + ) : ( + '' + )} +
+ ) : ( + '' + )} +
+ ); +}; + +PageConfig.propTypes = { + pageIndex: PropTypes.number.isRequired, + background: PropTypes.string, + setBackground: PropTypes.func.isRequired, + transition: PropTypes.shape({ + name: PropTypes.string, + }), + transitions: PropTypes.arrayOf( + PropTypes.shape({ + text: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + }) + ).isRequired, + setTransition: PropTypes.func.isRequired, +}; diff --git a/x-pack/plugins/canvas/public/components/page_manager/index.js b/x-pack/plugins/canvas/public/components/page_manager/index.js new file mode 100644 index 0000000000000..f4f7cad4431c7 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/page_manager/index.js @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { connect } from 'react-redux'; +import { compose, withState } from 'recompose'; +import * as pageActions from '../../state/actions/pages'; +import { getSelectedPage, getWorkpad, getPages } from '../../state/selectors/workpad'; +import { PageManager as Component } from './page_manager'; + +const mapStateToProps = state => ({ + pages: getPages(state), + selectedPage: getSelectedPage(state), + workpadId: getWorkpad(state).id, +}); + +const mapDispatchToProps = dispatch => ({ + addPage: () => dispatch(pageActions.addPage()), + movePage: (id, position) => dispatch(pageActions.movePage(id, position)), + duplicatePage: id => dispatch(pageActions.duplicatePage(id)), + removePage: id => dispatch(pageActions.removePage(id)), +}); + +export const PageManager = compose( + connect( + mapStateToProps, + mapDispatchToProps + ), + withState('deleteId', 'setDeleteId', null) +)(Component); diff --git a/x-pack/plugins/canvas/public/components/page_manager/page_manager.js b/x-pack/plugins/canvas/public/components/page_manager/page_manager.js new file mode 100644 index 0000000000000..aabf086fa0023 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/page_manager/page_manager.js @@ -0,0 +1,197 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { EuiIcon, EuiFlexGroup, EuiFlexItem, EuiText, EuiToolTip } from '@elastic/eui'; +import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd'; +import { ConfirmModal } from '../confirm_modal'; +import { Link } from '../link'; +import { PagePreview } from '../page_preview'; + +export class PageManager extends React.PureComponent { + static propTypes = { + pages: PropTypes.array.isRequired, + workpadId: PropTypes.string.isRequired, + addPage: PropTypes.func.isRequired, + movePage: PropTypes.func.isRequired, + previousPage: PropTypes.func.isRequired, + duplicatePage: PropTypes.func.isRequired, + removePage: PropTypes.func.isRequired, + selectedPage: PropTypes.string, + deleteId: PropTypes.string, + setDeleteId: PropTypes.func.isRequired, + }; + + state = { + showTrayPop: true, + }; + + componentDidMount() { + // gives the tray pop animation time to finish + setTimeout(() => { + this.scrollToActivePage(); + this.setState({ showTrayPop: false }); + }, 1000); + } + + componentDidUpdate(prevProps) { + // scrolls to the active page on the next tick, otherwise new pages don't scroll completely into view + if (prevProps.selectedPage !== this.props.selectedPage) setTimeout(this.scrollToActivePage, 0); + } + + scrollToActivePage = () => { + if (this.activePageRef && this.pageListRef) { + const pageOffset = this.activePageRef.offsetLeft; + const { + left: pageLeft, + right: pageRight, + width: pageWidth, + } = this.activePageRef.getBoundingClientRect(); + const { + left: listLeft, + right: listRight, + width: listWidth, + } = this.pageListRef.getBoundingClientRect(); + + if (pageLeft < listLeft) { + this.pageListRef.scrollTo({ + left: pageOffset, + behavior: 'smooth', + }); + } + if (pageRight > listRight) { + this.pageListRef.scrollTo({ + left: pageOffset - listWidth + pageWidth, + behavior: 'smooth', + }); + } + } + }; + + confirmDelete = pageId => { + this.props.setDeleteId(pageId); + }; + + resetDelete = () => this.props.setDeleteId(null); + + doDelete = () => { + const { previousPage, removePage, deleteId, selectedPage } = this.props; + this.resetDelete(); + if (deleteId === selectedPage) previousPage(); + removePage(deleteId); + }; + + onDragEnd = ({ draggableId: pageId, source, destination }) => { + // dropped outside the list + if (!destination) return; + + const position = destination.index - source.index; + + this.props.movePage(pageId, position); + }; + + renderPage = (page, i) => { + const { selectedPage, workpadId, movePage, duplicatePage } = this.props; + const pageNumber = i + 1; + + return ( + + {provided => ( +
{ + if (page.id === selectedPage) this.activePageRef = el; + provided.innerRef(el); + }} + {...provided.draggableProps} + {...provided.dragHandleProps} + > + + + + {pageNumber} + + + + + + + + +
+ )} +
+ ); + }; + + render() { + const { pages, addPage, deleteId } = this.props; + const { showTrayPop } = this.state; + + return ( + + + + + + {provided => ( +
{ + this.pageListRef = el; + provided.innerRef(el); + }} + {...provided.droppableProps} + > + {pages.map(this.renderPage)} + {provided.placeholder} +
+ )} +
+
+
+ + + + + +
+ +
+ ); + } +} diff --git a/x-pack/plugins/canvas/public/components/page_manager/page_manager.scss b/x-pack/plugins/canvas/public/components/page_manager/page_manager.scss new file mode 100644 index 0000000000000..8261d421b0022 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/page_manager/page_manager.scss @@ -0,0 +1,138 @@ +.canvasPageManager { + .canvasPageManager__pages { + position: relative; + } + + .canvasPageManager__pageList { + @include euiScrollBar; + display: flex; + overflow-x: auto; + overflow-y: hidden; + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + } + + .canvasPageManager--trayPop > div { + animation: trayPop $euiAnimSpeedNormal $euiAnimSlightResistance; + opacity: 0; + animation-fill-mode: forwards; + } + @for $i from 1 through 20 { + .canvasPageManager--trayPop > div:nth-child(#{$i}n) { + animation-delay: #{$i * 0.05}s; + } + } + + .canvasPageManager__addPage { + width: $euiSizeXXL + $euiSize; + background: $euiColorSecondary; + color: $euiColorGhost; + opacity: 0; + animation: buttonPop $euiAnimSpeedNormal $euiAnimSlightResistance; + animation-fill-mode: forwards; + height: 144px; + } + + .canvasPageManager__addPageTip { + display: block; + height: 100%; + } + + .canvasPageManager__page { + padding: $euiSize $euiSize $euiSize $euiSizeS; + + &:focus, + &-isActive { + background-color: transparentize(darken($euiColorLightestShade, 30%), 0.5); + outline: none; + text-decoration: none; + } + + &-isActive:focus { + .canvasPageManager__pagePreview { + outline-color: $euiColorVis0; + } + } + + &:hover, + &:focus { + text-decoration: none; + + .canvasPageManager__pagePreview { + @include euiBottomShadowMedium($opacity: 0.3); + } + + .canvasPageManager__controls { + visibility: visible; + opacity: 1; + } + } + + &-isActive { + .canvasPageManager__pagePreview { + @include euiBottomShadowMedium; + outline: $euiBorderThick; + outline-color: $euiColorDarkShade; + } + } + } + + .canvasPageManager__pageNumber { + color: $euiColorDarkShade !important; + } + + .canvasPageManager__pagePreview { + @include euiBottomShadowSmall; + position: relative; + overflow: hidden; + + .canvasPositionable { + position: absolute; + } + } + + .canvasPageManager__controls { + position: absolute; + right: $euiSizeS; + top: $euiSizeS; + visibility: hidden; + opacity: 0; + transition: opacity $euiAnimSpeedFast $euiAnimSlightResistance; + transition-delay: $euiAnimSpeedNormal; + background: transparentize($euiColorGhost, 0.5); + border-radius: $euiBorderRadius; + } +} + +@keyframes buttonPop { + 0% { + opacity: 0; + transform: translateX(100%); + } + 1% { + opacity: 0; + transform: translateX(100%); + } + 100% { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes trayPop { + 0% { + opacity: 0; + transform: translateY(100%); + } + 1% { + opacity: 0; + transform: translateY(100%); + } + 100% { + opacity: 1; + transform: translateY(0); + } +} diff --git a/x-pack/plugins/canvas/public/components/page_preview/index.js b/x-pack/plugins/canvas/public/components/page_preview/index.js new file mode 100644 index 0000000000000..d72d6403dd5be --- /dev/null +++ b/x-pack/plugins/canvas/public/components/page_preview/index.js @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { PagePreview } from './page_preview'; diff --git a/x-pack/plugins/canvas/public/components/page_preview/page_controls.js b/x-pack/plugins/canvas/public/components/page_preview/page_controls.js new file mode 100644 index 0000000000000..6cde599c79fa6 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/page_preview/page_controls.js @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { EuiFlexGroup, EuiFlexItem, EuiButtonIcon, EuiToolTip } from '@elastic/eui'; + +export const PageControls = ({ pageId, onDelete, onDuplicate }) => { + const handleDuplicate = ev => { + ev.preventDefault(); + onDuplicate(pageId); + }; + + const handleDelete = ev => { + ev.preventDefault(); + onDelete(pageId); + }; + + return ( + + + + + + + + + + + + + ); +}; + +PageControls.propTypes = { + pageId: PropTypes.string.isRequired, + pageNumber: PropTypes.number.isRequired, + onDelete: PropTypes.func.isRequired, + onDuplicate: PropTypes.func.isRequired, +}; diff --git a/x-pack/plugins/canvas/public/components/page_preview/page_preview.js b/x-pack/plugins/canvas/public/components/page_preview/page_preview.js new file mode 100644 index 0000000000000..dfcbc31c54fa2 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/page_preview/page_preview.js @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { DomPreview } from '../dom_preview'; +import { PageControls } from './page_controls'; + +export const PagePreview = ({ page, pageNumber, height, duplicatePage, confirmDelete }) => ( +
+ + +
+); + +PagePreview.propTypes = { + page: PropTypes.shape({ + id: PropTypes.string.isRequired, + style: PropTypes.shape({ + background: PropTypes.string.isRequired, + }).isRequired, + }).isRequired, + height: PropTypes.number.isRequired, + pageNumber: PropTypes.number.isRequired, + duplicatePage: PropTypes.func.isRequired, + confirmDelete: PropTypes.func.isRequired, +}; diff --git a/x-pack/plugins/canvas/public/components/paginate/index.js b/x-pack/plugins/canvas/public/components/paginate/index.js new file mode 100644 index 0000000000000..2854324c3981a --- /dev/null +++ b/x-pack/plugins/canvas/public/components/paginate/index.js @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import PropTypes from 'prop-types'; +import { compose, withState, withProps, withHandlers, lifecycle } from 'recompose'; +import { Paginate as Component } from './paginate'; + +export const Paginate = compose( + withProps(({ rows, perPage }) => ({ + perPage: Number(perPage), + totalPages: Math.ceil(rows.length / (perPage || 10)), + })), + withState('currentPage', 'setPage', ({ startPage, totalPages }) => { + if (totalPages > 0) return Math.min(startPage, totalPages - 1); + return 0; + }), + withProps(({ rows, totalPages, currentPage, perPage }) => { + const maxPage = totalPages - 1; + const start = currentPage * perPage; + const end = currentPage === 0 ? perPage : perPage * (currentPage + 1); + return { + pageNumber: currentPage, + nextPageEnabled: currentPage < maxPage, + prevPageEnabled: currentPage > 0, + partialRows: rows.slice(start, end), + }; + }), + withHandlers({ + nextPage: ({ currentPage, nextPageEnabled, setPage }) => () => + nextPageEnabled && setPage(currentPage + 1), + prevPage: ({ currentPage, prevPageEnabled, setPage }) => () => + prevPageEnabled && setPage(currentPage - 1), + }), + lifecycle({ + componentDidUpdate(prevProps) { + if (prevProps.perPage !== this.props.perPage) this.props.setPage(0); + }, + }) +)(Component); + +Paginate.propTypes = { + rows: PropTypes.array.isRequired, + perPage: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + startPage: PropTypes.number, +}; + +Paginate.defaultProps = { + perPage: 10, + startPage: 0, +}; diff --git a/x-pack/plugins/canvas/public/components/paginate/paginate.js b/x-pack/plugins/canvas/public/components/paginate/paginate.js new file mode 100644 index 0000000000000..52e744ec769e8 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/paginate/paginate.js @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import PropTypes from 'prop-types'; + +export const Paginate = props => { + return props.children({ + rows: props.partialRows, + perPage: props.perPage, + pageNumber: props.pageNumber, + totalPages: props.totalPages, + nextPageEnabled: props.nextPageEnabled, + prevPageEnabled: props.prevPageEnabled, + setPage: num => props.setPage(num), + nextPage: props.nextPage, + prevPage: props.prevPage, + }); +}; + +Paginate.propTypes = { + children: PropTypes.func.isRequired, + partialRows: PropTypes.array.isRequired, + perPage: PropTypes.number.isRequired, + pageNumber: PropTypes.number.isRequired, + totalPages: PropTypes.number.isRequired, + nextPageEnabled: PropTypes.bool.isRequired, + prevPageEnabled: PropTypes.bool.isRequired, + setPage: PropTypes.func.isRequired, + nextPage: PropTypes.func.isRequired, + prevPage: PropTypes.func.isRequired, +}; diff --git a/x-pack/plugins/canvas/public/components/palette_picker/index.js b/x-pack/plugins/canvas/public/components/palette_picker/index.js new file mode 100644 index 0000000000000..33d1d22777183 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/palette_picker/index.js @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pure } from 'recompose'; + +import { PalettePicker as Component } from './palette_picker'; + +export const PalettePicker = pure(Component); diff --git a/x-pack/plugins/canvas/public/components/palette_picker/palette_picker.js b/x-pack/plugins/canvas/public/components/palette_picker/palette_picker.js new file mode 100644 index 0000000000000..ff9909fb7747c --- /dev/null +++ b/x-pack/plugins/canvas/public/components/palette_picker/palette_picker.js @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { map } from 'lodash'; +import { Popover } from '../popover'; +import { PaletteSwatch } from '../palette_swatch'; +import { palettes } from '../../../common/lib/palettes'; + +export const PalettePicker = ({ onChange, value, anchorPosition }) => { + const button = handleClick => ( + + ); + + return ( + + {() => ( +
+ {map(palettes, (palette, name) => ( + + ))} +
+ )} +
+ ); +}; + +PalettePicker.propTypes = { + value: PropTypes.object, + onChange: PropTypes.func, + anchorPosition: PropTypes.string, +}; diff --git a/x-pack/plugins/canvas/public/components/palette_picker/palette_picker.scss b/x-pack/plugins/canvas/public/components/palette_picker/palette_picker.scss new file mode 100644 index 0000000000000..64678e9fc8de9 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/palette_picker/palette_picker.scss @@ -0,0 +1,41 @@ +.canvasPalettePicker { + display: inline-block; + width: 100%; +} + +.canvasPalettePicker__swatches { + @include euiScrollBar; + + width: 280px; + height: 250px; + overflow-y: scroll; +} + +.canvasPalettePicker__swatchesPanel { + padding: $euiSizeS 0 !important; +} + +.canvasPalettePicker__swatch { + padding: $euiSizeS $euiSize; + + &:hover, + &:focus { + text-decoration: underline; + background-color: $euiColorLightestShade; + + .canvasPaletteSwatch, + .canvasPaletteSwatch__background { + transform: scaleY(2); + } + .canvasPalettePicker__label { + color: $euiTextColor; + } + } +} + +.canvasPalettePicker__label { + font-size: $euiFontSizeXS; + text-transform: capitalize; + text-align: left; + color: $euiColorDarkShade; +} diff --git a/x-pack/plugins/canvas/public/components/palette_swatch/index.js b/x-pack/plugins/canvas/public/components/palette_swatch/index.js new file mode 100644 index 0000000000000..2be37a8338b2b --- /dev/null +++ b/x-pack/plugins/canvas/public/components/palette_swatch/index.js @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pure } from 'recompose'; + +import { PaletteSwatch as Component } from './palette_swatch'; + +export const PaletteSwatch = pure(Component); diff --git a/x-pack/plugins/canvas/public/components/palette_swatch/palette_swatch.js b/x-pack/plugins/canvas/public/components/palette_swatch/palette_swatch.js new file mode 100644 index 0000000000000..fcc34c8d85448 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/palette_swatch/palette_swatch.js @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; + +export const PaletteSwatch = ({ colors, gradient }) => { + let colorBoxes; + + if (!gradient) { + colorBoxes = colors.map(color => ( +
+ )); + } else { + colorBoxes = [ +
, + ]; + } + + return ( +
+
+
{colorBoxes}
+
+ ); +}; + +PaletteSwatch.propTypes = { + colors: PropTypes.array, + gradient: PropTypes.bool, +}; diff --git a/x-pack/plugins/canvas/public/components/palette_swatch/palette_swatch.scss b/x-pack/plugins/canvas/public/components/palette_swatch/palette_swatch.scss new file mode 100644 index 0000000000000..b57c520a5b07f --- /dev/null +++ b/x-pack/plugins/canvas/public/components/palette_swatch/palette_swatch.scss @@ -0,0 +1,35 @@ +.canvasPaletteSwatch { + display: inline-block; + position: relative; + height: $euiSizeXS; + width: 100%; + overflow: hidden; + text-align: left; + transform: scaleY(1); + transition: transform $euiAnimSlightResistance $euiAnimSpeedExtraFast; + + .canvasPaletteSwatch__background { + position: absolute; + height: $euiSizeXS; + top: 0; + left: 0; + width: 100%; + transform: scaleY(1); + transition: transform $euiAnimSlightResistance $euiAnimSpeedExtraFast; + } + + .canvasPaletteSwatch__foreground { + position: absolute; + height: 100%; // TODO: No idea why this can't be 25, but it leaves a 1px white spot in the palettePicker if its 25 + top: 0; + left: 0; + white-space: nowrap; + width: 100%; + display: flex; + } + + .canvasPaletteSwatch__box { + display: inline-block; + width: 100%; + } +} diff --git a/x-pack/plugins/canvas/public/components/popover/index.js b/x-pack/plugins/canvas/public/components/popover/index.js new file mode 100644 index 0000000000000..f560da14079b5 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/popover/index.js @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { Popover } from './popover'; diff --git a/x-pack/plugins/canvas/public/components/popover/popover.js b/x-pack/plugins/canvas/public/components/popover/popover.js new file mode 100644 index 0000000000000..19d90de50478b --- /dev/null +++ b/x-pack/plugins/canvas/public/components/popover/popover.js @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint react/no-did-mount-set-state: 0, react/forbid-elements: 0 */ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { EuiPopover, EuiToolTip } from '@elastic/eui'; + +export class Popover extends Component { + static propTypes = { + isOpen: PropTypes.bool, + ownFocus: PropTypes.bool, + button: PropTypes.func.isRequired, + children: PropTypes.func.isRequired, + tooltip: PropTypes.string, + tooltipPosition: PropTypes.oneOf(['top', 'bottom', 'left', 'right']), + }; + + static defaultProps = { + isOpen: false, + ownFocus: true, + tooltip: '', + tooltipPosition: 'top', + }; + + state = { + isPopoverOpen: false, + }; + + componentDidMount() { + if (this.props.isOpen) this.setState({ isPopoverOpen: true }); + } + + handleClick = () => { + this.setState(state => ({ + isPopoverOpen: !state.isPopoverOpen, + })); + }; + + closePopover = () => { + this.setState({ + isPopoverOpen: false, + }); + }; + + render() { + const { button, children, tooltip, tooltipPosition, ...rest } = this.props; + + const wrappedButton = handleClick => { + // wrap button in tooltip, if tooltip text is provided + if (!this.state.isPopoverOpen && tooltip.length) { + return ( + + {button(handleClick)} + + ); + } + + return button(handleClick); + }; + + return ( + + {children({ closePopover: this.closePopover })} + + ); + } +} diff --git a/x-pack/plugins/canvas/public/components/positionable/index.js b/x-pack/plugins/canvas/public/components/positionable/index.js new file mode 100644 index 0000000000000..e5c3c32acb024 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/positionable/index.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pure } from 'recompose'; +import { Positionable as Component } from './positionable'; + +export const Positionable = pure(Component); diff --git a/x-pack/plugins/canvas/public/components/positionable/positionable.js b/x-pack/plugins/canvas/public/components/positionable/positionable.js new file mode 100644 index 0000000000000..b11856d6c1061 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/positionable/positionable.js @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import aero from '../../lib/aeroelastic'; + +export const Positionable = ({ children, transformMatrix, width, height }) => { + // Throw if there is more than one child + React.Children.only(children); + // This could probably be made nicer by having just one child + const wrappedChildren = React.Children.map(children, child => { + const newStyle = { + width, + height, + marginLeft: -width / 2, + marginTop: -height / 2, + position: 'absolute', + transform: aero.dom.matrixToCSS(transformMatrix.map((n, i) => (i < 12 ? n : Math.round(n)))), + }; + + const stepChild = React.cloneElement(child, { size: { width, height } }); + return ( +
+ {stepChild} +
+ ); + }); + + return wrappedChildren; +}; + +Positionable.propTypes = { + onChange: PropTypes.func, + children: PropTypes.element.isRequired, + transformMatrix: PropTypes.arrayOf(PropTypes.number).isRequired, + width: PropTypes.number.isRequired, + height: PropTypes.number.isRequired, +}; diff --git a/x-pack/plugins/canvas/public/components/positionable/positionable.scss b/x-pack/plugins/canvas/public/components/positionable/positionable.scss new file mode 100644 index 0000000000000..d1d927672e052 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/positionable/positionable.scss @@ -0,0 +1,4 @@ +.canvasPositionable { + transform-origin: center center; /* the default, only for clarity */ + transform-style: preserve-3d; +} diff --git a/x-pack/plugins/canvas/public/components/refresh_control/auto_refresh_controls.js b/x-pack/plugins/canvas/public/components/refresh_control/auto_refresh_controls.js new file mode 100644 index 0000000000000..98e2c9c1f7d16 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/refresh_control/auto_refresh_controls.js @@ -0,0 +1,144 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, Component } from 'react'; +import PropTypes from 'prop-types'; +import { + EuiFlexGroup, + EuiFlexGrid, + EuiFlexItem, + EuiFormRow, + EuiButton, + EuiLink, + EuiFieldText, + EuiSpacer, + EuiHorizontalRule, + EuiDescriptionList, + EuiDescriptionListTitle, + EuiDescriptionListDescription, + EuiFormLabel, + EuiText, +} from '@elastic/eui'; +import { timeDurationString } from '../../lib/time_duration'; + +export class AutoRefreshControls extends Component { + static propTypes = { + refreshInterval: PropTypes.number, + setRefresh: PropTypes.func.isRequired, + disableInterval: PropTypes.func.isRequired, + doRefresh: PropTypes.func.isRequired, + }; + + refreshInput = null; + + render() { + const { refreshInterval, setRefresh, doRefresh, disableInterval } = this.props; + + return ( +
+ + + + Refresh this page + + {refreshInterval > 0 ? ( + + Every {timeDurationString(refreshInterval)} +
+ + Disable auto-refresh + +
+
+ ) : ( + Manually + )} +
+
+
+ + + Refresh + + +
+ + + + Change auto-refresh interval + + + + + setRefresh(5000)}>5 Seconds + + + setRefresh(15000)}>15 Seconds + + + setRefresh(30000)}>30 Seconds + + + setRefresh(60000)}>1 Minute + + + setRefresh(300000)}>5 Minutes + + + setRefresh(900000)}>15 Minutes + + + setRefresh(1800000)}>30 Minutes + + + setRefresh(3600000)}>1 Hour + + + setRefresh(7200000)}>2 Hours + + + setRefresh(21600000)}>6 Hours + + + setRefresh(43200000)}>12 Hours + + + setRefresh(86400000)}>24 Hours + + + + + + +
{ + ev.preventDefault(); + setRefresh(this.refreshInput.value); + }} + > + + + + (this.refreshInput = i)} /> + + + + + + Set + + + + +
+
+ ); + } +} diff --git a/x-pack/plugins/canvas/public/components/refresh_control/index.js b/x-pack/plugins/canvas/public/components/refresh_control/index.js new file mode 100644 index 0000000000000..0fb0185020357 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/refresh_control/index.js @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { connect } from 'react-redux'; +import { fetchAllRenderables } from '../../state/actions/elements'; +import { setRefreshInterval } from '../../state/actions/workpad'; +import { getInFlight } from '../../state/selectors/resolved_args'; +import { getRefreshInterval } from '../../state/selectors/workpad'; +import { RefreshControl as Component } from './refresh_control'; + +const mapStateToProps = state => ({ + inFlight: getInFlight(state), + refreshInterval: getRefreshInterval(state), +}); + +const mapDispatchToProps = { + doRefresh: fetchAllRenderables, + setRefreshInterval, +}; + +export const RefreshControl = connect( + mapStateToProps, + mapDispatchToProps +)(Component); diff --git a/x-pack/plugins/canvas/public/components/refresh_control/refresh_control.js b/x-pack/plugins/canvas/public/components/refresh_control/refresh_control.js new file mode 100644 index 0000000000000..bcf66c46db4a4 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/refresh_control/refresh_control.js @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { EuiButtonEmpty } from '@elastic/eui'; +import { Popover } from '../popover'; +import { AutoRefreshControls } from './auto_refresh_controls'; + +const getRefreshInterval = (val = '') => { + // if it's a number, just use it directly + if (!isNaN(Number(val))) return val; + + // if it's a string, try to parse out the shorthand duration value + const match = String(val).match(/^([0-9]{1,})([hmsd])$/); + + // TODO: do something better with improper input, like show an error... + if (!match) return; + + switch (match[2]) { + case 's': + return match[1] * 1000; + case 'm': + return match[1] * 1000 * 60; + case 'h': + return match[1] * 1000 * 60 * 60; + case 'd': + return match[1] * 1000 * 60 * 60 * 24; + } +}; + +export const RefreshControl = ({ inFlight, setRefreshInterval, refreshInterval, doRefresh }) => { + const setRefresh = val => setRefreshInterval(getRefreshInterval(val)); + + const popoverButton = handleClick => ( + + Refresh + + ); + + const autoRefreshControls = ( + + {({ closePopover }) => ( +
+ { + setRefresh(val); + closePopover(); + }} + doRefresh={doRefresh} + disableInterval={() => { + setRefresh(0); + closePopover(); + }} + /> +
+ )} +
+ ); + + return autoRefreshControls; +}; + +RefreshControl.propTypes = { + inFlight: PropTypes.bool.isRequired, + doRefresh: PropTypes.func.isRequired, + refreshInterval: PropTypes.number, + setRefreshInterval: PropTypes.func.isRequired, +}; diff --git a/x-pack/plugins/canvas/public/components/refresh_control/refresh_control.scss b/x-pack/plugins/canvas/public/components/refresh_control/refresh_control.scss new file mode 100644 index 0000000000000..0a193851ba1bc --- /dev/null +++ b/x-pack/plugins/canvas/public/components/refresh_control/refresh_control.scss @@ -0,0 +1,8 @@ +.canvasRefreshControl { + display: flex; + align-items: center; +} + +.canvasRefreshControl__popover { + width: 300px; +} diff --git a/x-pack/plugins/canvas/public/components/remove_icon/index.js b/x-pack/plugins/canvas/public/components/remove_icon/index.js new file mode 100644 index 0000000000000..0c4f97372670d --- /dev/null +++ b/x-pack/plugins/canvas/public/components/remove_icon/index.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pure } from 'recompose'; +import { RemoveIcon as Component } from './remove_icon'; + +export const RemoveIcon = pure(Component); diff --git a/x-pack/plugins/canvas/public/components/remove_icon/remove_icon.js b/x-pack/plugins/canvas/public/components/remove_icon/remove_icon.js new file mode 100644 index 0000000000000..726936b8b4e9b --- /dev/null +++ b/x-pack/plugins/canvas/public/components/remove_icon/remove_icon.js @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { EuiIcon } from '@elastic/eui'; + +export const RemoveIcon = ({ onClick, className }) => ( +
+ +
+); + +RemoveIcon.propTypes = { + onClick: PropTypes.func, + style: PropTypes.object, + className: PropTypes.string, +}; diff --git a/x-pack/plugins/canvas/public/components/remove_icon/remove_icon.scss b/x-pack/plugins/canvas/public/components/remove_icon/remove_icon.scss new file mode 100644 index 0000000000000..a27400b2e0522 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/remove_icon/remove_icon.scss @@ -0,0 +1,29 @@ +.canvasRemove { + $clearSize: $euiSize; + position: absolute; + pointer-events: all; + @include size($clearSize); + background-color: $euiColorSlightHue; + border-radius: $clearSize; + line-height: $clearSize; + top: -$euiSizeL; + right: -$euiSizeL; + + .canvasRemove__icon { + @include size($euiSizeS); + fill: $euiColorEmptyShade; + stroke: $euiColorEmptyShade; + stroke-width: 3px; // increase thickness of icon at such a small size + // better vertical position fix that works with IE + position: relative; + top: -1px; + left: $euiSizeXS; + + } + + &:hover { + background-color: $euiColorDanger; + cursor: pointer; + } + +} diff --git a/x-pack/plugins/canvas/public/components/render_to_dom/index.js b/x-pack/plugins/canvas/public/components/render_to_dom/index.js new file mode 100644 index 0000000000000..e8a3f8cd8c93b --- /dev/null +++ b/x-pack/plugins/canvas/public/components/render_to_dom/index.js @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { compose, withState } from 'recompose'; +import { RenderToDom as Component } from './render_to_dom'; + +export const RenderToDom = compose( + withState('domNode', 'setDomNode') // Still don't like this, seems to be the only way todo it. +)(Component); diff --git a/x-pack/plugins/canvas/public/components/render_to_dom/render_to_dom.js b/x-pack/plugins/canvas/public/components/render_to_dom/render_to_dom.js new file mode 100644 index 0000000000000..b47424fb5c25e --- /dev/null +++ b/x-pack/plugins/canvas/public/components/render_to_dom/render_to_dom.js @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; + +export class RenderToDom extends React.Component { + static propTypes = { + domNode: PropTypes.object, + setDomNode: PropTypes.func.isRequired, + render: PropTypes.func.isRequired, + style: PropTypes.object, + }; + + shouldComponentUpdate(nextProps) { + return this.props.domNode !== nextProps.domNode; + } + + componentDidUpdate() { + // Calls render function once we have the reference to the DOM element to render into + if (this.props.domNode) this.props.render(this.props.domNode); + } + + render() { + const { domNode, setDomNode, style } = this.props; + const linkRef = refNode => { + if (!domNode && refNode) { + // Initialize the domNode property. This should only happen once, even if config changes. + setDomNode(refNode); + } + }; + + return
; + } +} diff --git a/x-pack/plugins/canvas/public/components/render_with_fn/index.js b/x-pack/plugins/canvas/public/components/render_with_fn/index.js new file mode 100644 index 0000000000000..1441e1382dace --- /dev/null +++ b/x-pack/plugins/canvas/public/components/render_with_fn/index.js @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { compose, withPropsOnChange, withProps } from 'recompose'; +import PropTypes from 'prop-types'; +import { notify } from '../../lib/notify'; +import { RenderWithFn as Component } from './render_with_fn'; +import { ElementHandlers } from './lib/handlers'; + +export const RenderWithFn = compose( + withPropsOnChange( + () => false, + () => ({ + elementHandlers: new ElementHandlers(), + }) + ), + withProps(({ handlers, elementHandlers }) => ({ + handlers: Object.assign(elementHandlers, handlers), + onError: notify.error, + })) +)(Component); + +RenderWithFn.propTypes = { + handlers: PropTypes.object, + elementHandlers: PropTypes.object, +}; diff --git a/x-pack/plugins/canvas/public/components/render_with_fn/lib/handlers.js b/x-pack/plugins/canvas/public/components/render_with_fn/lib/handlers.js new file mode 100644 index 0000000000000..9e5032efa97e2 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/render_with_fn/lib/handlers.js @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export class ElementHandlers { + resize() {} + + destroy() {} + + onResize(fn) { + this.resize = fn; + } + + onDestroy(fn) { + this.destroy = fn; + } +} diff --git a/x-pack/plugins/canvas/public/components/render_with_fn/render_with_fn.js b/x-pack/plugins/canvas/public/components/render_with_fn/render_with_fn.js new file mode 100644 index 0000000000000..f859c15d1b1ac --- /dev/null +++ b/x-pack/plugins/canvas/public/components/render_with_fn/render_with_fn.js @@ -0,0 +1,140 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { isEqual, cloneDeep } from 'lodash'; +import { RenderToDom } from '../render_to_dom'; + +export class RenderWithFn extends React.Component { + static propTypes = { + name: PropTypes.string.isRequired, + renderFn: PropTypes.func.isRequired, + reuseNode: PropTypes.bool, + handlers: PropTypes.shape({ + // element handlers, see components/element_wrapper/lib/handlers.js + setFilter: PropTypes.func.isRequired, + getFilter: PropTypes.func.isRequired, + done: PropTypes.func.isRequired, + // render handlers, see lib/handlers.js + resize: PropTypes.func.isRequired, + onResize: PropTypes.func.isRequired, + destroy: PropTypes.func.isRequired, + onDestroy: PropTypes.func.isRequired, + }), + config: PropTypes.object.isRequired, + size: PropTypes.object.isRequired, + onError: PropTypes.func.isRequired, + }; + + static defaultProps = { + reuseNode: false, + }; + + static domNode = null; + + componentDidMount() { + this.firstRender = true; + this.renderTarget = null; + } + + componentWillReceiveProps({ renderFn }) { + const newRenderFunction = renderFn !== this.props.renderFn; + + if (newRenderFunction) this.resetRenderTarget(this.domNode); + } + + shouldComponentUpdate(prevProps) { + return !isEqual(this.props.size, prevProps.size) || this.shouldFullRerender(prevProps); + } + + componentDidUpdate(prevProps) { + const { handlers, size } = this.props; + // Config changes + if (this.shouldFullRerender(prevProps)) { + // This should be the only place you call renderFn besides the first time + this.callRenderFn(); + } + + // Size changes + if (!isEqual(size, prevProps.size)) return handlers.resize(size); + } + + componentWillUnmount() { + this.props.handlers.destroy(); + } + + callRenderFn = () => { + const { handlers, config, renderFn, reuseNode, name: functionName } = this.props; + // TODO: We should wait until handlers.done() is called before replacing the element content? + if (!reuseNode || !this.renderTarget) this.resetRenderTarget(this.domNode); + // else if (!firstRender) handlers.destroy(); + + const renderConfig = cloneDeep(config); + + // TODO: this is hacky, but it works. it stops Kibana from blowing up when a render throws + try { + renderFn(this.renderTarget, renderConfig, handlers); + this.firstRender = false; + } catch (err) { + console.error('renderFn threw', err); + this.props.onError(err, { title: `Rendering '${functionName || 'function'}' failed` }); + } + }; + + resetRenderTarget = domNode => { + const { handlers } = this.props; + + if (!domNode) throw new Error('RenderWithFn can not reset undefined target node'); + + // call destroy on existing element + if (!this.firstRender) handlers.destroy(); + + while (domNode.firstChild) domNode.removeChild(domNode.firstChild); + + this.firstRender = true; + this.renderTarget = this.createRenderTarget(); + domNode.appendChild(this.renderTarget); + }; + + createRenderTarget = () => { + const div = document.createElement('div'); + div.style.width = '100%'; + div.style.height = '100%'; + return div; + }; + + shouldFullRerender = prevProps => { + // TODO: What a shitty hack. None of these props should update when you move the element. + // This should be fixed at a higher level. + return ( + !isEqual(this.props.config, prevProps.config) || + !isEqual(this.props.renderFn.toString(), prevProps.renderFn.toString()) + ); + }; + + destroy = () => { + this.props.handlers.destroy(); + }; + + render() { + // NOTE: the data-shared-* attributes here are used for reporting + return ( +
+ { + this.domNode = domNode; + this.callRenderFn(); + }} + /> +
+ ); + } +} diff --git a/x-pack/plugins/canvas/public/components/rotation_handle/index.js b/x-pack/plugins/canvas/public/components/rotation_handle/index.js new file mode 100644 index 0000000000000..86c99ce12a04e --- /dev/null +++ b/x-pack/plugins/canvas/public/components/rotation_handle/index.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pure } from 'recompose'; +import { RotationHandle as Component } from './rotation_handle'; + +export const RotationHandle = pure(Component); diff --git a/x-pack/plugins/canvas/public/components/rotation_handle/rotation_handle.js b/x-pack/plugins/canvas/public/components/rotation_handle/rotation_handle.js new file mode 100644 index 0000000000000..2c16943a3a7de --- /dev/null +++ b/x-pack/plugins/canvas/public/components/rotation_handle/rotation_handle.js @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import aero from '../../lib/aeroelastic'; + +export const RotationHandle = ({ transformMatrix }) => ( +
+
+
+); + +RotationHandle.propTypes = { + transformMatrix: PropTypes.arrayOf(PropTypes.number).isRequired, +}; diff --git a/x-pack/plugins/canvas/public/components/rotation_handle/rotation_handle.scss b/x-pack/plugins/canvas/public/components/rotation_handle/rotation_handle.scss new file mode 100644 index 0000000000000..0f71565666f90 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/rotation_handle/rotation_handle.scss @@ -0,0 +1,24 @@ +.canvasRotationHandle--connector { + transform-origin: center center; /* the default, only for clarity */ + transform-style: preserve-3d; + display: block; + position: absolute; + height: 24px; + width: 0; + margin-left: -1px; + margin-top: -12px; + border: 1px solid #d9d9d9; +} + +.canvasRotationHandle--handle { + transform-origin: center center; /* the default, only for clarity */ + transform-style: preserve-3d; + display: block; + position: absolute; + height: 8px; + width: 8px; + margin-left: -4px; + margin-top: -3px; + border-radius: 50%; + background-color: #999; +} diff --git a/x-pack/plugins/canvas/public/components/router/canvas_loading.js b/x-pack/plugins/canvas/public/components/router/canvas_loading.js new file mode 100644 index 0000000000000..8e6e1fdb4376f --- /dev/null +++ b/x-pack/plugins/canvas/public/components/router/canvas_loading.js @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { EuiPanel, EuiLoadingChart, EuiSpacer, EuiText } from '@elastic/eui'; + +export const CanvasLoading = ({ msg }) => ( +
+ + + + + {/* + For some reason a styled color is required, + likely something with the chrome css from Kibana + */} +

{msg}

+
+
+
+); + +CanvasLoading.propTypes = { + msg: PropTypes.string, +}; + +CanvasLoading.defaultProps = { + msg: 'Loading...', +}; diff --git a/x-pack/plugins/canvas/public/components/router/index.js b/x-pack/plugins/canvas/public/components/router/index.js new file mode 100644 index 0000000000000..6ef30feff1071 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/router/index.js @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { Router } from './router'; diff --git a/x-pack/plugins/canvas/public/components/router/router.js b/x-pack/plugins/canvas/public/components/router/router.js new file mode 100644 index 0000000000000..bedae7cc56c53 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/router/router.js @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { routerProvider } from '../../lib/router_provider'; +import { CanvasLoading } from './canvas_loading'; + +export class Router extends React.PureComponent { + static childContextTypes = { + router: PropTypes.object.isRequired, + }; + + static propTypes = { + showLoading: PropTypes.bool.isRequired, + onLoad: PropTypes.func.isRequired, + onError: PropTypes.func.isRequired, + routes: PropTypes.array.isRequired, + loadingMessage: PropTypes.string, + onRouteChange: PropTypes.func, + }; + + static state = { + router: {}, + activeComponent: CanvasLoading, + }; + + getChildContext() { + const { router } = this.state; + return { router }; + } + + componentWillMount() { + // routerProvider is a singleton, and will only ever return one instance + const { routes, onRouteChange, onLoad, onError } = this.props; + const router = routerProvider(routes); + + // when the component in the route changes, render it + router.onPathChange(route => { + const { pathname } = route.location; + const firstLoad = !this.state; + const { component } = route.meta; + + if (!component) { + // TODO: render some kind of 404 page, maybe from a prop? + if (process.env.NODE_ENV !== 'production') + console.warn(`No component defined on route: ${route.name}`); + + return; + } + + // if this is the first load, execute the route + if (firstLoad) { + router + .execute() + .then(() => onLoad()) + .catch(err => onError(err)); + } + + // notify upstream handler of route change + onRouteChange && onRouteChange(pathname); + + this.setState({ activeComponent: component }); + }); + + this.setState({ router }); + } + + render() { + if (this.props.showLoading) + return React.createElement(CanvasLoading, { msg: this.props.loadingMessage }); + + return React.createElement(this.state.activeComponent, {}); + } +} diff --git a/x-pack/plugins/canvas/public/components/shape_picker/index.js b/x-pack/plugins/canvas/public/components/shape_picker/index.js new file mode 100644 index 0000000000000..d3ed85831cbe2 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/shape_picker/index.js @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pure } from 'recompose'; + +import { ShapePicker as Component } from './shape_picker'; + +export const ShapePicker = pure(Component); diff --git a/x-pack/plugins/canvas/public/components/shape_picker/shape_picker.js b/x-pack/plugins/canvas/public/components/shape_picker/shape_picker.js new file mode 100644 index 0000000000000..e9c4f1ef66df5 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/shape_picker/shape_picker.js @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { EuiFlexGrid, EuiFlexItem, EuiLink } from '@elastic/eui'; +import { shapes } from '../../../canvas_plugin_src/renderers/shape/shapes'; +import { ShapePreview } from '../shape_preview'; + +export const ShapePicker = ({ onChange }) => { + return ( + + {Object.keys(shapes) + .sort() + .map(shapeKey => ( + + onChange(shapeKey)}> + + + + ))} + + ); +}; + +ShapePicker.propTypes = { + value: PropTypes.string, + onChange: PropTypes.func, +}; diff --git a/x-pack/plugins/canvas/public/components/shape_picker_mini/index.js b/x-pack/plugins/canvas/public/components/shape_picker_mini/index.js new file mode 100644 index 0000000000000..7188f026af590 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/shape_picker_mini/index.js @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pure } from 'recompose'; + +import { ShapePickerMini as Component } from './shape_picker_mini'; + +export const ShapePickerMini = pure(Component); diff --git a/x-pack/plugins/canvas/public/components/shape_picker_mini/shape_picker_mini.js b/x-pack/plugins/canvas/public/components/shape_picker_mini/shape_picker_mini.js new file mode 100644 index 0000000000000..cf9c34adc1fe6 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/shape_picker_mini/shape_picker_mini.js @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { EuiLink } from '@elastic/eui'; +import { Popover } from '../popover'; +import { ShapePicker } from '../shape_picker/'; +import { ShapePreview } from '../shape_preview'; + +export const ShapePickerMini = ({ onChange, value, anchorPosition }) => { + const button = handleClick => ( + + + + ); + + return ( + + {() => } + + ); +}; + +ShapePickerMini.propTypes = { + value: PropTypes.string, + onChange: PropTypes.func, + anchorPosition: PropTypes.string, +}; diff --git a/x-pack/plugins/canvas/public/components/shape_picker_mini/shape_picker_mini.scss b/x-pack/plugins/canvas/public/components/shape_picker_mini/shape_picker_mini.scss new file mode 100644 index 0000000000000..e426de8719b7c --- /dev/null +++ b/x-pack/plugins/canvas/public/components/shape_picker_mini/shape_picker_mini.scss @@ -0,0 +1,3 @@ +.canvasShapePickerMini--popover { + width: 220px; +} diff --git a/x-pack/plugins/canvas/public/components/shape_preview/index.js b/x-pack/plugins/canvas/public/components/shape_preview/index.js new file mode 100644 index 0000000000000..4320a10d97a85 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/shape_preview/index.js @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pure } from 'recompose'; + +import { ShapePreview as Component } from './shape_preview'; + +export const ShapePreview = pure(Component); diff --git a/x-pack/plugins/canvas/public/components/shape_preview/shape_preview.js b/x-pack/plugins/canvas/public/components/shape_preview/shape_preview.js new file mode 100644 index 0000000000000..ed8ede5d4bac2 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/shape_preview/shape_preview.js @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { shapes } from '../../../canvas_plugin_src/renderers/shape/shapes'; + +export const ShapePreview = ({ value }) => { + // eslint-disable-next-line react/no-danger + return
; +}; + +ShapePreview.propTypes = { + value: PropTypes.string, +}; diff --git a/x-pack/plugins/canvas/public/components/shape_preview/shape_preview.scss b/x-pack/plugins/canvas/public/components/shape_preview/shape_preview.scss new file mode 100644 index 0000000000000..1e39827d657a2 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/shape_preview/shape_preview.scss @@ -0,0 +1,10 @@ +.canvasShapePreview { + width: $euiSizeXXL; + height: $euiSizeXXL; + + svg { + fill: black; + width: 100%; + height: 100%; + } +} diff --git a/x-pack/plugins/canvas/public/components/sidebar/global_config.js b/x-pack/plugins/canvas/public/components/sidebar/global_config.js new file mode 100644 index 0000000000000..ee0837495209d --- /dev/null +++ b/x-pack/plugins/canvas/public/components/sidebar/global_config.js @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { PageConfig } from '../page_config'; +import { WorkpadConfig } from '../workpad_config'; +import { SidebarSection } from './sidebar_section'; + +export const GlobalConfig = () => ( +
+ + + + + + +
+); diff --git a/x-pack/plugins/canvas/public/components/sidebar/index.js b/x-pack/plugins/canvas/public/components/sidebar/index.js new file mode 100644 index 0000000000000..5cdbc8668ce55 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/sidebar/index.js @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { connect } from 'react-redux'; +import { duplicateElement, elementLayer } from '../../state/actions/elements'; +import { getSelectedPage, getSelectedElement } from '../../state/selectors/workpad'; + +import { Sidebar as Component } from './sidebar'; + +const mapStateToProps = state => ({ + selectedPage: getSelectedPage(state), + selectedElement: getSelectedElement(state), +}); + +const mapDispatchToProps = dispatch => ({ + duplicateElement: (pageId, selectedElement) => () => + dispatch(duplicateElement(selectedElement, pageId)), + elementLayer: (pageId, selectedElement) => movement => + dispatch( + elementLayer({ + pageId, + elementId: selectedElement.id, + movement, + }) + ), +}); + +const mergeProps = (stateProps, dispatchProps, ownProps) => { + const { selectedElement, selectedPage } = stateProps; + + return { + ...stateProps, + ...dispatchProps, + ...ownProps, + elementIsSelected: Boolean(selectedElement), + duplicateElement: dispatchProps.duplicateElement(selectedPage, selectedElement), + elementLayer: dispatchProps.elementLayer(selectedPage, selectedElement), + }; +}; + +export const Sidebar = connect( + mapStateToProps, + mapDispatchToProps, + mergeProps +)(Component); diff --git a/x-pack/plugins/canvas/public/components/sidebar/sidebar.js b/x-pack/plugins/canvas/public/components/sidebar/sidebar.js new file mode 100644 index 0000000000000..d1a03674e81a0 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/sidebar/sidebar.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { compose, branch, renderComponent } from 'recompose'; +import { SidebarComponent } from './sidebar_component'; +import { GlobalConfig } from './global_config'; + +const branches = [branch(props => !props.selectedElement, renderComponent(GlobalConfig))]; + +export const Sidebar = compose(...branches)(SidebarComponent); diff --git a/x-pack/plugins/canvas/public/components/sidebar/sidebar.scss b/x-pack/plugins/canvas/public/components/sidebar/sidebar.scss new file mode 100644 index 0000000000000..bc31e945cc887 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/sidebar/sidebar.scss @@ -0,0 +1,36 @@ +.canvasSidebar { + @include euiScrollBar; + + width: 100%; + padding: $euiSizeM; + max-height: 100vh; + overflow-y: auto; + overflow-x: hidden; +} + +.canvasSidebar__pop { + animation: sidebarPop $euiAnimSpeedFast $euiAnimSlightResistance; +} + + +.canvasSidebarFlyout { + width: 350px; + min-width: 350px; +} + +.canvasSidebar__elementButtons { + background: darken($euiColorLightestShade, 5%); + margin-bottom: $euiSizeS; +} + +@keyframes sidebarPop { + 0% { + opacity: 0; + } + 1% { + opacity: 0; + } + 100% { + opacity: 1; + } +} diff --git a/x-pack/plugins/canvas/public/components/sidebar/sidebar_component.js b/x-pack/plugins/canvas/public/components/sidebar/sidebar_component.js new file mode 100644 index 0000000000000..2af995285594c --- /dev/null +++ b/x-pack/plugins/canvas/public/components/sidebar/sidebar_component.js @@ -0,0 +1,136 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { + EuiTitle, + EuiSpacer, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiTabbedContent, + EuiToolTip, +} from '@elastic/eui'; +import { Datasource } from '../datasource'; +import { FunctionFormList } from '../function_form_list'; + +export const SidebarComponent = ({ + selectedElement, + duplicateElement, + elementLayer, + elementIsSelected, +}) => { + const tabs = [ + { + id: 'edit', + name: 'Display', + content: ( +
+ +
+ +
+
+ ), + }, + { + id: 'data', + name: 'Data', + content: ( +
+ + +
+ ), + }, + ]; + + return ( +
+ {elementIsSelected && ( +
+ + + +

Selected layer

+
+
+ + + + + + + elementLayer(Infinity)} + aria-label="Move element to top layer" + /> + + + + + elementLayer(1)} + aria-label="Move element up one layer" + /> + + + + + elementLayer(-1)} + aria-label="Move element down one layer" + /> + + + + + elementLayer(-Infinity)} + aria-label="Move element to bottom layer" + /> + + + + + duplicateElement()} + aria-label="Duplicate this element into a new layer" + /> + + + + + + +
+ +
+ )} +
+ ); +}; + +SidebarComponent.propTypes = { + selectedElement: PropTypes.object, + duplicateElement: PropTypes.func.isRequired, + elementLayer: PropTypes.func, + elementIsSelected: PropTypes.bool.isRequired, +}; diff --git a/x-pack/plugins/canvas/public/components/sidebar/sidebar_section.js b/x-pack/plugins/canvas/public/components/sidebar/sidebar_section.js new file mode 100644 index 0000000000000..29ca72a9737a1 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/sidebar/sidebar_section.js @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; + +import { EuiPanel } from '@elastic/eui'; + +export const SidebarSection = ({ children }) => ( + {children} +); + +SidebarSection.propTypes = { + children: PropTypes.node, + title: PropTypes.string, + tip: PropTypes.string, +}; diff --git a/x-pack/plugins/canvas/public/components/sidebar/sidebar_section_title.js b/x-pack/plugins/canvas/public/components/sidebar/sidebar_section_title.js new file mode 100644 index 0000000000000..2d9888b969452 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/sidebar/sidebar_section_title.js @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { EuiTitle, EuiFlexItem, EuiFlexGroup, EuiToolTip } from '@elastic/eui'; + +export const SidebarSectionTitle = ({ title, tip, children }) => { + const formattedTitle = ( + +

{title}

+
+ ); + const renderTitle = () => { + if (tip) { + return ( + + {formattedTitle} + + ); + } + + return formattedTitle; + }; + + return ( + + {renderTitle(tip)} + {children} + + ); +}; + +SidebarSectionTitle.propTypes = { + children: PropTypes.node, + title: PropTypes.string, + tip: PropTypes.string, +}; diff --git a/x-pack/plugins/canvas/public/components/text_style_picker/font_sizes.js b/x-pack/plugins/canvas/public/components/text_style_picker/font_sizes.js new file mode 100644 index 0000000000000..e13b0c1118355 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/text_style_picker/font_sizes.js @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const fontSizes = [0, 6, 7, 8, 9, 10, 11, 12, 14, 16, 18, 24, 30, 36, 48, 60, 72, 96]; diff --git a/x-pack/plugins/canvas/public/components/text_style_picker/index.js b/x-pack/plugins/canvas/public/components/text_style_picker/index.js new file mode 100644 index 0000000000000..79bde95723682 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/text_style_picker/index.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pure } from 'recompose'; +import { TextStylePicker as Component } from './text_style_picker'; + +export const TextStylePicker = pure(Component); diff --git a/x-pack/plugins/canvas/public/components/text_style_picker/text_style_picker.js b/x-pack/plugins/canvas/public/components/text_style_picker/text_style_picker.js new file mode 100644 index 0000000000000..38d34a48d7532 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/text_style_picker/text_style_picker.js @@ -0,0 +1,159 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { EuiFlexGroup, EuiFlexItem, EuiSelect, EuiSpacer, EuiButtonGroup } from '@elastic/eui'; +import { FontPicker } from '../font_picker'; +import { ColorPickerMini } from '../color_picker_mini'; +import { fontSizes } from './font_sizes'; + +export const TextStylePicker = ({ + family, + size, + align, + color, + weight, + underline, + italic, + onChange, + colors, +}) => { + const alignmentButtons = [ + { + id: 'left', + label: 'Align left', + iconType: 'editorAlignLeft', + }, + { + id: 'center', + label: 'Align center', + iconType: 'editorAlignCenter', + }, + { + id: 'right', + label: 'Align right', + iconType: 'editorAlignRight', + }, + ]; + + const styleButtons = [ + { + id: 'bold', + label: 'Bold', + iconType: 'editorBold', + }, + { + id: 'italic', + label: 'Italic', + iconType: 'editorItalic', + }, + { + id: 'underline', + label: 'Underline', + iconType: 'editorUnderline', + }, + ]; + + const stylesSelectedMap = { + ['bold']: weight === 'bold', + ['italic']: Boolean(italic), + ['underline']: Boolean(underline), + }; + + const doChange = (propName, value) => { + onChange({ + family, + size, + align, + color, + weight: weight || 'normal', + underline: underline || false, + italic: italic || false, + [propName]: value, + }); + }; + + const onAlignmentChange = optionId => doChange('align', optionId); + + const onStyleChange = optionId => { + let prop; + let value; + + if (optionId === 'bold') { + prop = 'weight'; + value = !stylesSelectedMap[optionId] ? 'bold' : 'normal'; + } else { + prop = optionId; + value = !stylesSelectedMap[optionId]; + } + + doChange(prop, value); + }; + + return ( +
+ + + doChange('size', Number(e.target.value))} + options={fontSizes.map(size => ({ text: String(size), value: size }))} + /> + + + doChange('family', value)} /> + + + + + + + + + + + + + + doChange('color', value)} + colors={colors} + /> + + +
+ ); +}; + +TextStylePicker.propTypes = { + family: PropTypes.string, + size: PropTypes.number, + align: PropTypes.string, + color: PropTypes.string, + weight: PropTypes.string, + underline: PropTypes.bool, + italic: PropTypes.bool, + onChange: PropTypes.func.isRequired, + colors: PropTypes.array, +}; + +TextStylePicker.defaultProps = { + align: 'left', +}; diff --git a/x-pack/plugins/canvas/public/components/toolbar/index.js b/x-pack/plugins/canvas/public/components/toolbar/index.js new file mode 100644 index 0000000000000..a533e032ff765 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/toolbar/index.js @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { compose, withState, getContext, withHandlers } from 'recompose'; +import { getEditing } from '../../state/selectors/app'; + +import { + getWorkpad, + getWorkpadName, + getSelectedPageIndex, + getSelectedElement, +} from '../../state/selectors/workpad'; + +import { Toolbar as Component } from './toolbar'; + +const mapStateToProps = state => ({ + editing: getEditing(state), + workpadName: getWorkpadName(state), + workpadId: getWorkpad(state).id, + totalPages: getWorkpad(state).pages.length, + selectedPageNumber: getSelectedPageIndex(state) + 1, + selectedElement: getSelectedElement(state), +}); + +export const Toolbar = compose( + connect(mapStateToProps), + getContext({ + router: PropTypes.object, + }), + withHandlers({ + nextPage: props => () => { + const pageNumber = Math.min(props.selectedPageNumber + 1, props.totalPages); + props.router.navigateTo('loadWorkpad', { id: props.workpadId, page: pageNumber }); + }, + previousPage: props => () => { + const pageNumber = Math.max(1, props.selectedPageNumber - 1); + props.router.navigateTo('loadWorkpad', { id: props.workpadId, page: pageNumber }); + }, + }), + withState('tray', 'setTray', props => props.tray) +)(Component); diff --git a/x-pack/plugins/canvas/public/components/toolbar/toolbar.js b/x-pack/plugins/canvas/public/components/toolbar/toolbar.js new file mode 100644 index 0000000000000..68db85912dbe3 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/toolbar/toolbar.js @@ -0,0 +1,133 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { + EuiButtonEmpty, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiOverlayMask, + EuiModal, + EuiModalFooter, + EuiButton, +} from '@elastic/eui'; +import { Navbar } from '../navbar'; +import { WorkpadLoader } from '../workpad_loader'; +import { PageManager } from '../page_manager'; +import { Expression } from '../expression'; +import { Tray } from './tray'; + +export const Toolbar = props => { + const { + editing, + selectedElement, + tray, + setTray, + previousPage, + nextPage, + selectedPageNumber, + workpadName, + totalPages, + } = props; + + const elementIsSelected = Boolean(selectedElement); + + const done = () => setTray(null); + + const showHideTray = exp => { + if (tray && tray === exp) return done(); + setTray(exp); + }; + + const workpadLoader = ( + + + + + + Dismiss + + + + + ); + + const trays = { + pageManager: , + workpadloader: workpadLoader, + expression: !elementIsSelected ? null : , + }; + + return !editing ? null : ( +
+ {trays[tray] && {trays[tray]}} + + + + showHideTray('workpadloader')} + > + {workpadName} + + + + + + + + showHideTray('pageManager')}> + Page {selectedPageNumber} + {totalPages > 1 ? ` of ${totalPages}` : null} + + + + = totalPages} + aria-label="Next Page" + /> + + + {elementIsSelected && ( + + showHideTray('expression')} + > + Expression editor + + + )} + + +
+ ); +}; + +Toolbar.propTypes = { + workpadName: PropTypes.string, + editing: PropTypes.bool, + tray: PropTypes.node, + setTray: PropTypes.func.isRequired, + nextPage: PropTypes.func.isRequired, + previousPage: PropTypes.func.isRequired, + selectedPageNumber: PropTypes.number.isRequired, + totalPages: PropTypes.number.isRequired, + selectedElement: PropTypes.object, +}; diff --git a/x-pack/plugins/canvas/public/components/toolbar/toolbar.scss b/x-pack/plugins/canvas/public/components/toolbar/toolbar.scss new file mode 100644 index 0000000000000..1219a8d1dce5f --- /dev/null +++ b/x-pack/plugins/canvas/public/components/toolbar/toolbar.scss @@ -0,0 +1,4 @@ +.canvasToolbar__controls { + padding: $euiSizeM; + height: 100%; +} diff --git a/x-pack/plugins/canvas/public/components/toolbar/tray/index.js b/x-pack/plugins/canvas/public/components/toolbar/tray/index.js new file mode 100644 index 0000000000000..1343bc8d01e9a --- /dev/null +++ b/x-pack/plugins/canvas/public/components/toolbar/tray/index.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pure } from 'recompose'; +import { Tray as Component } from './tray'; + +export const Tray = pure(Component); diff --git a/x-pack/plugins/canvas/public/components/toolbar/tray/tray.js b/x-pack/plugins/canvas/public/components/toolbar/tray/tray.js new file mode 100644 index 0000000000000..07cb732f04691 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/toolbar/tray/tray.js @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { EuiFlexGroup, EuiFlexItem, EuiButtonIcon } from '@elastic/eui'; + +export const Tray = ({ children, done }) => { + return ( + + + + + + + +
{children}
+
+ ); +}; + +Tray.propTypes = { + children: PropTypes.node, + done: PropTypes.func, +}; diff --git a/x-pack/plugins/canvas/public/components/toolbar/tray/tray.scss b/x-pack/plugins/canvas/public/components/toolbar/tray/tray.scss new file mode 100644 index 0000000000000..9d8b6d13dde94 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/toolbar/tray/tray.scss @@ -0,0 +1,5 @@ +.canvasTray { + max-height: 400px; + flex-direction: column; + @include euiBottomShadowFlat; +} diff --git a/x-pack/plugins/canvas/public/components/tooltip_icon/index.js b/x-pack/plugins/canvas/public/components/tooltip_icon/index.js new file mode 100644 index 0000000000000..3d94574570b9e --- /dev/null +++ b/x-pack/plugins/canvas/public/components/tooltip_icon/index.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pure } from 'recompose'; +import { TooltipIcon as Component } from './tooltip_icon'; + +export const TooltipIcon = pure(Component); diff --git a/x-pack/plugins/canvas/public/components/tooltip_icon/tooltip_icon.js b/x-pack/plugins/canvas/public/components/tooltip_icon/tooltip_icon.js new file mode 100644 index 0000000000000..9afdf5e06138d --- /dev/null +++ b/x-pack/plugins/canvas/public/components/tooltip_icon/tooltip_icon.js @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint react/forbid-elements: 0 */ +import React from 'react'; +import { PropTypes } from 'prop-types'; +import { EuiIconTip } from '@elastic/eui'; + +export const TooltipIcon = ({ icon = 'info', ...rest }) => { + const icons = { + error: { type: 'alert', color: 'danger' }, + warning: { type: 'alert', color: 'warning' }, + info: { type: 'iInCircle', color: 'default' }, + }; + + if (!Object.keys(icons).includes(icon)) throw new Error(`Unsupported icon type: ${icon}`); + + return ; +}; + +TooltipIcon.propTypes = { + icon: PropTypes.string, +}; diff --git a/x-pack/plugins/canvas/public/components/workpad/index.js b/x-pack/plugins/canvas/public/components/workpad/index.js new file mode 100644 index 0000000000000..cc6c3ff124615 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad/index.js @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { compose, withState, withProps, getContext, withHandlers } from 'recompose'; +import { transitionsRegistry } from '../../lib/transitions_registry'; +import { undoHistory, redoHistory } from '../../state/actions/history'; +import { fetchAllRenderables } from '../../state/actions/elements'; +import { getFullscreen, getEditing } from '../../state/selectors/app'; +import { + getSelectedPageIndex, + getAllElements, + getWorkpad, + getPages, +} from '../../state/selectors/workpad'; +import { Workpad as Component } from './workpad'; + +const mapStateToProps = state => ({ + pages: getPages(state), + selectedPageNumber: getSelectedPageIndex(state) + 1, + totalElementCount: getAllElements(state).length, + workpad: getWorkpad(state), + isFullscreen: getFullscreen(state), + isEditing: getEditing(state), +}); + +const mapDispatchToProps = { + undoHistory, + redoHistory, + fetchAllRenderables, +}; + +export const Workpad = compose( + getContext({ + router: PropTypes.object, + }), + withState('grid', 'setGrid', false), + connect( + mapStateToProps, + mapDispatchToProps + ), + withState('transition', 'setTransition', null), + withState('prevSelectedPageNumber', 'setPrevSelectedPageNumber', 0), + withProps(({ selectedPageNumber, prevSelectedPageNumber, transition }) => { + function getAnimation(pageNumber) { + if (!transition || !transition.name) return null; + if (![selectedPageNumber, prevSelectedPageNumber].includes(pageNumber)) return null; + const { enter, exit } = transitionsRegistry.get(transition.name); + const laterPageNumber = Math.max(selectedPageNumber, prevSelectedPageNumber); + const name = pageNumber === laterPageNumber ? enter : exit; + const direction = prevSelectedPageNumber > selectedPageNumber ? 'reverse' : 'normal'; + return { name, direction }; + } + + return { getAnimation }; + }), + withHandlers({ + onPageChange: props => pageNumber => { + if (pageNumber === props.selectedPageNumber) return; + props.setPrevSelectedPageNumber(props.selectedPageNumber); + const transitionPage = Math.max(props.selectedPageNumber, pageNumber) - 1; + const { transition } = props.workpad.pages[transitionPage]; + if (transition) props.setTransition(transition); + props.router.navigateTo('loadWorkpad', { id: props.workpad.id, page: pageNumber }); + }, + }), + withHandlers({ + onTransitionEnd: ({ setTransition }) => () => setTransition(null), + nextPage: props => () => { + const pageNumber = Math.min(props.selectedPageNumber + 1, props.workpad.pages.length); + props.onPageChange(pageNumber); + }, + previousPage: props => () => { + const pageNumber = Math.max(1, props.selectedPageNumber - 1); + props.onPageChange(pageNumber); + }, + }) +)(Component); diff --git a/x-pack/plugins/canvas/public/components/workpad/workpad.js b/x-pack/plugins/canvas/public/components/workpad/workpad.js new file mode 100644 index 0000000000000..fde969340e1e4 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad/workpad.js @@ -0,0 +1,129 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { Shortcuts } from 'react-shortcuts'; +import { WorkpadPage } from '../workpad_page'; +import { Fullscreen } from '../fullscreen'; + +export const Workpad = props => { + const { + selectedPageNumber, + getAnimation, + onTransitionEnd, + pages, + totalElementCount, + workpad, + fetchAllRenderables, + undoHistory, + redoHistory, + setGrid, // TODO: Get rid of grid when we improve the layout engine + grid, + nextPage, + previousPage, + isFullscreen, + } = props; + + const { height, width } = workpad; + const bufferStyle = { + height: isFullscreen ? height : height + 32, + width: isFullscreen ? width : width + 32, + }; + + const keyHandler = action => { + // handle keypress events for editor and presentation events + // this exists in both contexts + if (action === 'REFRESH') return fetchAllRenderables(); + + // editor events + if (action === 'UNDO') return undoHistory(); + if (action === 'REDO') return redoHistory(); + if (action === 'GRID') return setGrid(!grid); + + // presentation events + if (action === 'PREV') return previousPage(); + if (action === 'NEXT') return nextPage(); + }; + + return ( +
+
+ {!isFullscreen && ( + + )} + + + {({ isFullscreen, windowSize }) => { + const scale = Math.min(windowSize.height / height, windowSize.width / width); + const fsStyle = isFullscreen + ? { + transform: `scale3d(${scale}, ${scale}, 1)`, + WebkitTransform: `scale3d(${scale}, ${scale}, 1)`, + msTransform: `scale3d(${scale}, ${scale}, 1)`, + // height, + // width, + height: windowSize.height < height ? 'auto' : height, + width: windowSize.width < width ? 'auto' : width, + } + : {}; + + // NOTE: the data-shared-* attributes here are used for reporting + return ( +
+ {isFullscreen && ( + + )} + {pages.map((page, i) => ( + + ))} +
+
+ ); + }} + +
+
+ ); +}; + +Workpad.propTypes = { + selectedPageNumber: PropTypes.number.isRequired, + getAnimation: PropTypes.func.isRequired, + onTransitionEnd: PropTypes.func.isRequired, + grid: PropTypes.bool.isRequired, + setGrid: PropTypes.func.isRequired, + pages: PropTypes.array.isRequired, + totalElementCount: PropTypes.number.isRequired, + isFullscreen: PropTypes.bool.isRequired, + workpad: PropTypes.object.isRequired, + undoHistory: PropTypes.func.isRequired, + redoHistory: PropTypes.func.isRequired, + nextPage: PropTypes.func.isRequired, + previousPage: PropTypes.func.isRequired, + fetchAllRenderables: PropTypes.func.isRequired, + style: PropTypes.object, +}; diff --git a/x-pack/plugins/canvas/public/components/workpad/workpad.scss b/x-pack/plugins/canvas/public/components/workpad/workpad.scss new file mode 100644 index 0000000000000..6bd1ae58d2846 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad/workpad.scss @@ -0,0 +1,18 @@ +.canvasWorkpad { + position: relative; +} + +.canvasWorkpad__buffer { + padding: $euiSize; + margin: auto; +} + +.canvasGrid { + position: absolute; + user-select: none; + pointer-events: none; + background-image: linear-gradient(to right, grey 1px, transparent 1px), + linear-gradient(to bottom, grey 1px, transparent 1px); + background-size: 50px 50px; + top: 0; +} diff --git a/x-pack/plugins/canvas/public/components/workpad_color_picker/index.js b/x-pack/plugins/canvas/public/components/workpad_color_picker/index.js new file mode 100644 index 0000000000000..23f8a3ff8abbd --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_color_picker/index.js @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { connect } from 'react-redux'; +import { getWorkpadColors } from '../../state/selectors/workpad'; +import { addColor, removeColor } from '../../state/actions/workpad'; + +import { WorkpadColorPicker as Component } from './workpad_color_picker'; + +const mapStateToProps = state => ({ + colors: getWorkpadColors(state), +}); + +const mapDispatchToProps = { + addColor, + removeColor, +}; + +export const WorkpadColorPicker = connect( + mapStateToProps, + mapDispatchToProps +)(Component); diff --git a/x-pack/plugins/canvas/public/components/workpad_color_picker/workpad_color_picker.js b/x-pack/plugins/canvas/public/components/workpad_color_picker/workpad_color_picker.js new file mode 100644 index 0000000000000..9d57085d9b123 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_color_picker/workpad_color_picker.js @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { ColorPicker } from '../color_picker'; + +export const WorkpadColorPicker = ({ onChange, value, colors, addColor, removeColor }) => { + return ( +
+ +
+ ); +}; + +WorkpadColorPicker.propTypes = { + value: PropTypes.string, + onChange: PropTypes.func, + colors: PropTypes.array, + addColor: PropTypes.func, + removeColor: PropTypes.func, +}; diff --git a/x-pack/plugins/canvas/public/components/workpad_config/index.js b/x-pack/plugins/canvas/public/components/workpad_config/index.js new file mode 100644 index 0000000000000..0d802a87ac560 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_config/index.js @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { connect } from 'react-redux'; + +import { get } from 'lodash'; +import { sizeWorkpad, setName } from '../../state/actions/workpad'; +import { getWorkpad } from '../../state/selectors/workpad'; + +import { WorkpadConfig as Component } from './workpad_config'; + +const mapStateToProps = state => { + const workpad = getWorkpad(state); + + return { + name: get(workpad, 'name'), + size: { + width: get(workpad, 'width'), + height: get(workpad, 'height'), + }, + }; +}; + +const mapDispatchToProps = { + setSize: size => sizeWorkpad(size), + setName: name => setName(name), +}; + +export const WorkpadConfig = connect( + mapStateToProps, + mapDispatchToProps +)(Component); diff --git a/x-pack/plugins/canvas/public/components/workpad_config/workpad_config.js b/x-pack/plugins/canvas/public/components/workpad_config/workpad_config.js new file mode 100644 index 0000000000000..c2effd5237293 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_config/workpad_config.js @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { + EuiFieldText, + EuiFieldNumber, + EuiBadge, + EuiButtonIcon, + EuiFormRow, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiTitle, + EuiToolTip, +} from '@elastic/eui'; + +export const WorkpadConfig = ({ size, name, setSize, setName }) => { + const rotate = () => setSize({ width: size.height, height: size.width }); + + const badges = [ + { + name: '1080p', + size: { height: 1080, width: 1920 }, + }, + { + name: '720p', + size: { height: 720, width: 1280 }, + }, + { + name: 'A4', + size: { height: 842, width: 590 }, + }, + { + name: 'US Letter', + size: { height: 792, width: 612 }, + }, + ]; + + return ( +
+ +

Workpad

+
+ + + + + setName(e.target.value)} /> + + + + + + setSize({ width: Number(e.target.value), height: size.height })} + value={size.width} + /> + + + + + + + + + + + + setSize({ height: Number(e.target.value), width: size.width })} + value={size.height} + /> + + + + + + +
+ {badges.map((badge, i) => ( + setSize(badge.size)} + aria-label={`Preset Page Size: ${badge.name}`} + onClickAriaLabel={`Set page size to ${badge.name}`} + > + {badge.name} + + ))} +
+
+ ); +}; + +WorkpadConfig.propTypes = { + size: PropTypes.object.isRequired, + name: PropTypes.string.isRequired, + setSize: PropTypes.func.isRequired, + setName: PropTypes.func.isRequired, +}; diff --git a/x-pack/plugins/canvas/public/components/workpad_export/index.js b/x-pack/plugins/canvas/public/components/workpad_export/index.js new file mode 100644 index 0000000000000..1ef7438f520d5 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_export/index.js @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint import/no-unresolved: 1 */ +// TODO: remove eslint rule when updating to use the linked kibana resolve package +import { jobCompletionNotifications } from 'plugins/reporting/services/job_completion_notifications'; +import { connect } from 'react-redux'; +import { compose, withProps } from 'recompose'; +import { getWorkpad, getPages } from '../../state/selectors/workpad'; +import { getReportingBrowserType } from '../../state/selectors/app'; +import { notify } from '../../lib/notify'; +import { getWindow } from '../../lib/get_window'; +import { WorkpadExport as Component } from './workpad_export'; +import { getPdfUrl, createPdf } from './utils'; + +const mapStateToProps = state => ({ + workpad: getWorkpad(state), + pageCount: getPages(state).length, + enabled: getReportingBrowserType(state) === 'chromium', +}); + +const getAbsoluteUrl = path => { + const { location } = getWindow(); + if (!location) return path; // fallback for mocked window object + + const { protocol, hostname, port } = location; + return `${protocol}//${hostname}:${port}${path}`; +}; + +export const WorkpadExport = compose( + connect(mapStateToProps), + withProps(({ workpad, pageCount }) => ({ + getExportUrl: type => { + if (type === 'pdf') return getAbsoluteUrl(getPdfUrl(workpad, { pageCount })); + + throw new Error(`Unknown export type: ${type}`); + }, + onCopy: type => { + if (type === 'pdf') + return notify.info('The PDF generation URL was copied to your clipboard.'); + + throw new Error(`Unknown export type: ${type}`); + }, + onExport: type => { + if (type === 'pdf') { + return createPdf(workpad, { pageCount }) + .then(({ data }) => { + notify.info('Exporting PDF. You can track the progress in Management.', { + title: `PDF export of workpad '${workpad.name}'`, + }); + + // register the job so a completion notification shows up when it's ready + jobCompletionNotifications.add(data.job.id); + }) + .catch(err => { + notify.error(err, { title: `Failed to create PDF for '${workpad.name}'` }); + }); + } + + throw new Error(`Unknown export type: ${type}`); + }, + })) +)(Component); diff --git a/x-pack/plugins/canvas/public/components/workpad_export/utils.js b/x-pack/plugins/canvas/public/components/workpad_export/utils.js new file mode 100644 index 0000000000000..be2e7e6d8114c --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_export/utils.js @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import chrome from 'ui/chrome'; +import { QueryString } from 'ui/utils/query_string'; +import rison from 'rison-node'; +import { fetch } from '../../../common/lib/fetch'; + +// type of the desired pdf output (print or preserve_layout) +const PDF_LAYOUT_TYPE = 'preserve_layout'; + +export function getPdfUrl({ id, name: title, width, height }, { pageCount }) { + const reportingEntry = chrome.addBasePath('/api/reporting/generate'); + const canvasEntry = '/app/canvas#'; + + // The viewport in Reporting by specifying the dimensions. In order for things to work, + // we need a viewport that will include all of the pages in the workpad. The viewport + // also needs to include any offset values from the 0,0 position, otherwise the cropped + // screenshot that Reporting takes will be off the mark. Reporting will take a screenshot + // of the entire viewport and then crop it down to the element that was asked for. + + // NOTE: while the above is true, the scaling seems to be broken. The export screen draws + // pages at the 0,0 point, so the offset isn't currently required to get the correct + // viewport size. + + // build a list of all page urls for exporting, they are captured one at a time + const workpadUrls = []; + for (let i = 1; i <= pageCount; i++) + workpadUrls.push(rison.encode(`${canvasEntry}/export/workpad/pdf/${id}/page/${i}`)); + + const jobParams = { + browserTimezone: 'America/Phoenix', // TODO: get browser timezone, or Kibana setting? + layout: { + dimensions: { width, height }, + id: PDF_LAYOUT_TYPE, + }, + objectType: 'canvas workpad', + relativeUrls: workpadUrls, + title, + }; + + return `${reportingEntry}/printablePdf?${QueryString.param( + 'jobParams', + rison.encode(jobParams) + )}`; +} + +export function createPdf(...args) { + const createPdfUri = getPdfUrl(...args); + return fetch.post(createPdfUri); +} diff --git a/x-pack/plugins/canvas/public/components/workpad_export/workpad_export.js b/x-pack/plugins/canvas/public/components/workpad_export/workpad_export.js new file mode 100644 index 0000000000000..7056e6250789a --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_export/workpad_export.js @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { + EuiButton, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiCodeBlock, + EuiHorizontalRule, + EuiFormRow, +} from '@elastic/eui'; +import { Popover } from '../popover'; +import { Clipboard } from '../clipboard'; + +export class WorkpadExport extends React.PureComponent { + static propTypes = { + enabled: PropTypes.bool.isRequired, + onCopy: PropTypes.func.isRequired, + onExport: PropTypes.func.isRequired, + getExportUrl: PropTypes.func.isRequired, + }; + + exportPdf = () => { + this.props.onExport('pdf'); + }; + + renderControls = closePopover => { + const pdfUrl = this.props.getExportUrl('pdf'); + return ( +
+ + + + { + this.exportPdf(); + closePopover(); + }} + > + Export as PDF + + + + + + + + + + {pdfUrl} + + + + + { + this.props.onCopy('pdf'); + closePopover(); + }} + > + + + + + +
+ ); + }; + + renderDisabled = () => { + return ( +
+ Export to PDF is disabled. You must configure reporting to use the Chromium browser. Add + this to your kibana.yml file. + + + xpack.reporting.capture.browser.type: chromium + +
+ ); + }; + + render() { + const exportControl = togglePopover => ( + + ); + + return ( + + {({ closePopover }) => ( + + + {this.props.enabled && this.renderControls(closePopover)} + {!this.props.enabled && this.renderDisabled()} + + + )} + + ); + } +} diff --git a/x-pack/plugins/canvas/public/components/workpad_header/index.js b/x-pack/plugins/canvas/public/components/workpad_header/index.js new file mode 100644 index 0000000000000..1941fc4bcf3fc --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_header/index.js @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { compose, withState } from 'recompose'; +import { connect } from 'react-redux'; +import { getEditing } from '../../state/selectors/app'; +import { getWorkpadName, getSelectedPage } from '../../state/selectors/workpad'; +import { setEditing } from '../../state/actions/transient'; +import { getAssets } from '../../state/selectors/assets'; +import { addElement } from '../../state/actions/elements'; +import { WorkpadHeader as Component } from './workpad_header'; + +const mapStateToProps = state => ({ + editing: getEditing(state), + workpadName: getWorkpadName(state), + selectedPage: getSelectedPage(state), + hasAssets: Object.keys(getAssets(state)).length ? true : false, +}); + +const mapDispatchToProps = dispatch => ({ + setEditing: editing => dispatch(setEditing(editing)), + addElement: pageId => partialElement => dispatch(addElement(pageId, partialElement)), +}); + +const mergeProps = (stateProps, dispatchProps, ownProps) => ({ + ...stateProps, + ...dispatchProps, + ...ownProps, + addElement: dispatchProps.addElement(stateProps.selectedPage), + toggleEditing: () => dispatchProps.setEditing(!stateProps.editing), +}); + +export const WorkpadHeader = compose( + withState('showElementModal', 'setShowElementModal', false), + connect( + mapStateToProps, + mapDispatchToProps, + mergeProps + ) +)(Component); diff --git a/x-pack/plugins/canvas/public/components/workpad_header/workpad_header.js b/x-pack/plugins/canvas/public/components/workpad_header/workpad_header.js new file mode 100644 index 0000000000000..f268e475b7589 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_header/workpad_header.js @@ -0,0 +1,132 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { Shortcuts } from 'react-shortcuts'; +import { + EuiFlexItem, + EuiFlexGroup, + EuiButtonIcon, + EuiButton, + EuiOverlayMask, + EuiModal, + EuiModalFooter, + EuiToolTip, +} from '@elastic/eui'; +import { AssetManager } from '../asset_manager'; +import { ElementTypes } from '../element_types'; +import { WorkpadExport } from '../workpad_export'; +import { FullscreenControl } from '../fullscreen_control'; +import { RefreshControl } from '../refresh_control'; + +export const WorkpadHeader = ({ + editing, + toggleEditing, + hasAssets, + addElement, + setShowElementModal, + showElementModal, +}) => { + const keyHandler = action => { + if (action === 'EDITING') toggleEditing(); + }; + + const elementAdd = ( + + setShowElementModal(false)} className="canvasModal--fixedSize"> + { + addElement(element); + setShowElementModal(false); + }} + /> + + setShowElementModal(false)}> + Dismiss + + + + + ); + + return ( +
+ {showElementModal ? elementAdd : null} + + + + + + + + + {({ toggleFullscreen }) => ( + + + + )} + + + + + + + + + { + toggleEditing(); + }} + size="s" + aria-label={editing ? 'Hide editing controls' : 'Show editing controls'} + /> + + + + + {editing ? ( + + + {hasAssets && ( + + + + )} + + setShowElementModal(true)} + > + Add element + + + + + ) : null} + +
+ ); +}; + +WorkpadHeader.propTypes = { + editing: PropTypes.bool, + toggleEditing: PropTypes.func, + hasAssets: PropTypes.bool, + addElement: PropTypes.func.isRequired, + showElementModal: PropTypes.bool, + setShowElementModal: PropTypes.func, +}; diff --git a/x-pack/plugins/canvas/public/components/workpad_loader/index.js b/x-pack/plugins/canvas/public/components/workpad_loader/index.js new file mode 100644 index 0000000000000..8eee65d70df02 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_loader/index.js @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { compose, withState, getContext, withHandlers } from 'recompose'; +import fileSaver from 'file-saver'; +import * as workpadService from '../../lib/workpad_service'; +import { notify } from '../../lib/notify'; +import { getWorkpad } from '../../state/selectors/workpad'; +import { getId } from '../../lib/get_id'; +import { WorkpadLoader as Component } from './workpad_loader'; + +const mapStateToProps = state => ({ + workpadId: getWorkpad(state).id, +}); + +export const WorkpadLoader = compose( + getContext({ + router: PropTypes.object, + }), + connect(mapStateToProps), + withState('workpads', 'setWorkpads', null), + withHandlers({ + // Workpad creation via navigation + createWorkpad: props => async workpad => { + // workpad data uploaded, create and load it + if (workpad != null) { + try { + await workpadService.create(workpad); + props.router.navigateTo('loadWorkpad', { id: workpad.id, page: 1 }); + } catch (err) { + notify.error(err, { title: `Couldn't upload workpad` }); + } + return; + } + + props.router.navigateTo('createWorkpad'); + }, + + // Workpad search + findWorkpads: ({ setWorkpads }) => async text => { + try { + const workpads = await workpadService.find(text); + setWorkpads(workpads); + } catch (err) { + notify.error(err, { title: `Couldn't find workpads` }); + } + }, + + // Workpad import/export methods + downloadWorkpad: () => async workpadId => { + try { + const workpad = await workpadService.get(workpadId); + const jsonBlob = new Blob([JSON.stringify(workpad)], { type: 'application/json' }); + fileSaver.saveAs(jsonBlob, `canvas-workpad-${workpad.name}-${workpad.id}.json`); + } catch (err) { + notify.error(err, { title: `Couldn't download workpad` }); + } + }, + + // Clone workpad given an id + cloneWorkpad: props => async workpadId => { + try { + const workpad = await workpadService.get(workpadId); + workpad.name += ' - Copy'; + workpad.id = getId('workpad'); + await workpadService.create(workpad); + props.router.navigateTo('loadWorkpad', { id: workpad.id, page: 1 }); + } catch (err) { + notify.error(err, { title: `Couldn't clone workpad` }); + } + }, + + // Remove workpad given an array of id + removeWorkpads: props => async workpadIds => { + const { setWorkpads, workpads, workpadId: loadedWorkpad } = props; + + const removeWorkpads = workpadIds.map(id => + workpadService + .remove(id) + .then(() => ({ id, err: null })) + .catch(err => ({ + id, + err, + })) + ); + + return Promise.all(removeWorkpads).then(results => { + let redirectHome = false; + + const [passes, errors] = results.reduce( + ([passes, errors], result) => { + if (result.id === loadedWorkpad && !result.err) redirectHome = true; + + if (result.err) errors.push(result.id); + else passes.push(result.id); + + return [passes, errors]; + }, + [[], []] + ); + + const remainingWorkpads = workpads.workpads.filter(({ id }) => !passes.includes(id)); + + const workpadState = { + total: remainingWorkpads.length, + workpads: remainingWorkpads, + }; + + if (errors.length > 0) notify.error("Couldn't delete all workpads"); + + setWorkpads(workpadState); + + if (redirectHome) props.router.navigateTo('home'); + + return errors.map(({ id }) => id); + }); + }, + }) +)(Component); diff --git a/x-pack/plugins/canvas/public/components/workpad_loader/workpad_create.js b/x-pack/plugins/canvas/public/components/workpad_loader/workpad_create.js new file mode 100644 index 0000000000000..3caf4d553cca2 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_loader/workpad_create.js @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { EuiButton } from '@elastic/eui'; + +export const WorkpadCreate = ({ createPending, onCreate }) => ( + + Create workpad + +); + +WorkpadCreate.propTypes = { + onCreate: PropTypes.func.isRequired, + createPending: PropTypes.bool, +}; diff --git a/x-pack/plugins/canvas/public/components/workpad_loader/workpad_dropzone/index.js b/x-pack/plugins/canvas/public/components/workpad_loader/workpad_dropzone/index.js new file mode 100644 index 0000000000000..f2cce0118c6ed --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_loader/workpad_dropzone/index.js @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import PropTypes from 'prop-types'; +import { compose, withState, withHandlers } from 'recompose'; +import { getId } from '../../../lib/get_id'; +import { notify } from '../../../lib/notify'; +import { WorkpadDropzone as Component } from './workpad_dropzone'; + +export const WorkpadDropzone = compose( + withState('isDropping', 'setDropping', false), + withHandlers({ + onDropAccepted: ({ onUpload, setDropping }) => ([file]) => { + // TODO: Clean up this file, this loading stuff can, and should be, abstracted + const reader = new FileReader(); + + // handle reading the uploaded file + reader.onload = () => { + try { + const workpad = JSON.parse(reader.result); + workpad.id = getId('workpad'); + onUpload(workpad); + } catch (e) { + notify.error(e, { title: `Couldn't upload '${file.name || 'file'}'` }); + } + }; + + // read the uploaded file + reader.readAsText(file); + setDropping(false); + }, + onDropRejected: ({ setDropping }) => ([file]) => { + notify.warning('Only JSON files are accepted', { + title: `Couldn't upload '${file.name || 'file'}'`, + }); + setDropping(false); + }, + }) +)(Component); + +WorkpadDropzone.propTypes = { + onUpload: PropTypes.func.isRequired, +}; diff --git a/x-pack/plugins/canvas/public/components/workpad_loader/workpad_dropzone/workpad_dropzone.js b/x-pack/plugins/canvas/public/components/workpad_loader/workpad_dropzone/workpad_dropzone.js new file mode 100644 index 0000000000000..274c8ef2cc9b8 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_loader/workpad_dropzone/workpad_dropzone.js @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import Dropzone from 'react-dropzone'; + +export const WorkpadDropzone = ({ setDropping, onDropAccepted, onDropRejected, children }) => ( + setDropping(true)} + onDragLeave={() => setDropping(false)} + disableClick + className="canvasWorkpad__dropzone" + activeClassName="canvasWorkpad__dropzone--active" + > + {children} + +); + +WorkpadDropzone.propTypes = { + isDropping: PropTypes.bool.isRequired, + setDropping: PropTypes.func.isRequired, + onDropAccepted: PropTypes.func.isRequired, + onDropRejected: PropTypes.func.isRequired, + children: PropTypes.node.isRequired, +}; diff --git a/x-pack/plugins/canvas/public/components/workpad_loader/workpad_dropzone/workpad_dropzone.scss b/x-pack/plugins/canvas/public/components/workpad_loader/workpad_dropzone/workpad_dropzone.scss new file mode 100644 index 0000000000000..b7bdff7ca6e17 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_loader/workpad_dropzone/workpad_dropzone.scss @@ -0,0 +1,12 @@ +.canvasWorkpad__dropzone { + border: 2px dashed transparent; +} + +.canvasWorkpad__dropzone--active { + background-color: $euiColorLightestShade; + border-color: $euiColorLightShade; +} + +.canvasWorkpad__dropzoneTable .euiTable { + background-color: transparent; +} diff --git a/x-pack/plugins/canvas/public/components/workpad_loader/workpad_loader.js b/x-pack/plugins/canvas/public/components/workpad_loader/workpad_loader.js new file mode 100644 index 0000000000000..8987eead96486 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_loader/workpad_loader.js @@ -0,0 +1,355 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiBasicTable, + EuiButtonIcon, + EuiPagination, + EuiSpacer, + EuiButton, + EuiToolTip, + EuiModalHeader, + EuiModalHeaderTitle, + EuiModalBody, + EuiEmptyPrompt, +} from '@elastic/eui'; +import { sortByOrder } from 'lodash'; +import moment from 'moment'; +import { ConfirmModal } from '../confirm_modal'; +import { Link } from '../link'; +import { Paginate } from '../paginate'; +import { WorkpadDropzone } from './workpad_dropzone'; +import { WorkpadCreate } from './workpad_create'; +import { WorkpadSearch } from './workpad_search'; +import { WorkpadUpload } from './workpad_upload'; + +const formatDate = date => date && moment(date).format('MMM D, YYYY @ h:mma'); + +export class WorkpadLoader extends React.PureComponent { + static propTypes = { + workpadId: PropTypes.string.isRequired, + createWorkpad: PropTypes.func.isRequired, + findWorkpads: PropTypes.func.isRequired, + downloadWorkpad: PropTypes.func.isRequired, + cloneWorkpad: PropTypes.func.isRequired, + removeWorkpads: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + workpads: PropTypes.object, + }; + + state = { + deletingWorkpad: false, + createPending: false, + sortField: '@timestamp', + sortDirection: 'desc', + selectedWorkpads: [], + pageSize: 10, + }; + + async componentDidMount() { + // on component load, kick off the workpad search + this.props.findWorkpads(); + } + + componentWillReceiveProps(newProps) { + // the workpadId prop will change when a is created or loaded, close the toolbar when it does + const { workpadId, onClose } = this.props; + if (workpadId !== newProps.workpadId) onClose(); + } + + // create new empty workpad + createWorkpad = async () => { + this.setState({ createPending: true }); + await this.props.createWorkpad(); + this.setState({ createPending: false }); + }; + + // create new workpad from uploaded JSON + uploadWorkpad = async workpad => { + this.setState({ createPending: true }); + await this.props.createWorkpad(workpad); + this.setState({ createPending: false }); + }; + + // clone existing workpad + cloneWorkpad = async workpad => { + this.setState({ createPending: true }); + await this.props.cloneWorkpad(workpad.id); + this.setState({ createPending: false }); + }; + + // Workpad remove methods + openRemoveConfirm = () => this.setState({ deletingWorkpad: true }); + + closeRemoveConfirm = () => this.setState({ deletingWorkpad: false }); + + removeWorkpads = () => { + const { selectedWorkpads } = this.state; + this.props.removeWorkpads(selectedWorkpads.map(({ id }) => id)).then(remainingIds => { + const remainingWorkpads = + remainingIds.length > 0 + ? selectedWorkpads.filter(({ id }) => remainingIds.includes(id)) + : []; + + this.setState({ + deletingWorkpad: false, + selectedWorkpads: remainingWorkpads, + }); + }); + }; + + // downloads selected workpads as JSON files + downloadWorkpads = () => { + this.state.selectedWorkpads.forEach(({ id }) => this.props.downloadWorkpad(id)); + }; + + onSelectionChange = selectedWorkpads => { + this.setState({ selectedWorkpads }); + }; + + onTableChange = ({ sort = {} }) => { + const { field: sortField, direction: sortDirection } = sort; + this.setState({ + sortField, + sortDirection, + }); + }; + + renderWorkpadTable = ({ rows, pageNumber, totalPages, setPage }) => { + const { sortField, sortDirection } = this.state; + + const actions = [ + { + render: workpad => ( + + + + this.props.downloadWorkpad(workpad.id)} + aria-label="Download Workpad" + /> + + + + + this.cloneWorkpad(workpad)} + aria-label="Clone Workpad" + /> + + + + ), + }, + ]; + + const columns = [ + { + field: 'name', + name: 'Workpad Name', + sortable: true, + dataType: 'string', + render: (name, workpad) => { + const workpadName = workpad.name.length ? workpad.name : {workpad.id}; + + return ( + + {workpadName} + + ); + }, + }, + { + field: '@created', + name: 'Created', + sortable: true, + dataType: 'date', + width: '20%', + render: date => formatDate(date), + }, + { + field: '@timestamp', + name: 'Updated', + sortable: true, + dataType: 'date', + width: '20%', + render: date => formatDate(date), + }, + { name: '', actions, width: '5%' }, + ]; + + const sorting = { + sort: { + field: sortField, + direction: sortDirection, + }, + }; + + const selection = { + itemId: 'id', + onSelectionChange: this.onSelectionChange, + }; + + const emptyTable = ( + Add your first workpad} + titleSize="s" + body={ + +

Create a new workpad or drag and drop previously built workpad JSON files here.

+
+ } + /> + ); + + return ( + + + + + + + + + + + + ); + }; + + render() { + const { + deletingWorkpad, + createPending, + selectedWorkpads, + sortField, + sortDirection, + } = this.state; + const isLoading = this.props.workpads == null; + const modalTitle = + selectedWorkpads.length === 1 + ? `Delete workpad '${selectedWorkpads[0].name}'?` + : `Delete ${selectedWorkpads.length} workpads?`; + + const confirmModal = ( + + ); + + let sortedWorkpads = []; + + if (!createPending && !isLoading) { + const { workpads } = this.props.workpads; + sortedWorkpads = sortByOrder(workpads, [sortField, '@timestamp'], [sortDirection, 'desc']); + } + + return ( + + {pagination => ( + + +
+ Canvas workpads + + + + + {selectedWorkpads.length > 0 && ( + + + + {`Download (${selectedWorkpads.length})`} + + + + + {`Delete (${selectedWorkpads.length})`} + + + + )} + + { + pagination.setPage(0); + this.props.findWorkpads(text); + }} + /> + + + + + + + + + + + + + + +
+
+ + {createPending &&
Creating Workpad...
} + + {!createPending && isLoading &&
Fetching Workpads...
} + + {!createPending && !isLoading && this.renderWorkpadTable(pagination)} + + {confirmModal} +
+
+ )} +
+ ); + } +} diff --git a/x-pack/plugins/canvas/public/components/workpad_loader/workpad_search.js b/x-pack/plugins/canvas/public/components/workpad_loader/workpad_search.js new file mode 100644 index 0000000000000..b11d144804e89 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_loader/workpad_search.js @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { EuiFieldSearch } from '@elastic/eui'; +import { debounce } from 'lodash'; + +export class WorkpadSearch extends React.PureComponent { + static propTypes = { + onChange: PropTypes.func.isRequired, + initialText: PropTypes.string, + }; + + state = { + searchText: this.props.initialText || '', + }; + + triggerChange = debounce(this.props.onChange, 150); + + setSearchText = ev => { + const text = ev.target.value; + this.setState({ searchText: text }); + this.triggerChange(text); + }; + + render() { + return ( + + ); + } +} diff --git a/x-pack/plugins/canvas/public/components/workpad_loader/workpad_upload.js b/x-pack/plugins/canvas/public/components/workpad_loader/workpad_upload.js new file mode 100644 index 0000000000000..d2be98bada119 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_loader/workpad_upload.js @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { EuiFilePicker } from '@elastic/eui'; +import { get } from 'lodash'; +import { getId } from '../../lib/get_id'; +import { notify } from '../../lib/notify'; + +export const WorkpadUpload = ({ onUpload }) => ( + { + if (get(file, 'type') !== 'application/json') { + return notify.warning('Only JSON files are accepted', { + title: `Couldn't upload '${file.name || 'file'}'`, + }); + } + // TODO: Clean up this file, this loading stuff can, and should be, abstracted + const reader = new FileReader(); + + // handle reading the uploaded file + reader.onload = () => { + try { + const workpad = JSON.parse(reader.result); + workpad.id = getId('workpad'); + onUpload(workpad); + } catch (e) { + notify.error(e, { title: `Couldn't upload '${file.name || 'file'}'` }); + } + }; + + // read the uploaded file + reader.readAsText(file); + }} + /> +); + +WorkpadUpload.propTypes = { + onUpload: PropTypes.func.isRequired, +}; diff --git a/x-pack/plugins/canvas/public/components/workpad_page/event_handlers.js b/x-pack/plugins/canvas/public/components/workpad_page/event_handlers.js new file mode 100644 index 0000000000000..2fbcecc94d8dd --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_page/event_handlers.js @@ -0,0 +1,125 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { withHandlers } from 'recompose'; + +const ancestorElement = (element, className) => { + if (!element) return element; + do if (element.classList.contains(className)) return element; + while ((element = element.parentElement)); +}; + +const localMousePosition = (box, clientX, clientY) => { + return { + x: clientX - box.left, + y: clientY - box.top, + }; +}; + +const resetHandler = () => { + window.onmousemove = null; + window.onmouseup = null; +}; + +const setupHandler = (commit, target) => { + // Ancestor has to be identified on setup, rather than 1st interaction, otherwise events may be triggered on + // DOM elements that had been removed: kibana-canvas github issue #1093 + const canvasPage = ancestorElement(target, 'canvasPage'); + if (!canvasPage) return; + const canvasOrigin = canvasPage.getBoundingClientRect(); + window.onmousemove = ({ clientX, clientY, altKey, metaKey }) => { + const { x, y } = localMousePosition(canvasOrigin, clientX, clientY); + commit('cursorPosition', { x, y, altKey, metaKey }); + }; + window.onmouseup = e => { + e.stopPropagation(); + const { clientX, clientY, altKey, metaKey } = e; + const { x, y } = localMousePosition(canvasOrigin, clientX, clientY); + commit('mouseEvent', { event: 'mouseUp', x, y, altKey, metaKey }); + resetHandler(); + }; +}; + +const handleMouseMove = (commit, { target, clientX, clientY, altKey, metaKey }, isEditable) => { + // mouse move must be handled even before an initial click + if (!window.onmousemove && isEditable) { + const { x, y } = localMousePosition(target, clientX, clientY); + setupHandler(commit, target); + commit('cursorPosition', { x, y, altKey, metaKey }); + } +}; + +const handleMouseDown = (commit, e, isEditable) => { + e.stopPropagation(); + const { target, clientX, clientY, button, altKey, metaKey } = e; + if (button !== 0 || !isEditable) { + resetHandler(); + return; // left-click and edit mode only + } + const ancestor = ancestorElement(target, 'canvasPage'); + if (!ancestor) return; + const { x, y } = localMousePosition(ancestor, clientX, clientY); + setupHandler(commit, ancestor); + commit('mouseEvent', { event: 'mouseDown', x, y, altKey, metaKey }); +}; + +const keyCode = key => (key === 'Meta' ? 'MetaLeft' : 'Key' + key.toUpperCase()); + +const isNotTextInput = ({ tagName, type }) => { + // input types that aren't variations of text input + const nonTextInputs = [ + 'button', + 'checkbox', + 'color', + 'file', + 'image', + 'radio', + 'range', + 'reset', + 'submit', + ]; + + switch (tagName.toLowerCase()) { + case 'input': + return nonTextInputs.includes(type); + case 'textarea': + return false; + default: + return true; + } +}; + +const handleKeyDown = (commit, e, isEditable, remove) => { + const { key, target } = e; + + if (isEditable) { + if (isNotTextInput(target) && (key === 'Backspace' || key === 'Delete')) { + e.preventDefault(); + remove(); + } else { + commit('keyboardEvent', { + event: 'keyDown', + code: keyCode(key), // convert to standard event code + }); + } + } +}; + +const handleKeyUp = (commit, { key }, isEditable) => { + if (isEditable) { + commit('keyboardEvent', { + event: 'keyUp', + code: keyCode(key), // convert to standard event code + }); + } +}; + +export const withEventHandlers = withHandlers({ + onMouseDown: props => e => handleMouseDown(props.commit, e, props.isEditable), + onMouseMove: props => e => handleMouseMove(props.commit, e, props.isEditable), + onKeyDown: props => e => handleKeyDown(props.commit, e, props.isEditable, props.remove), + onKeyUp: props => e => handleKeyUp(props.commit, e, props.isEditable), +}); diff --git a/x-pack/plugins/canvas/public/components/workpad_page/index.js b/x-pack/plugins/canvas/public/components/workpad_page/index.js new file mode 100644 index 0000000000000..b0f50a438b179 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_page/index.js @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { compose, withState, withProps } from 'recompose'; +import { aeroelastic } from '../../lib/aeroelastic_kibana'; +import { removeElement } from '../../state/actions/elements'; +import { getFullscreen, getEditing } from '../../state/selectors/app'; +import { getElements } from '../../state/selectors/workpad'; +import { withEventHandlers } from './event_handlers'; +import { WorkpadPage as Component } from './workpad_page'; + +const mapStateToProps = (state, ownProps) => { + return { + isEditable: !getFullscreen(state) && getEditing(state), + elements: getElements(state, ownProps.page.id), + }; +}; + +const mapDispatchToProps = dispatch => { + return { + removeElement: pageId => elementId => dispatch(removeElement(elementId, pageId)), + }; +}; + +const getRootElementId = (lookup, id) => { + if (!lookup.has(id)) return null; + + const element = lookup.get(id); + return element.parent ? getRootElementId(lookup, element.parent) : element.id; +}; + +export const WorkpadPage = compose( + connect( + mapStateToProps, + mapDispatchToProps + ), + withProps(({ isSelected, animation }) => { + function getClassName() { + if (animation) return animation.name; + return isSelected ? 'canvasPage--isActive' : 'canvasPage--isInactive'; + } + + function getAnimationStyle() { + if (!animation) return {}; + return { + animationDirection: animation.direction, + // TODO: Make this configurable + animationDuration: '1s', + }; + } + + return { + className: getClassName(), + animationStyle: getAnimationStyle(), + }; + }), + withState('updateCount', 'setUpdateCount', 0), // TODO: remove this, see setUpdateCount below + withProps(({ updateCount, setUpdateCount, page, elements: pageElements, removeElement }) => { + const { shapes, selectedShapes = [], cursor } = aeroelastic.getStore(page.id).currentScene; + const elementLookup = new Map(pageElements.map(element => [element.id, element])); + const shapeLookup = new Map(shapes.map(shape => [shape.id, shape])); + const elements = shapes.map( + shape => + elementLookup.has(shape.id) + ? // instead of just combining `element` with `shape`, we make property transfer explicit + { ...shape, filter: elementLookup.get(shape.id).filter } + : shape + ); + const selectedElements = selectedShapes.map(id => getRootElementId(shapeLookup, id)); + + return { + elements, + cursor, + commit: (...args) => { + aeroelastic.commit(page.id, ...args); + // TODO: remove this, it's a hack to force react to rerender + setUpdateCount(updateCount + 1); + }, + remove: () => { + // currently, handle the removal of one element, exploiting multiselect subsequently + if (selectedElements[0]) removeElement(page.id)(selectedElements[0]); + }, + }; + }), // Updates states; needs to have both local and global + withEventHandlers // Captures user intent, needs to have reconciled state +)(Component); + +WorkpadPage.propTypes = { + page: PropTypes.shape({ + id: PropTypes.string.isRequired, + }).isRequired, +}; diff --git a/x-pack/plugins/canvas/public/components/workpad_page/workpad_page.js b/x-pack/plugins/canvas/public/components/workpad_page/workpad_page.js new file mode 100644 index 0000000000000..2aa28492af522 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_page/workpad_page.js @@ -0,0 +1,118 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { ElementWrapper } from '../element_wrapper'; +import { AlignmentGuide } from '../alignment_guide'; +import { HoverAnnotation } from '../hover_annotation'; +import { RotationHandle } from '../rotation_handle'; +import { BorderConnection } from '../border_connection'; +import { BorderResizeHandle } from '../border_resize_handle'; + +// NOTE: the data-shared-* attributes here are used for reporting +export const WorkpadPage = ({ + page, + className, + animationStyle, + elements, + cursor = 'auto', + height, + width, + isEditable, + onDoubleClick, + onKeyDown, + onKeyUp, + onMouseDown, + onMouseMove, + onMouseUp, + onAnimationEnd, +}) => { + return ( +
+ {elements + .map(element => { + if (element.type === 'annotation') { + if (!isEditable) return; + const props = { + key: element.id, + type: element.type, + transformMatrix: element.transformMatrix, + width: element.width, + height: element.height, + }; + + switch (element.subtype) { + case 'alignmentGuide': + return ; + case 'hoverAnnotation': + return ; + case 'rotationHandle': + return ; + case 'resizeHandle': + return ; + case 'resizeConnector': + return ; + default: + return []; + } + } else { + return ; + } + }) + .filter(element => !!element)} +
+ ); +}; + +WorkpadPage.propTypes = { + page: PropTypes.shape({ + id: PropTypes.string.isRequired, + style: PropTypes.object, + }).isRequired, + className: PropTypes.string.isRequired, + animationStyle: PropTypes.object.isRequired, + elements: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string.isRequired, + transformMatrix: PropTypes.arrayOf(PropTypes.number).isRequired, + width: PropTypes.number.isRequired, + height: PropTypes.number.isRequired, + type: PropTypes.string, + }) + ).isRequired, + cursor: PropTypes.string, + height: PropTypes.number.isRequired, + width: PropTypes.number.isRequired, + isEditable: PropTypes.bool.isRequired, + onDoubleClick: PropTypes.func, + onKeyDown: PropTypes.func, + onKeyUp: PropTypes.func, + onMouseDown: PropTypes.func, + onMouseMove: PropTypes.func, + onMouseUp: PropTypes.func, + onAnimationEnd: PropTypes.func, +}; diff --git a/x-pack/plugins/canvas/public/components/workpad_page/workpad_page.scss b/x-pack/plugins/canvas/public/components/workpad_page/workpad_page.scss new file mode 100644 index 0000000000000..05652b73a7d00 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_page/workpad_page.scss @@ -0,0 +1,19 @@ +.canvasPage, +.canvasPage:focus { + z-index: initial; + box-shadow: 0 0 7px 0 rgba(0, 0, 0, 0.2); + position: absolute; + top: 0; + transform-style: preserve-3d !important; +} + +.canvasPage--isEditable { + user-select: none; +} + +.canvasLayout__stage { + .canvasPage--isInactive { + visibility: hidden; + opacity: 0; + } +} diff --git a/x-pack/plugins/canvas/public/expression_types/arg.js b/x-pack/plugins/canvas/public/expression_types/arg.js new file mode 100644 index 0000000000000..341bd1e3953fa --- /dev/null +++ b/x-pack/plugins/canvas/public/expression_types/arg.js @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createElement } from 'react'; +import { pick } from 'lodash'; +import { ArgForm } from '../components/arg_form'; +import { argTypeRegistry } from './arg_type'; + +export class Arg { + constructor(props) { + const argType = argTypeRegistry.get(props.argType); + if (!argType) throw new Error(`Invalid arg type: ${props.argType}`); + if (!props.name) throw new Error('Args must have a name property'); + + // properties that can be overridden + const defaultProps = { + multi: false, + required: false, + types: [], + default: argType.default != null ? argType.default : null, + options: {}, + resolve: () => ({}), + }; + + const viewOverrides = { + argType, + ...pick(props, [ + 'name', + 'displayName', + 'help', + 'multi', + 'required', + 'types', + 'default', + 'resolve', + 'options', + ]), + }; + + Object.assign(this, defaultProps, argType, viewOverrides); + } + + // TODO: Document what these otherProps are. Maybe make them named arguments? + render({ onValueChange, onValueRemove, argValue, key, label, ...otherProps }) { + // This is everything the arg_type template needs to render + const templateProps = { + ...otherProps, + ...this.resolve(otherProps), + onValueChange, + argValue, + typeInstance: this, + }; + + const formProps = { + key, + argTypeInstance: this, + valueMissing: this.required && argValue == null, + label, + onValueChange, + onValueRemove, + templateProps, + argId: key, + }; + + return createElement(ArgForm, formProps); + } +} diff --git a/x-pack/plugins/canvas/public/expression_types/arg_type.js b/x-pack/plugins/canvas/public/expression_types/arg_type.js new file mode 100644 index 0000000000000..76f29afee7185 --- /dev/null +++ b/x-pack/plugins/canvas/public/expression_types/arg_type.js @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Registry } from '../../common/lib/registry'; +import { BaseForm } from './base_form'; + +export class ArgType extends BaseForm { + constructor(props) { + super(props); + + this.simpleTemplate = props.simpleTemplate; + this.template = props.template; + this.default = props.default; + this.resolveArgValue = Boolean(props.resolveArgValue); + } +} + +class ArgTypeRegistry extends Registry { + wrapper(obj) { + return new ArgType(obj); + } +} + +export const argTypeRegistry = new ArgTypeRegistry(); diff --git a/x-pack/plugins/canvas/public/expression_types/arg_types/color.js b/x-pack/plugins/canvas/public/expression_types/arg_types/color.js new file mode 100644 index 0000000000000..69518f015f01d --- /dev/null +++ b/x-pack/plugins/canvas/public/expression_types/arg_types/color.js @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { templateFromReactComponent } from '../../lib/template_from_react_component'; +import { ColorPickerMini } from '../../components/color_picker_mini/'; + +const ColorArgInput = ({ onValueChange, argValue, workpad }) => ( + + + + + +); + +ColorArgInput.propTypes = { + argValue: PropTypes.any.isRequired, + onValueChange: PropTypes.func.isRequired, + workpad: PropTypes.shape({ + colors: PropTypes.array.isRequired, + }).isRequired, +}; + +export const color = () => ({ + name: 'color', + displayName: 'Color', + help: 'Color picker', + simpleTemplate: templateFromReactComponent(ColorArgInput), + default: '#000000', +}); diff --git a/x-pack/plugins/canvas/public/expression_types/arg_types/container_style/appearance_form.js b/x-pack/plugins/canvas/public/expression_types/arg_types/container_style/appearance_form.js new file mode 100644 index 0000000000000..0a400bd03aef3 --- /dev/null +++ b/x-pack/plugins/canvas/public/expression_types/arg_types/container_style/appearance_form.js @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { EuiFieldNumber, EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiSelect } from '@elastic/eui'; + +const opacities = [ + { value: 1, text: '100%' }, + { value: 0.9, text: '90%' }, + { value: 0.7, text: '70%' }, + { value: 0.5, text: '50%' }, + { value: 0.3, text: '30%' }, + { value: 0.1, text: '10%' }, +]; + +const overflows = [{ value: 'hidden', text: 'Hidden' }, { value: 'visible', text: 'Visible' }]; + +export const AppearanceForm = ({ padding, opacity, overflow, onChange }) => { + const paddingVal = padding ? padding.replace('px', '') : ''; + + const namedChange = name => ev => { + if (name === 'padding') return onChange(name, `${ev.target.value}px`); + + onChange(name, ev.target.value); + }; + + return ( + + + + + + + + + + + + + + + + + + ); +}; + +AppearanceForm.propTypes = { + padding: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + opacity: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + overflow: PropTypes.oneOf(['hidden', 'visible']), + onChange: PropTypes.func.isRequired, +}; diff --git a/x-pack/plugins/canvas/public/expression_types/arg_types/container_style/border_form.js b/x-pack/plugins/canvas/public/expression_types/arg_types/container_style/border_form.js new file mode 100644 index 0000000000000..05807741956c9 --- /dev/null +++ b/x-pack/plugins/canvas/public/expression_types/arg_types/container_style/border_form.js @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { EuiFlexGroup, EuiFormRow, EuiFlexItem, EuiFieldNumber, EuiSelect } from '@elastic/eui'; +import { ColorPickerMini } from '../../../components/color_picker_mini'; + +const styles = ['solid', 'dotted', 'dashed', 'double', 'groove', 'ridge', 'inset', 'outset']; + +const options = [{ value: '', text: '--' }]; +styles.forEach(val => options.push({ value: val, text: val })); + +export const BorderForm = ({ value, radius, onChange, colors }) => { + const border = value || ''; + const [borderWidth = '', borderStyle = '', borderColor = ''] = border.split(' '); + const borderWidthVal = borderWidth ? borderWidth.replace('px', '') : ''; + const radiusVal = radius ? radius.replace('px', '') : ''; + + const namedChange = name => ev => { + const val = ev.target.value; + + if (name === 'borderWidth') return onChange('border', `${val}px ${borderStyle} ${borderColor}`); + if (name === 'borderStyle') { + if (val === '') return onChange('border', `0px`); + return onChange('border', `${borderWidth} ${val} ${borderColor}`); + } + if (name === 'borderRadius') return onChange('borderRadius', `${val}px`); + + onChange(name, ev.target.value); + }; + + const borderColorChange = color => onChange('border', `${borderWidth} ${borderStyle} ${color}`); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +BorderForm.propTypes = { + value: PropTypes.string, + radius: PropTypes.string, + onChange: PropTypes.func.isRequired, + colors: PropTypes.array.isRequired, +}; diff --git a/x-pack/plugins/canvas/public/expression_types/arg_types/container_style/extended_template.js b/x-pack/plugins/canvas/public/expression_types/arg_types/container_style/extended_template.js new file mode 100644 index 0000000000000..1183b7cbe24b7 --- /dev/null +++ b/x-pack/plugins/canvas/public/expression_types/arg_types/container_style/extended_template.js @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { EuiSpacer, EuiTitle } from '@elastic/eui'; +import { BorderForm } from './border_form'; +import { AppearanceForm } from './appearance_form'; + +export const ExtendedTemplate = ({ getArgValue, setArgValue, workpad }) => ( +
+ +
Appearance
+
+ + + + + + + +
Border
+
+ + +
+); + +ExtendedTemplate.displayName = 'ContainerStyleArgExtendedInput'; + +ExtendedTemplate.propTypes = { + onValueChange: PropTypes.func.isRequired, + argValue: PropTypes.any.isRequired, + getArgValue: PropTypes.func.isRequired, + setArgValue: PropTypes.func.isRequired, + workpad: PropTypes.shape({ + colors: PropTypes.array.isRequired, + }).isRequired, +}; diff --git a/x-pack/plugins/canvas/public/expression_types/arg_types/container_style/index.js b/x-pack/plugins/canvas/public/expression_types/arg_types/container_style/index.js new file mode 100644 index 0000000000000..f7056be915623 --- /dev/null +++ b/x-pack/plugins/canvas/public/expression_types/arg_types/container_style/index.js @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { withHandlers } from 'recompose'; +import { set } from 'object-path-immutable'; +import { get } from 'lodash'; +import { templateFromReactComponent } from '../../../lib/template_from_react_component'; +import { SimpleTemplate } from './simple_template'; +import { ExtendedTemplate } from './extended_template'; + +const wrap = Component => + // TODO: this should be in a helper + withHandlers({ + getArgValue: ({ argValue }) => (name, alt) => { + const args = get(argValue, 'chain.0.arguments', {}); + return get(args, [name, 0], alt); + }, + setArgValue: ({ argValue, onValueChange }) => (name, val) => { + const newValue = set(argValue, ['chain', 0, 'arguments', name, 0], val); + onValueChange(newValue); + }, + })(Component); + +export const containerStyle = () => ({ + name: 'containerStyle', + displayName: 'Container Style', + help: 'Tweak the appearance of the element container', + default: '{containerStyle}', + simpleTemplate: templateFromReactComponent(wrap(SimpleTemplate)), + template: templateFromReactComponent(wrap(ExtendedTemplate)), +}); diff --git a/x-pack/plugins/canvas/public/expression_types/arg_types/container_style/simple_template.js b/x-pack/plugins/canvas/public/expression_types/arg_types/container_style/simple_template.js new file mode 100644 index 0000000000000..86dd745f7b78f --- /dev/null +++ b/x-pack/plugins/canvas/public/expression_types/arg_types/container_style/simple_template.js @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { ColorPickerMini } from '../../../components/color_picker_mini'; + +export const SimpleTemplate = ({ getArgValue, setArgValue, workpad }) => ( +
+ setArgValue('backgroundColor', color)} + colors={workpad.colors} + anchorPosition="leftCenter" + /> +
+); + +SimpleTemplate.displayName = 'ContainerStyleArgSimpleInput'; + +SimpleTemplate.propTypes = { + onValueChange: PropTypes.func.isRequired, + argValue: PropTypes.any.isRequired, + getArgValue: PropTypes.func.isRequired, + setArgValue: PropTypes.func.isRequired, + workpad: PropTypes.shape({ + colors: PropTypes.array.isRequired, + }), +}; diff --git a/x-pack/plugins/canvas/public/expression_types/arg_types/font.js b/x-pack/plugins/canvas/public/expression_types/arg_types/font.js new file mode 100644 index 0000000000000..fc64c9c3a668c --- /dev/null +++ b/x-pack/plugins/canvas/public/expression_types/arg_types/font.js @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { get, mapValues, set } from 'lodash'; +import { openSans } from '../../../common/lib/fonts'; +import { templateFromReactComponent } from '../../lib/template_from_react_component'; +import { TextStylePicker } from '../../components/text_style_picker'; + +export const FontArgInput = props => { + const { onValueChange, argValue, workpad } = props; + const chain = get(argValue, 'chain.0', {}); + const chainArgs = get(chain, 'arguments', {}); + + // TODO: Validate input + + const spec = mapValues(chainArgs, '[0]'); + + function handleChange(newSpec) { + const newValue = set(argValue, ['chain', 0, 'arguments'], mapValues(newSpec, v => [v])); + return onValueChange(newValue); + } + + return ( + + ); +}; + +FontArgInput.propTypes = { + onValueChange: PropTypes.func.isRequired, + argValue: PropTypes.any.isRequired, + typeInstance: PropTypes.object, + workpad: PropTypes.shape({ + colors: PropTypes.array.isRequired, + }).isRequired, +}; + +FontArgInput.displayName = 'FontArgInput'; + +export const font = () => ({ + name: 'font', + displayName: 'Text Settings', + help: 'Set the font, size and color', + template: templateFromReactComponent(FontArgInput), + default: `{font size=14 family="${openSans.value}" color="#000000" align=left}`, +}); diff --git a/x-pack/plugins/canvas/public/expression_types/arg_types/index.js b/x-pack/plugins/canvas/public/expression_types/arg_types/index.js new file mode 100644 index 0000000000000..4075d545fded4 --- /dev/null +++ b/x-pack/plugins/canvas/public/expression_types/arg_types/index.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { color } from './color'; +import { seriesStyle } from './series_style'; +import { containerStyle } from './container_style'; +import { font } from './font'; + +// Anything that uses the color picker has to be loaded privately because the color picker uses Redux +export const argTypeSpecs = [color, containerStyle, font, seriesStyle]; diff --git a/x-pack/plugins/canvas/public/expression_types/arg_types/series_style/extended_template.js b/x-pack/plugins/canvas/public/expression_types/arg_types/series_style/extended_template.js new file mode 100644 index 0000000000000..2e0c4ede6cc53 --- /dev/null +++ b/x-pack/plugins/canvas/public/expression_types/arg_types/series_style/extended_template.js @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiSelect } from '@elastic/eui'; +import { set, del } from 'object-path-immutable'; +import { get } from 'lodash'; + +export const ExtendedTemplate = props => { + const { typeInstance, onValueChange, labels, argValue } = props; + const chain = get(argValue, 'chain.0', {}); + const chainArgs = get(chain, 'arguments', {}); + const selectedSeries = get(chainArgs, 'label.0', ''); + const { name } = typeInstance; + const fields = get(typeInstance, 'options.include', []); + const hasPropFields = fields.some(field => ['lines', 'bars', 'points'].indexOf(field) !== -1); + + const handleChange = (argName, ev) => { + const fn = ev.target.value === '' ? del : set; + + const newValue = fn(argValue, ['chain', 0, 'arguments', argName], [ev.target.value]); + return onValueChange(newValue); + }; + + // TODO: add fill and stack options + // TODO: add label name auto-complete + const values = [ + { value: 0, text: 'None' }, + { value: 1, text: '1' }, + { value: 2, text: '2' }, + { value: 3, text: '3' }, + { value: 4, text: '4' }, + { value: 5, text: '5' }, + ]; + + const labelOptions = [{ value: '', text: 'Select Series' }]; + labels.sort().forEach(val => labelOptions.push({ value: val, text: val })); + + return ( +
+ {name !== 'defaultStyle' && ( + + handleChange('label', ev)} + /> + + )} + {hasPropFields && ( + + {fields.includes('lines') && ( + + + handleChange('lines', ev)} + /> + + + )} + {fields.includes('bars') && ( + + + handleChange('bars', ev)} + /> + + + )} + {fields.includes('points') && ( + + + handleChange('points', ev)} + /> + + + )} + + )} +
+ ); +}; + +ExtendedTemplate.displayName = 'SeriesStyleArgAdvancedInput'; + +ExtendedTemplate.propTypes = { + onValueChange: PropTypes.func.isRequired, + argValue: PropTypes.any.isRequired, + typeInstance: PropTypes.object, + labels: PropTypes.array.isRequired, + renderError: PropTypes.func, +}; diff --git a/x-pack/plugins/canvas/public/expression_types/arg_types/series_style/index.js b/x-pack/plugins/canvas/public/expression_types/arg_types/series_style/index.js new file mode 100644 index 0000000000000..829804e2bf88a --- /dev/null +++ b/x-pack/plugins/canvas/public/expression_types/arg_types/series_style/index.js @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import PropTypes from 'prop-types'; +import { lifecycle } from 'recompose'; +import { get } from 'lodash'; +import { templateFromReactComponent } from '../../../lib/template_from_react_component'; +import { SimpleTemplate } from './simple_template'; +import { ExtendedTemplate } from './extended_template'; + +const EnhancedExtendedTemplate = lifecycle({ + formatLabel(label) { + if (typeof label !== 'string') this.props.renderError(); + return `Style: ${label}`; + }, + componentWillMount() { + const label = get(this.props.argValue, 'chain.0.arguments.label.0', ''); + if (label) this.props.setLabel(this.formatLabel(label)); + }, + componentWillReceiveProps(newProps) { + const newLabel = get(newProps.argValue, 'chain.0.arguments.label.0', ''); + if (newLabel && this.props.label !== this.formatLabel(newLabel)) + this.props.setLabel(this.formatLabel(newLabel)); + }, +})(ExtendedTemplate); + +EnhancedExtendedTemplate.propTypes = { + argValue: PropTypes.any.isRequired, + setLabel: PropTypes.func.isRequired, + label: PropTypes.string, +}; + +export const seriesStyle = () => ({ + name: 'seriesStyle', + displayName: 'Series Style', + help: 'Set the style for a selected named series', + template: templateFromReactComponent(EnhancedExtendedTemplate), + simpleTemplate: templateFromReactComponent(SimpleTemplate), + default: '{seriesStyle}', +}); diff --git a/x-pack/plugins/canvas/public/expression_types/arg_types/series_style/simple_template.js b/x-pack/plugins/canvas/public/expression_types/arg_types/series_style/simple_template.js new file mode 100644 index 0000000000000..790c082d4a1dd --- /dev/null +++ b/x-pack/plugins/canvas/public/expression_types/arg_types/series_style/simple_template.js @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiLink, EuiButtonIcon } from '@elastic/eui'; +import { set, del } from 'object-path-immutable'; +import { get } from 'lodash'; +import { ColorPickerMini } from '../../../components/color_picker_mini'; +import { TooltipIcon } from '../../../components/tooltip_icon'; + +export const SimpleTemplate = props => { + const { typeInstance, argValue, onValueChange, labels, workpad } = props; + const { name } = typeInstance; + const chain = get(argValue, 'chain.0', {}); + const chainArgs = get(chain, 'arguments', {}); + const color = get(chainArgs, 'color.0', ''); + + const handleChange = (argName, ev) => { + const fn = ev.target.value === '' ? del : set; + + const newValue = fn(argValue, ['chain', 0, 'arguments', argName], [ev.target.value]); + return onValueChange(newValue); + }; + + const handlePlain = (argName, val) => handleChange(argName, { target: { value: val } }); + + return ( + + {!color || color.length === 0 ? ( + + + + + + handlePlain('color', '#000000')}> + Auto + + + + ) : ( + + + + + + handlePlain('color', val)} + colors={workpad.colors} + placement="leftCenter" + /> + + + handlePlain('color', '')} + aria-label="Remove Series Color" + /> + + + )} + {name !== 'defaultStyle' && + (!labels || labels.length === 0) && ( + + + + )} + + ); +}; + +SimpleTemplate.displayName = 'SeriesStyleArgSimpleInput'; + +SimpleTemplate.propTypes = { + onValueChange: PropTypes.func.isRequired, + argValue: PropTypes.any.isRequired, + labels: PropTypes.array, + workpad: PropTypes.shape({ + colors: PropTypes.array.isRequired, + }).isRequired, + typeInstance: PropTypes.shape({ name: PropTypes.string.isRequired }).isRequired, +}; diff --git a/x-pack/plugins/canvas/public/expression_types/base_form.js b/x-pack/plugins/canvas/public/expression_types/base_form.js new file mode 100644 index 0000000000000..4b97f6204a355 --- /dev/null +++ b/x-pack/plugins/canvas/public/expression_types/base_form.js @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export class BaseForm { + constructor(props) { + if (!props.name) throw new Error('Expression specs require a name property'); + + this.name = props.name; + this.displayName = props.displayName || this.name; + this.help = props.help || ''; + } +} diff --git a/x-pack/plugins/canvas/public/expression_types/datasource.js b/x-pack/plugins/canvas/public/expression_types/datasource.js new file mode 100644 index 0000000000000..858be2b4e33dd --- /dev/null +++ b/x-pack/plugins/canvas/public/expression_types/datasource.js @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { Registry } from '../../common/lib/registry'; +import { RenderToDom } from '../components/render_to_dom'; +import { ExpressionFormHandlers } from '../../common/lib/expression_form_handlers'; +import { BaseForm } from './base_form'; + +const defaultTemplate = () => ( +
+

This datasource has no interface. Use the expression editor to make changes.

+
+); + +class DatasourceWrapper extends React.PureComponent { + static propTypes = { + spec: PropTypes.object.isRequired, + datasourceProps: PropTypes.object.isRequired, + handlers: PropTypes.object.isRequired, + }; + + componentDidUpdate() { + this.callRenderFn(); + } + + componentWillUnmount() { + this.props.handlers.destroy(); + } + + callRenderFn = () => { + const { spec, datasourceProps, handlers } = this.props; + const { template } = spec; + template(this.domNode, datasourceProps, handlers); + }; + + render() { + return ( + { + this.domNode = domNode; + this.callRenderFn(); + }} + /> + ); + } +} + +export class Datasource extends BaseForm { + constructor(props) { + super(props); + + this.template = props.template || defaultTemplate; + this.image = props.image; + } + + render(props = {}) { + const expressionFormHandlers = new ExpressionFormHandlers(); + return ( + + ); + } +} + +class DatasourceRegistry extends Registry { + wrapper(obj) { + return new Datasource(obj); + } +} + +export const datasourceRegistry = new DatasourceRegistry(); diff --git a/x-pack/plugins/canvas/public/expression_types/datasources/esdocs.js b/x-pack/plugins/canvas/public/expression_types/datasources/esdocs.js new file mode 100644 index 0000000000000..b2675777d3bf1 --- /dev/null +++ b/x-pack/plugins/canvas/public/expression_types/datasources/esdocs.js @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { EuiFormRow, EuiSelect, EuiFieldText, EuiCallOut, EuiSpacer } from '@elastic/eui'; +import { getSimpleArg, setSimpleArg } from '../../lib/arg_helpers'; +import { ESFieldsSelect } from '../../components/es_fields_select'; +import { ESFieldSelect } from '../../components/es_field_select'; +import { ESIndexSelect } from '../../components/es_index_select'; +import { templateFromReactComponent } from '../../lib/template_from_react_component'; + +const EsdocsDatasource = ({ args, updateArgs }) => { + const setArg = (name, value) => { + updateArgs && + updateArgs({ + ...args, + ...setSimpleArg(name, value), + }); + }; + + // TODO: This is a terrible way of doing defaults. We need to find a way to read the defaults for the function + // and set them for the data source UI. + const getArgName = () => { + if (getSimpleArg('_', args)[0]) return '_'; + if (getSimpleArg('q', args)[0]) return 'q'; + return 'query'; + }; + + const getIndex = () => { + return getSimpleArg('index', args)[0] || ''; + }; + + const getQuery = () => { + return getSimpleArg(getArgName(), args)[0] || ''; + }; + + const getFields = () => { + const commas = getSimpleArg('fields', args)[0] || ''; + if (commas.length === 0) return []; + return commas.split(',').map(str => str.trim()); + }; + + const getSortBy = () => { + const commas = getSimpleArg('sort', args)[0] || ', DESC'; + return commas.split(',').map(str => str.trim()); + }; + + const fields = getFields(); + const [sortField, sortOrder] = getSortBy(); + + const index = getIndex().toLowerCase(); + + const sortOptions = [{ value: 'asc', text: 'Ascending' }, { value: 'desc', text: 'Descending' }]; + + return ( +
+ +

+ The Elasticsearch Docs datasource is used to pull documents directly from Elasticsearch + without the use of aggregations. It is best used with low volume datasets and in + situations where you need to view raw documents or plot exact, non-aggregated values on a + chart. +

+
+ + + + + setArg('index', index)} /> + + + + setArg(getArgName(), e.target.value)} /> + + + setArg('sort', [field, sortOrder].join(', '))} + /> + + + setArg('sort', [sortField, e.target.value].join(', '))} + options={sortOptions} + /> + + + setArg('fields', fields.join(', '))} + selected={fields} + /> + +
+ ); +}; + +EsdocsDatasource.propTypes = { + args: PropTypes.object.isRequired, + updateArgs: PropTypes.func, +}; + +export const esdocs = () => ({ + name: 'esdocs', + displayName: 'Elasticsearch Raw Documents', + help: 'Pull back raw documents from elasticsearch', + image: 'logoElasticsearch', + template: templateFromReactComponent(EsdocsDatasource), +}); diff --git a/x-pack/plugins/canvas/public/expression_types/datasources/index.js b/x-pack/plugins/canvas/public/expression_types/datasources/index.js new file mode 100644 index 0000000000000..91dca7d275f8b --- /dev/null +++ b/x-pack/plugins/canvas/public/expression_types/datasources/index.js @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { esdocs } from './esdocs'; + +export const datasourceSpecs = [esdocs]; diff --git a/x-pack/plugins/canvas/public/expression_types/function_form.js b/x-pack/plugins/canvas/public/expression_types/function_form.js new file mode 100644 index 0000000000000..70da0004ab175 --- /dev/null +++ b/x-pack/plugins/canvas/public/expression_types/function_form.js @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiCallOut } from '@elastic/eui'; +import React from 'react'; +import { isPlainObject, uniq, last, compact } from 'lodash'; +import { fromExpression } from '../../common/lib/ast'; +import { ArgAddPopover } from '../components/arg_add_popover'; +import { SidebarSection } from '../components/sidebar/sidebar_section'; +import { SidebarSectionTitle } from '../components/sidebar/sidebar_section_title'; +import { BaseForm } from './base_form'; +import { Arg } from './arg'; + +export class FunctionForm extends BaseForm { + constructor(props) { + super({ ...props }); + + this.args = props.args || []; + this.resolve = props.resolve || (() => ({})); + } + + renderArg(props, dataArg) { + const { onValueRemove, onValueChange, ...passedProps } = props; + const { arg, argValues, skipRender, label } = dataArg; + const { argType, expressionIndex } = passedProps; + + // TODO: show some information to the user than an argument was skipped + if (!arg || skipRender) return null; + + const renderArgWithProps = (argValue, valueIndex) => + arg.render({ + key: `${argType}-${expressionIndex}-${arg.name}-${valueIndex}`, + ...passedProps, + label, + valueIndex, + argValue, + onValueChange: onValueChange(arg.name, valueIndex), + onValueRemove: onValueRemove(arg.name, valueIndex), + }); + + // render the argument's template, wrapped in a remove control + // if the argument is required but not included, render the control anyway + if (!argValues && arg.required) return renderArgWithProps({ type: undefined, value: '' }, 0); + + // render all included argument controls + return argValues && argValues.map(renderArgWithProps); + } + + // TODO: Argument adding isn't very good, we should improve this UI + getAddableArg(props, dataArg) { + const { onValueAdd } = props; + const { arg, argValues, skipRender } = dataArg; + + // skip arguments that aren't defined in the expression type schema + if (!arg || arg.required || skipRender) return null; + if (argValues && !arg.multi) return null; + + const value = arg.default == null ? null : fromExpression(arg.default, 'argument'); + + return { arg, onValueAdd: onValueAdd(arg.name, value) }; + } + + resolveArg() { + // basically a no-op placeholder + return {}; + } + + render(data = {}) { + const { args, argTypeDef } = data; + + // Don't instaniate these until render time, to give the registries a chance to populate. + const argInstances = this.args.map(argSpec => new Arg(argSpec)); + + if (!isPlainObject(args)) throw new Error(`Form "${this.name}" expects "args" object`); + + // get a mapping of arg values from the expression and from the renderable's schema + const argNames = uniq(argInstances.map(arg => arg.name).concat(Object.keys(args))); + const dataArgs = argNames.map(argName => { + const arg = argInstances.find(arg => arg.name === argName); + + // if arg is not multi, only preserve the last value found + // otherwise, leave the value alone (including if the arg is not defined) + const isMulti = arg && arg.multi; + const argValues = args[argName] && !isMulti ? [last(args[argName])] : args[argName]; + + return { arg, argValues }; + }); + + // props are passed to resolve and the returned object is mixed into the template props + const props = { ...data, ...this.resolve(data), typeInstance: this }; + + try { + // allow a hook to override the data args + const resolvedDataArgs = dataArgs.map(d => ({ ...d, ...this.resolveArg(d, props) })); + + const argumentForms = compact(resolvedDataArgs.map(d => this.renderArg(props, d))); + const addableArgs = compact(resolvedDataArgs.map(d => this.getAddableArg(props, d))); + + if (!addableArgs.length && !argumentForms.length) return null; + + return ( + + + {addableArgs.length === 0 ? null : } + + {argumentForms} + + ); + } catch (e) { + return ( + +

{e.message}

+
+ ); + } + } +} diff --git a/x-pack/plugins/canvas/public/expression_types/index.js b/x-pack/plugins/canvas/public/expression_types/index.js new file mode 100644 index 0000000000000..a75c33bb4e3d0 --- /dev/null +++ b/x-pack/plugins/canvas/public/expression_types/index.js @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { Datasource, datasourceRegistry } from './datasource'; +export { Transform, transformRegistry } from './transform'; +export { Model, modelRegistry } from './model'; +export { View, viewRegistry } from './view'; +export { ArgType, argTypeRegistry } from './arg_type'; +export { Arg } from './arg'; diff --git a/x-pack/plugins/canvas/public/expression_types/model.js b/x-pack/plugins/canvas/public/expression_types/model.js new file mode 100644 index 0000000000000..bae74d75589be --- /dev/null +++ b/x-pack/plugins/canvas/public/expression_types/model.js @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get, pick } from 'lodash'; +import { Registry } from '../../common/lib/registry'; +import { FunctionForm } from './function_form'; + +const NO_NEXT_EXP = 'no next expression'; +const MISSING_MODEL_ARGS = 'missing model args'; + +function getModelArgs(expressionType) { + if (!expressionType) return NO_NEXT_EXP; + if (!expressionType.modelArgs) return MISSING_MODEL_ARGS; + return expressionType.modelArgs.length > 0 ? expressionType.modelArgs : MISSING_MODEL_ARGS; +} + +export class Model extends FunctionForm { + constructor(props) { + super(props); + + const propNames = ['requiresContext']; + const defaultProps = { + requiresContext: true, + }; + + Object.assign(this, defaultProps, pick(props, propNames)); + } + + resolveArg(dataArg, props) { + // custom argument resolver + // uses `modelArgs` from following expression to control which arguments get rendered + const { nextExpressionType } = props; + const modelArgs = getModelArgs(nextExpressionType); + + // if modelArgs are missing, something went wrong here + if (modelArgs === MISSING_MODEL_ARGS) { + // if there is a next expression, it is lacking modelArgs, so we throw + throw new Error(`${nextExpressionType.displayName} modelArgs Error: + The modelArgs value is empty. Either it should contain an arg, + or a model should not be used in the expression. + `); + } + + // if there is no following expression, argument is skipped + if (modelArgs === NO_NEXT_EXP) return { skipRender: true }; + + // if argument is missing from modelArgs, mark it as skipped + const argName = get(dataArg, 'arg.name'); + const modelArg = modelArgs.find(modelArg => { + if (Array.isArray(modelArg)) return modelArg[0] === argName; + return modelArg === argName; + }); + + return { + label: Array.isArray(modelArg) ? get(modelArg[1], 'label') : null, + skipRender: !modelArg, + }; + } +} + +class ModelRegistry extends Registry { + wrapper(obj) { + return new Model(obj); + } +} + +export const modelRegistry = new ModelRegistry(); diff --git a/x-pack/plugins/canvas/public/expression_types/transform.js b/x-pack/plugins/canvas/public/expression_types/transform.js new file mode 100644 index 0000000000000..216e79b9c106c --- /dev/null +++ b/x-pack/plugins/canvas/public/expression_types/transform.js @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pick } from 'lodash'; +import { Registry } from '../../common/lib/registry'; +import { FunctionForm } from './function_form'; + +export class Transform extends FunctionForm { + constructor(props) { + super(props); + + const propNames = ['requiresContext']; + const defaultProps = { + requiresContext: true, + }; + + Object.assign(this, defaultProps, pick(props, propNames)); + } +} + +class TransformRegistry extends Registry { + wrapper(obj) { + return new Transform(obj); + } +} + +export const transformRegistry = new TransformRegistry(); diff --git a/x-pack/plugins/canvas/public/expression_types/view.js b/x-pack/plugins/canvas/public/expression_types/view.js new file mode 100644 index 0000000000000..ee83fe3340d76 --- /dev/null +++ b/x-pack/plugins/canvas/public/expression_types/view.js @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pick } from 'lodash'; +import { Registry } from '../../common/lib/registry'; +import { FunctionForm } from './function_form'; + +export class View extends FunctionForm { + constructor(props) { + super(props); + + const propNames = ['help', 'modelArgs', 'requiresContext']; + const defaultProps = { + help: `Element: ${props.name}`, + requiresContext: true, + }; + + Object.assign(this, defaultProps, pick(props, propNames)); + + this.modelArgs = this.modelArgs || []; + + if (!Array.isArray(this.modelArgs)) + throw new Error(`${this.name} element is invalid, modelArgs must be an array`); + } +} + +class ViewRegistry extends Registry { + wrapper(obj) { + return new View(obj); + } +} + +export const viewRegistry = new ViewRegistry(); diff --git a/x-pack/plugins/canvas/public/functions/__tests__/asset.js b/x-pack/plugins/canvas/public/functions/__tests__/asset.js new file mode 100644 index 0000000000000..ea9f1d051ca61 --- /dev/null +++ b/x-pack/plugins/canvas/public/functions/__tests__/asset.js @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { functionWrapper } from '../../../__tests__/helpers/function_wrapper'; +import { asset } from '../asset'; + +// TODO: restore this test +// will require the ability to mock the store, or somehow remove the function's dependency on getState +describe.skip('asset', () => { + const fn = functionWrapper(asset); + + it('throws if asset could not be retrieved by ID', () => { + const throwsErr = () => { + return fn(null, { id: 'boo' }); + }; + expect(throwsErr).to.throwException(err => { + expect(err.message).to.be('Could not get the asset by ID: boo'); + }); + }); + + it('returns the asset for found asset ID', () => { + expect(fn(null, { id: 'yay' })).to.be('here is your image'); + }); +}); diff --git a/x-pack/plugins/canvas/public/functions/asset.js b/x-pack/plugins/canvas/public/functions/asset.js new file mode 100644 index 0000000000000..4ec4889fc344e --- /dev/null +++ b/x-pack/plugins/canvas/public/functions/asset.js @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getState } from '../state/store'; +import { getAssetById } from '../state/selectors/assets'; + +export const asset = () => ({ + name: 'asset', + aliases: [], + context: { + types: ['null'], + }, + type: 'string', + help: 'Use Canvas workpad asset objects to provide argument values. Usually images.', + args: { + id: { + aliases: ['_'], + types: ['string'], + help: 'The ID of the asset value to return', + multi: false, + }, + }, + fn: (context, args) => { + const assetId = args.id; + const asset = getAssetById(getState(), assetId); + if (asset !== undefined) return asset.value; + + throw new Error('Could not get the asset by ID: ' + assetId); + }, +}); diff --git a/x-pack/plugins/canvas/public/functions/filters.js b/x-pack/plugins/canvas/public/functions/filters.js new file mode 100644 index 0000000000000..a6f8d2a63fc5e --- /dev/null +++ b/x-pack/plugins/canvas/public/functions/filters.js @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { fromExpression } from '../../common/lib/ast'; +import { typesRegistry } from '../../common/lib/types_registry'; +import { getState } from '../state/store'; +import { getGlobalFilterExpression } from '../state/selectors/workpad'; +import { interpretAst } from '../lib/interpreter'; + +export const filters = () => ({ + name: 'filters', + type: 'filter', + context: { + types: ['null'], + }, + help: 'Collect element filters on the workpad, usually to provide them to a data source', + fn: () => { + const filterExpression = getGlobalFilterExpression(getState()); + + if (filterExpression && filterExpression.length) { + const filterAST = fromExpression(filterExpression); + return interpretAst(filterAST); + } else { + const filterType = typesRegistry.get('filter'); + return filterType.from(null); + } + }, +}); diff --git a/x-pack/plugins/canvas/public/functions/index.js b/x-pack/plugins/canvas/public/functions/index.js new file mode 100644 index 0000000000000..efcec12c1a8dc --- /dev/null +++ b/x-pack/plugins/canvas/public/functions/index.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { asset } from './asset'; +import { filters } from './filters'; + +export const clientFunctions = [asset, filters]; diff --git a/x-pack/plugins/canvas/public/icon.svg b/x-pack/plugins/canvas/public/icon.svg new file mode 100644 index 0000000000000..dfdd65ee159de --- /dev/null +++ b/x-pack/plugins/canvas/public/icon.svg @@ -0,0 +1,84 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/x-pack/plugins/canvas/public/lib/__tests__/find_expression_type.js b/x-pack/plugins/canvas/public/lib/__tests__/find_expression_type.js new file mode 100644 index 0000000000000..58f5c5eb303bd --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/__tests__/find_expression_type.js @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// import expect from 'expect.js'; +// import proxyquire from 'proxyquire'; +// import { Registry } from '../../../common/lib/registry'; + +// const registries = { +// datasource: new Registry(), +// transform: new Registry(), +// model: new Registry(), +// view: new Registry(), +// }; + +// const { findExpressionType } = proxyquire.noCallThru().load('../find_expression_type', { +// '../expression_types/datasource': { +// datasourceRegistry: registries.datasource, +// }, +// '../expression_types/transform': { +// transformRegistry: registries.transform, +// }, +// '../expression_types/model': { +// modelRegistry: registries.model, +// }, +// '../expression_types/view': { +// viewRegistry: registries.view, +// }, +// }); + +// describe('findExpressionType', () => { +// let expTypes; + +// beforeEach(() => { +// expTypes = []; +// const keys = Object.keys(registries); +// keys.forEach(key => { +// const reg = registries[key]; +// reg.reset(); + +// const expObj = () => ({ +// name: `__test_${key}`, +// key, +// }); +// expTypes.push(expObj); +// reg.register(expObj); +// }); +// }); + +// describe('all types', () => { +// it('returns the matching item, by name', () => { +// const match = findExpressionType('__test_model'); +// expect(match).to.eql(expTypes[2]()); +// }); + +// it('returns null when nothing is found', () => { +// const match = findExpressionType('@@nope_nope_nope'); +// expect(match).to.equal(null); +// }); + +// it('throws with multiple matches', () => { +// const commonName = 'commonName'; +// registries.transform.register(() => ({ +// name: commonName, +// })); +// registries.model.register(() => ({ +// name: commonName, +// })); + +// const check = () => { +// findExpressionType(commonName); +// }; +// expect(check).to.throwException(/Found multiple expressions/i); +// }); +// }); + +// describe('specific type', () => { +// it('return the match item, by name and type', () => { +// const match = findExpressionType('__test_view', 'view'); +// expect(match).to.eql(expTypes[3]()); +// }); + +// it('returns null with no match by name and type', () => { +// const match = findExpressionType('__test_view', 'datasource'); +// expect(match).to.equal(null); +// }); +// }); +// }); + +// TODO: restore this test +// proxyquire can not be used to inject mock registries + +describe.skip('findExpressionType', () => {}); diff --git a/x-pack/plugins/canvas/public/lib/__tests__/history_provider.js b/x-pack/plugins/canvas/public/lib/__tests__/history_provider.js new file mode 100644 index 0000000000000..bedc4c64fa1f7 --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/__tests__/history_provider.js @@ -0,0 +1,237 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import lzString from 'lz-string'; +import { historyProvider } from '../history_provider'; + +function createState() { + return { + transient: { + editing: false, + selectedPage: 'page-f3ce-4bb7-86c8-0417606d6592', + selectedElement: 'element-d88c-4bbd-9453-db22e949b92e', + resolvedArgs: {}, + }, + persistent: { + schemaVersion: 0, + time: new Date().getTime(), + }, + }; +} + +describe('historyProvider', () => { + let history; + let state; + + beforeEach(() => { + history = historyProvider(); + state = createState(); + }); + + describe('instances', () => { + it('should return the same instance for the same window object', () => { + expect(historyProvider()).to.equal(history); + }); + + it('should return different instance for different window object', () => { + const newWindow = {}; + expect(historyProvider(newWindow)).not.to.be(history); + }); + }); + + describe('push updates', () => { + beforeEach(() => { + history.push(state); + }); + + afterEach(() => { + // reset state back to initial after each test + history.undo(); + }); + + describe('push', () => { + it('should add state to location', () => { + expect(history.getLocation().state).to.eql(state); + }); + + it('should push compressed state into history', () => { + const hist = history.historyInstance; + expect(hist.location.state).to.equal(lzString.compress(JSON.stringify(state))); + }); + }); + + describe.skip('undo', () => { + it('should move history back', () => { + // pushed location has state value + expect(history.getLocation().state).to.eql(state); + + // back to initial location with null state + history.undo(); + expect(history.getLocation().state).to.be(null); + }); + }); + + describe.skip('redo', () => { + it('should move history forward', () => { + // back to initial location, with null state + history.undo(); + expect(history.getLocation().state).to.be(null); + + // return to pushed location, with state value + history.redo(); + expect(history.getLocation().state).to.eql(state); + }); + }); + }); + + describe.skip('replace updates', () => { + beforeEach(() => { + history.replace(state); + }); + + afterEach(() => { + // reset history to default after each test + history.replace(null); + }); + + describe('replace', () => { + it('should replace state in window history', () => { + expect(history.getLocation().state).to.eql(state); + }); + + it('should replace compressed state into history', () => { + const hist = history.historyInstance; + expect(hist.location.state).to.equal(lzString.compress(JSON.stringify(state))); + }); + }); + }); + + describe('onChange', () => { + const createOnceHandler = (history, done, fn) => { + const teardown = history.onChange((location, prevLocation) => { + if (typeof fn === 'function') fn(location, prevLocation); + teardown(); + done(); + }); + }; + + it('should return a method to remove the listener', () => { + const handler = () => 'hello world'; + const teardownFn = history.onChange(handler); + + expect(teardownFn).to.be.a('function'); + + // teardown the listener + teardownFn(); + }); + + it('should call handler on state change', done => { + createOnceHandler(history, done, loc => { + expect(loc).to.be.a('object'); + }); + + history.push({}); + }); + + it('should pass location object to handler', done => { + createOnceHandler(history, done, location => { + expect(location.pathname).to.be.a('string'); + expect(location.hash).to.be.a('string'); + expect(location.state).to.be.an('object'); + expect(location.action).to.equal('push'); + }); + + history.push(state); + }); + + it('should pass decompressed state to handler', done => { + createOnceHandler(history, done, ({ state: curState }) => { + expect(curState).to.eql(state); + }); + + history.push(state); + }); + + it('should pass in the previous location object to handler', done => { + createOnceHandler(history, done, (location, prevLocation) => { + expect(prevLocation.pathname).to.be.a('string'); + expect(prevLocation.hash).to.be.a('string'); + expect(prevLocation.state).to.be(null); + expect(prevLocation.action).to.equal('push'); + }); + + history.push(state); + }); + }); + + describe('resetOnChange', () => { + it('removes listeners', () => { + const createHandler = () => { + let callCount = 0; + + function handlerFn() { + callCount += 1; + } + handlerFn.getCallCount = () => callCount; + + return handlerFn; + }; + + const handler1 = createHandler(); + const handler2 = createHandler(); + + // attach and test the first handler + history.onChange(handler1); + + expect(handler1.getCallCount()).to.equal(0); + history.push({}); + expect(handler1.getCallCount()).to.equal(1); + + // attach and test the second handler + history.onChange(handler2); + + expect(handler2.getCallCount()).to.equal(0); + history.push({}); + expect(handler1.getCallCount()).to.equal(2); + expect(handler2.getCallCount()).to.equal(1); + + // remove all handlers + history.resetOnChange(); + history.push({}); + expect(handler1.getCallCount()).to.equal(2); + expect(handler2.getCallCount()).to.equal(1); + }); + }); + + describe('parse', () => { + it('returns the decompressed object', () => { + history.push(state); + + const hist = history.historyInstance; + const rawState = hist.location.state; + + expect(rawState).to.be.a('string'); + expect(history.parse(rawState)).to.eql(state); + }); + + it('returns null with invalid JSON', () => { + expect(history.parse('hello')).to.be(null); + }); + }); + + describe('encode', () => { + it('returns the compressed string', () => { + history.push(state); + + const hist = history.historyInstance; + const rawState = hist.location.state; + + expect(rawState).to.be.a('string'); + expect(history.encode(state)).to.eql(rawState); + }); + }); +}); diff --git a/x-pack/plugins/canvas/public/lib/__tests__/modify_path.js b/x-pack/plugins/canvas/public/lib/__tests__/modify_path.js new file mode 100644 index 0000000000000..c5d84e74a3e07 --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/__tests__/modify_path.js @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { prepend, append } from '../modify_path'; + +describe('modify paths', () => { + describe('prepend', () => { + it('prepends a string path', () => { + expect(prepend('a.b.c', '0')).to.eql([0, 'a', 'b', 'c']); + expect(prepend('a.b.c', ['0', '1'])).to.eql([0, 1, 'a', 'b', 'c']); + }); + + it('prepends an array path', () => { + expect(prepend(['a', 1, 'last'], '0')).to.eql([0, 'a', 1, 'last']); + expect(prepend(['a', 1, 'last'], [0, 1])).to.eql([0, 1, 'a', 1, 'last']); + }); + }); + + describe('append', () => { + it('appends to a string path', () => { + expect(append('one.2.3', 'zero')).to.eql(['one', 2, 3, 'zero']); + expect(append('one.2.3', ['zero', 'one'])).to.eql(['one', 2, 3, 'zero', 'one']); + }); + + it('appends to an array path', () => { + expect(append(['testString'], 'huzzah')).to.eql(['testString', 'huzzah']); + expect(append(['testString'], ['huzzah', 'yosh'])).to.eql(['testString', 'huzzah', 'yosh']); + }); + }); +}); diff --git a/x-pack/plugins/canvas/public/lib/__tests__/resolved_arg.js b/x-pack/plugins/canvas/public/lib/__tests__/resolved_arg.js new file mode 100644 index 0000000000000..24665a11c50f5 --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/__tests__/resolved_arg.js @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { getState, getValue, getError } from '../resolved_arg'; + +describe('resolved arg helper', () => { + describe('getState', () => { + it('returns pending by default', () => { + expect(getState()).to.be(null); + }); + + it('returns the state', () => { + expect(getState({ state: 'pending' })).to.equal('pending'); + expect(getState({ state: 'ready' })).to.equal('ready'); + expect(getState({ state: 'error' })).to.equal('error'); + }); + }); + + describe('getValue', () => { + it('returns null by default', () => { + expect(getValue()).to.be(null); + }); + + it('returns the value', () => { + expect(getValue({ value: 'hello test' })).to.equal('hello test'); + }); + }); + + describe('getError', () => { + it('returns null by default', () => { + expect(getError()).to.be(null); + }); + + it('returns null when state is not error', () => { + expect(getError({ state: 'pending', error: 'nope' })).to.be(null); + }); + + it('returns the error', () => { + const arg = { + state: 'error', + value: 'test', + error: new Error('i failed'), + }; + + expect(getError(arg)).to.be.an(Error); + expect(getError(arg).toString()).to.match(/i failed/); + }); + }); +}); diff --git a/x-pack/plugins/canvas/public/lib/aeroelastic/config.js b/x-pack/plugins/canvas/public/lib/aeroelastic/config.js new file mode 100644 index 0000000000000..fdb00987e80f9 --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/aeroelastic/config.js @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/** + * Mock config + */ + +const alignmentGuideName = 'alignmentGuide'; +const atopZ = 1000; +const depthSelect = true; +const devColor = 'magenta'; +const guideDistance = 3; +const hoverAnnotationName = 'hoverAnnotation'; +const resizeAnnotationOffset = 0; +const resizeAnnotationOffsetZ = 0.1; // causes resize markers to be slightly above the shape plane +const resizeAnnotationSize = 10; +const resizeAnnotationConnectorOffset = 0; //resizeAnnotationSize //+ 2 +const resizeConnectorName = 'resizeConnector'; +const rotateAnnotationOffset = 12; +const rotationHandleName = 'rotationHandle'; +const rotationHandleSize = 14; +const resizeHandleName = 'resizeHandle'; +const rotateSnapInPixels = 10; +const shortcuts = false; +const singleSelect = true; +const snapConstraint = true; +const minimumElementSize = 0; // guideDistance / 2 + 1; + +module.exports = { + alignmentGuideName, + atopZ, + depthSelect, + devColor, + guideDistance, + hoverAnnotationName, + minimumElementSize, + resizeAnnotationOffset, + resizeAnnotationOffsetZ, + resizeAnnotationSize, + resizeAnnotationConnectorOffset, + resizeConnectorName, + resizeHandleName, + rotateAnnotationOffset, + rotateSnapInPixels, + rotationHandleName, + rotationHandleSize, + shortcuts, + singleSelect, + snapConstraint, +}; diff --git a/x-pack/plugins/canvas/public/lib/aeroelastic/dom.js b/x-pack/plugins/canvas/public/lib/aeroelastic/dom.js new file mode 100644 index 0000000000000..a2f908dc1c382 --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/aeroelastic/dom.js @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// converts a transform matrix to a CSS string +const matrixToCSS = transformMatrix => + transformMatrix ? 'matrix3d(' + transformMatrix.join(',') + ')' : 'translate3d(0,0,0)'; + +// converts to string, and adds `px` if non-zero +const px = value => (value === 0 ? '0' : value + 'px'); + +module.exports = { + matrixToCSS, + px, +}; diff --git a/x-pack/plugins/canvas/public/lib/aeroelastic/functional.js b/x-pack/plugins/canvas/public/lib/aeroelastic/functional.js new file mode 100644 index 0000000000000..a3c5c06d0b23f --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/aeroelastic/functional.js @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/** + * flatten + * + * Flattens an array of arrays into an array + * + * @param {*[][]} arrays + * @returns *[] + */ +const flatten = arrays => [].concat(...arrays); + +/** + * identity + * + * @param d + * @returns d + */ +const identity = d => d; + +/** + * map + * + * Maps a function over an array + * + * Passing the index and the array are avoided + * + * @param {Function} fun + * @returns {function(*): *} + */ +const map = fun => array => array.map(value => fun(value)); + +/** + * log + * + * @param d + * @param {Function} printerFun + * @returns d + */ +const log = (d, printerFun = identity) => { + console.log(printerFun(d)); + return d; +}; + +/** + * disjunctiveUnion + * + * @param {Function} keyFun + * @param {*[]} set1 + * @param {*[]} set2 + * @returns *[] + */ +const disjunctiveUnion = (keyFun, set1, set2) => + set1 + .filter(s1 => !set2.find(s2 => keyFun(s2) === keyFun(s1))) + .concat(set2.filter(s2 => !set1.find(s1 => keyFun(s1) === keyFun(s2)))); + +/** + * + * @param {number} a + * @param {number} b + * @returns {number} the mean of the two parameters + */ +const mean = (a, b) => (a + b) / 2; + +/** + * unnest + * + * @param {*[][]} vectorOfVectors + * @returns {*[]} + */ +const unnest = vectorOfVectors => [].concat.apply([], vectorOfVectors); + +const shallowEqual = (a, b) => { + if (a === b) return true; + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; + + return true; +}; + +module.exports = { + disjunctiveUnion, + flatten, + identity, + log, + map, + mean, + shallowEqual, + unnest, +}; diff --git a/x-pack/plugins/canvas/public/lib/aeroelastic/geometry.js b/x-pack/plugins/canvas/public/lib/aeroelastic/geometry.js new file mode 100644 index 0000000000000..271e66efcdf5c --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/aeroelastic/geometry.js @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +const matrix = require('./matrix'); + +/** + * Pure calculations with geometry awareness - a set of rectangles with known size (a, b) and projection (transform matrix) + */ + +/** + * + * a * x0 + b * x1 = x + * a * y0 + b * y1 = y + * + * a, b = ? + * + * b = (y - a * y0) / y1 + * + * a * x0 + b * x1 = x + * + * a * x0 + (y - a * y0) / y1 * x1 = x + * + * a * x0 + y / y1 * x1 - a * y0 / y1 * x1 = x + * + * a * x0 - a * y0 / y1 * x1 = x - y / y1 * x1 + * + * a * (x0 - y0 / y1 * x1) = x - y / y1 * x1 + * + * a = (x - y / y1 * x1) / (x0 - y0 / y1 * x1) + * b = (y - a * y0) / y1 + * + */ +// set of shapes under a specific point +const shapesAtPoint = (shapes, x, y) => + shapes.map((shape, index) => { + const { transformMatrix, a, b } = shape; + + // Determine z (depth) by composing the x, y vector out of local unit x and unit y vectors; by knowing the + // scalar multipliers for the unit x and unit y vectors, we can determine z from their respective 'slope' (gradient) + const centerPoint = matrix.normalize(matrix.mvMultiply(transformMatrix, matrix.ORIGIN)); + const rightPoint = matrix.normalize(matrix.mvMultiply(transformMatrix, [1, 0, 0, 1])); + const upPoint = matrix.normalize(matrix.mvMultiply(transformMatrix, [0, 1, 0, 1])); + const x0 = rightPoint[0] - centerPoint[0]; + const y0 = rightPoint[1] - centerPoint[1]; + const x1 = upPoint[0] - centerPoint[0]; + const y1 = upPoint[1] - centerPoint[1]; + const A = (x - centerPoint[0] - ((y - centerPoint[1]) / y1) * x1) / (x0 - (y0 / y1) * x1); + const B = (y - centerPoint[1] - A * y0) / y1; + const rightSlope = rightPoint[2] - centerPoint[2]; + const upSlope = upPoint[2] - centerPoint[2]; + const z = centerPoint[2] + (y1 ? rightSlope * A + upSlope * B : 0); // handle degenerate case: y1 === 0 (infinite slope) + + // We go full tilt with the inverse transform approach because that's general enough to handle any non-pathological + // composition of transforms. Eg. this is a description of the idea: https://math.stackexchange.com/a/1685315 + // Hmm maybe we should reuse the above right and up unit vectors to establish whether we're within the (a, b) 'radius' + // rather than using matrix inversion. Bound to be cheaper. + + const inverseProjection = matrix.invert(transformMatrix); + const intersection = matrix.normalize(matrix.mvMultiply(inverseProjection, [x, y, z, 1])); + const [sx, sy] = intersection; + + // z is needed downstream, to tell which one is the closest shape hit by an x, y ray (shapes can be tilted in z) + // it looks weird to even return items where inside === false, but it could be useful for hotspots outside the rectangle + return { z, intersection, inside: Math.abs(sx) <= a && Math.abs(sy) <= b, shape, index }; + }); + +// Z-order the possibly several shapes under the same point. +// Since CSS X points to the right, Y to the bottom (not the top!) and Z toward the viewer, it's a left-handed coordinate +// system. Yet another wording is that X and Z point toward the expected directions (right, and towards the viewer, +// respectively), but Y is pointing toward the bottom (South). It's called left-handed because we can position the thumb (X), +// index (Y) and middle finger (Z) on the left hand such that they're all perpendicular to one another, and point to the +// positive direction. +// +// If it were a right handed coordinate system, AND Y still pointed down, then Z should increase away from the +// viewer. But that's not the case. So we maximize the Z value to tell what's on top. +const shapesAt = (shapes, { x, y }) => + shapesAtPoint(shapes, x, y) + .filter(shape => shape.inside) + .sort((shape1, shape2) => shape2.z - shape1.z || shape2.index - shape1.index) // stable sort: DOM insertion order!!! + .map(shape => shape.shape); // decreasing order, ie. from front (closest to viewer) to back + +const getExtremum = (transformMatrix, a, b) => + matrix.normalize(matrix.mvMultiply(transformMatrix, [a, b, 0, 1])); + +const landmarkPoint = ({ localTransformMatrix, a, b }, k, l) => + getExtremum(localTransformMatrix, k * a, l * b); + +module.exports = { + landmarkPoint, + shapesAt, +}; diff --git a/x-pack/plugins/canvas/public/lib/aeroelastic/gestures.js b/x-pack/plugins/canvas/public/lib/aeroelastic/gestures.js new file mode 100644 index 0000000000000..3ff3f5a0a7681 --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/aeroelastic/gestures.js @@ -0,0 +1,201 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +const { select, selectReduce } = require('./state'); + +/** + * Selectors directly from a state object + * + * (we could turn gesture.js into a factory, with this state root - primaryUpdate - being passed...) + */ + +const primaryUpdate = state => state.primaryUpdate; + +/** + * Gestures - derived selectors for transient state + */ + +// dispatch the various types of actions +const rawCursorPosition = select( + action => (action && action.type === 'cursorPosition' ? action.payload : null) +)(primaryUpdate); + +const mouseButtonEvent = select( + action => (action && action.type === 'mouseEvent' ? action.payload : null) +)(primaryUpdate); + +const keyboardEvent = select( + action => (action && action.type === 'keyboardEvent' ? action.payload : null) +)(primaryUpdate); + +const keyInfoFromMouseEvents = select( + action => + (action && action.type === 'cursorPosition') || action.type === 'mouseEvent' + ? { altKey: action.payload.altKey, metaKey: action.payload.metaKey } + : null +)(primaryUpdate); + +const altTest = key => key.slice(0, 3).toLowerCase() === 'alt' || key === 'KeyALT'; +const metaTest = key => key.slice(0, 4).toLowerCase() === 'meta'; +const deadKey1 = 'KeyDEAD'; +const deadKey2 = 'Key†'; + +// Key states (up vs down) from keyboard events are trivially only captured if there's a keyboard event, and that only +// happens if the user is interacting with the browser, and specifically, with the DOM subset that captures the keyboard +// event. It's also clear that all keys, and importantly, modifier keys (alt, meta etc.) can alter state while the user +// is not sending keyboard DOM events to the browser, eg. while using another tab or application. Similarly, an alt-tab +// switch away from the browser will cause the registration of an `Alt down`, but not an `Alt up`, because that happens +// in the switched-to application (https://github.com/elastic/kibana-canvas/issues/901). +// +// The solution is to also harvest modifier key (and in the future, maybe other key) statuses from mouse events, as these +// modifier keys typically alter behavior while a pointer gesture is going on, in this case now, relaxing or tightening +// snapping behavior. So we simply toggle the current key set up/down status (`lookup`) opportunistically. +// +// This function destructively modifies lookup, but could be made to work on immutable structures in the future. +const updateKeyLookupFromMouseEvent = (lookup, keyInfoFromMouseEvent) => { + Object.entries(keyInfoFromMouseEvent).forEach(([key, value]) => { + if (metaTest(key)) { + if (value) lookup.meta = true; + else delete lookup.meta; + } + if (altTest(key)) { + if (value) lookup.alt = true; + else delete lookup.alt; + } + }); + return lookup; +}; + +const pressedKeys = selectReduce((prevLookup, next, keyInfoFromMouseEvent) => { + const lookup = keyInfoFromMouseEvent + ? updateKeyLookupFromMouseEvent(prevLookup, keyInfoFromMouseEvent) + : prevLookup; + // these weird things get in when we alt-tab (or similar) etc. away and get back later: + delete lookup[deadKey1]; + delete lookup[deadKey2]; + if (!next) return { ...lookup }; + + let code = next.code; + if (altTest(next.code)) code = 'alt'; + + if (metaTest(next.code)) code = 'meta'; + + if (next.event === 'keyDown') { + return { ...lookup, [code]: true }; + } else { + /*eslint no-unused-vars: ["error", { "ignoreRestSiblings": true }]*/ + const { [code]: ignore, ...rest } = lookup; + return rest; + } +}, {})(keyboardEvent, keyInfoFromMouseEvents); + +const keyUp = select(keys => Object.keys(keys).length === 0)(pressedKeys); + +const metaHeld = select(lookup => Boolean(lookup.meta))(pressedKeys); +const optionHeld = select(lookup => Boolean(lookup.alt))(pressedKeys); + +const cursorPosition = selectReduce((previous, position) => position || previous, { x: 0, y: 0 })( + rawCursorPosition +); + +const mouseButton = selectReduce( + (prev, next) => { + if (!next) return prev; + const { event, uid } = next; + if (event === 'mouseDown') return { down: true, uid }; + else return event === 'mouseUp' ? { down: false, uid } : prev; + }, + { down: false, uid: null } +)(mouseButtonEvent); + +const mouseIsDown = selectReduce( + (previous, next) => (next ? next.event === 'mouseDown' : previous), + false +)(mouseButtonEvent); + +const gestureEnd = selectReduce( + (prev, keyUp, mouseIsDown) => { + const inAction = !keyUp || mouseIsDown; + const ended = !inAction && prev.inAction; + return { ended, inAction }; + }, + { + ended: false, + inAction: false, + }, + d => d.ended +)(keyUp, mouseIsDown); + +/** + * mouseButtonStateTransitions + * + * View: http://stable.ascii-flow.appspot.com/#567671116534197027 + * Edit: http://stable.ascii-flow.appspot.com/#567671116534197027/776257435 + * + * + * mouseIsDown + * initial state: 'up' +-----------> 'downed' + * ^ ^ + + + * | | !mouseIsDown | | + * !mouseIsDown | +-----------------+ | mouseIsDown && movedAlready + * | | + * +----+ 'dragging' <----+ + * + ^ + * | | + * +------+ + * mouseIsDown + * + */ +const mouseButtonStateTransitions = (state, mouseIsDown, movedAlready) => { + switch (state) { + case 'up': + return mouseIsDown ? 'downed' : 'up'; + case 'downed': + if (mouseIsDown) return movedAlready ? 'dragging' : 'downed'; + else return 'up'; + + case 'dragging': + return mouseIsDown ? 'dragging' : 'up'; + } +}; + +const mouseButtonState = selectReduce( + ({ buttonState, downX, downY }, mouseIsDown, { x, y }) => { + const movedAlready = x !== downX || y !== downY; + const newButtonState = mouseButtonStateTransitions(buttonState, mouseIsDown, movedAlready); + return { + buttonState: newButtonState, + downX: newButtonState === 'downed' ? x : downX, + downY: newButtonState === 'downed' ? y : downY, + }; + }, + { buttonState: 'up', downX: null, downY: null } +)(mouseIsDown, cursorPosition); + +const mouseDowned = select(state => state.buttonState === 'downed')(mouseButtonState); + +const dragging = select(state => state.buttonState === 'dragging')(mouseButtonState); + +const dragVector = select(({ buttonState, downX, downY }, { x, y }) => ({ + down: buttonState !== 'up', + x0: downX, + y0: downY, + x1: x, + y1: y, +}))(mouseButtonState, cursorPosition); + +module.exports = { + dragging, + dragVector, + cursorPosition, + gestureEnd, + metaHeld, + mouseButton, + mouseDowned, + mouseIsDown, + optionHeld, + pressedKeys, +}; diff --git a/x-pack/plugins/canvas/public/lib/aeroelastic/index.js b/x-pack/plugins/canvas/public/lib/aeroelastic/index.js new file mode 100644 index 0000000000000..df3bdac3c55d6 --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/aeroelastic/index.js @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +const dom = require('./dom'); +const geometry = require('./geometry'); +const gestures = require('./gestures'); +const layout = require('./layout'); +const matrix = require('./matrix'); +const state = require('./state'); + +module.exports = { + dom, + geometry, + gestures, + layout, + matrix, + state, +}; diff --git a/x-pack/plugins/canvas/public/lib/aeroelastic/layout.js b/x-pack/plugins/canvas/public/lib/aeroelastic/layout.js new file mode 100644 index 0000000000000..c0e836d5de229 --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/aeroelastic/layout.js @@ -0,0 +1,1210 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +const { select, selectReduce } = require('./state'); + +const { + dragging, + dragVector, + cursorPosition, + gestureEnd, + metaHeld, + mouseButton, + mouseDowned, + mouseIsDown, + optionHeld, + pressedKeys, +} = require('./gestures'); + +const { shapesAt, landmarkPoint } = require('./geometry'); + +const matrix = require('./matrix'); +const matrix2d = require('./matrix2d'); + +const config = require('./config'); + +const { identity, disjunctiveUnion, mean, shallowEqual, unnest } = require('./functional'); + +/** + * Selectors directly from a state object + */ + +const primaryUpdate = state => state.primaryUpdate; +const scene = state => state.currentScene; + +/** + * Pure calculations + */ + +// returns the currently dragged shape, or a falsey value otherwise +const draggingShape = ({ draggedShape, shapes }, hoveredShape, down, mouseDowned) => { + const dragInProgress = + down && + shapes.reduce((prev, next) => prev || (draggedShape && next.id === draggedShape.id), false); + const result = (dragInProgress && draggedShape) || (down && mouseDowned && hoveredShape); + return result; +}; + +/** + * Scenegraph update based on events, gestures... + */ + +const shapes = select(scene => scene.shapes)(scene); + +const hoveredShapes = select((shapes, cursorPosition) => + shapesAt(shapes.filter(s => s.type !== 'annotation' || s.interactive), cursorPosition) +)(shapes, cursorPosition); + +const hoveredShape = selectReduce( + (prev, hoveredShapes) => { + if (hoveredShapes.length) { + const depthIndex = 0; // (prev.depthIndex + 1) % hoveredShapes.length; + return { + shape: hoveredShapes[depthIndex], + depthIndex, + }; + } else { + return { + shape: null, + depthIndex: 0, + }; + } + }, + { + shape: null, + depthIndex: 0, + }, + tuple => tuple.shape +)(hoveredShapes); + +const draggedShape = select(draggingShape)(scene, hoveredShape, mouseIsDown, mouseDowned); + +// the currently dragged shape is considered in-focus; if no dragging is going on, then the hovered shape +const focusedShape = select((draggedShape, hoveredShape) => draggedShape || hoveredShape)( + draggedShape, + hoveredShape +); + +// focusedShapes has updated position etc. information while focusedShape may have stale position +const focusedShapes = select((shapes, focusedShape) => + shapes.filter(shape => focusedShape && shape.id === focusedShape.id) +)(shapes, focusedShape); + +const keyTransformGesture = select( + keys => + config.shortcuts + ? Object.keys(keys) + .map(keypress => { + switch (keypress) { + case 'KeyW': + return { transform: matrix.translate(0, -5, 0) }; + case 'KeyA': + return { transform: matrix.translate(-5, 0, 0) }; + case 'KeyS': + return { transform: matrix.translate(0, 5, 0) }; + case 'KeyD': + return { transform: matrix.translate(5, 0, 0) }; + case 'KeyF': + return { transform: matrix.translate(0, 0, -20) }; + case 'KeyC': + return { transform: matrix.translate(0, 0, 20) }; + case 'KeyX': + return { transform: matrix.rotateX(Math.PI / 45) }; + case 'KeyY': + return { transform: matrix.rotateY(Math.PI / 45 / 1.3) }; + case 'KeyZ': + return { transform: matrix.rotateZ(Math.PI / 45 / 1.6) }; + case 'KeyI': + return { transform: matrix.scale(1, 1.05, 1) }; + case 'KeyJ': + return { transform: matrix.scale(1 / 1.05, 1, 1) }; + case 'KeyK': + return { transform: matrix.scale(1, 1 / 1.05, 1) }; + case 'KeyL': + return { transform: matrix.scale(1.05, 1, 1) }; + case 'KeyP': + return { transform: matrix.perspective(2000) }; + case 'KeyR': + return { transform: matrix.shear(0.1, 0) }; + case 'KeyT': + return { transform: matrix.shear(-0.1, 0) }; + case 'KeyU': + return { transform: matrix.shear(0, 0.1) }; + case 'KeyH': + return { transform: matrix.shear(0, -0.1) }; + case 'KeyM': + return { transform: matrix.UNITMATRIX, sizes: [1.0, 0, 0, 0, 1.0, 0, 10, 0, 1] }; + case 'Backspace': + case 'Delete': + return { transform: matrix.UNITMATRIX, delete: true }; + } + }) + .filter(identity) + : [] +)(pressedKeys); + +const alterSnapGesture = select(metaHeld => (metaHeld ? ['relax'] : []))(metaHeld); + +const initialTransformTuple = { + deltaX: 0, + deltaY: 0, + transform: null, + cumulativeTransform: null, +}; + +const mouseTransformGesture = selectReduce( + (prev, dragging, { x0, y0, x1, y1 }) => { + if (dragging) { + const deltaX = x1 - x0; + const deltaY = y1 - y0; + const transform = matrix.translate(deltaX - prev.deltaX, deltaY - prev.deltaY, 0); + const cumulativeTransform = matrix.translate(deltaX, deltaY, 0); + return { + deltaX, + deltaY, + transform, + cumulativeTransform, + }; + } else { + return initialTransformTuple; + } + }, + initialTransformTuple, + tuple => + [tuple] + .filter(tuple => tuple.transform) + .map(({ transform, cumulativeTransform }) => ({ transform, cumulativeTransform })) +)(dragging, dragVector); + +const transformGestures = select((keyTransformGesture, mouseTransformGesture) => + keyTransformGesture.concat(mouseTransformGesture) +)(keyTransformGesture, mouseTransformGesture); + +const restateShapesEvent = select( + action => (action && action.type === 'restateShapesEvent' ? action.payload : null) +)(primaryUpdate); + +// directSelect is an API entry point (via the `shapeSelect` action) that lets the client directly specify what thing +// is selected, as otherwise selection is driven by gestures and knowledge of element positions +const directSelect = select( + action => (action && action.type === 'shapeSelect' ? action.payload : null) +)(primaryUpdate); + +const initialSelectedShapeState = { + shapes: [], + uid: null, + depthIndex: 0, + down: false, + metaHeld: false, +}; + +const singleSelect = (prev, hoveredShapes, metaHeld, down, uid) => { + // cycle from top ie. from zero after the cursor position changed ie. !sameLocation + const metaChanged = metaHeld !== prev.metaHeld; + const depthIndex = + config.depthSelect && metaHeld + ? (prev.depthIndex + (down && !prev.down ? 1 : 0)) % hoveredShapes.length + : 0; + return hoveredShapes.length + ? { + shapes: [hoveredShapes[depthIndex]], + uid, + depthIndex, + down, + metaHeld, + metaChanged: depthIndex === prev.depthIndex ? metaChanged : false, + } + : { ...initialSelectedShapeState, uid, down, metaHeld, metaChanged }; +}; + +const multiSelect = (prev, hoveredShapes, metaHeld, down, uid) => { + return { + shapes: hoveredShapes.length + ? disjunctiveUnion(shape => shape.id, prev.shapes, hoveredShapes) + : [], + uid, + }; +}; + +const selectedShapes = selectReduce( + (prev, hoveredShapes, { down, uid }, metaHeld, directSelect, allShapes) => { + const mouseButtonUp = !down; + if ( + directSelect && + directSelect.shapes && + !shallowEqual(directSelect.shapes, prev.shapes.map(shape => shape.id)) + ) { + const { shapes, uid } = directSelect; + return { ...prev, shapes: shapes.map(id => allShapes.find(shape => shape.id === id)), uid }; + } + if (uid === prev.uid && !directSelect) return prev; + if (mouseButtonUp) return { ...prev, down, uid, metaHeld }; // take action on mouse down only, ie. bail otherwise + const selectFunction = config.singleSelect ? singleSelect : multiSelect; + const result = selectFunction(prev, hoveredShapes, metaHeld, down, uid); + return result; + }, + initialSelectedShapeState, + d => d.shapes +)(hoveredShapes, mouseButton, metaHeld, directSelect, shapes); + +const selectedShapeIds = select(shapes => shapes.map(shape => shape.id))(selectedShapes); + +const primaryShape = shape => shape.parent || shape.id; + +const selectedPrimaryShapeIds = select(shapes => shapes.map(primaryShape))(selectedShapes); + +const rotationManipulation = ({ + shape, + directShape, + cursorPosition: { x, y }, + alterSnapGesture, +}) => { + // rotate around a Z-parallel line going through the shape center (ie. around the center) + if (!shape || !directShape) return { transforms: [], shapes: [] }; + const center = shape.transformMatrix; + const centerPosition = matrix.mvMultiply(center, matrix.ORIGIN); + const vector = matrix.mvMultiply( + matrix.multiply(center, directShape.localTransformMatrix), + matrix.ORIGIN + ); + const oldAngle = Math.atan2(centerPosition[1] - vector[1], centerPosition[0] - vector[0]); + const newAngle = Math.atan2(centerPosition[1] - y, centerPosition[0] - x); + const closest45deg = (Math.round(newAngle / (Math.PI / 4)) * Math.PI) / 4; + const radius = Math.sqrt(Math.pow(centerPosition[0] - x, 2) + Math.pow(centerPosition[1] - y, 2)); + const closest45degPosition = [Math.cos(closest45deg) * radius, Math.sin(closest45deg) * radius]; + const pixelDifference = Math.sqrt( + Math.pow(closest45degPosition[0] - (centerPosition[0] - x), 2) + + Math.pow(closest45degPosition[1] - (centerPosition[1] - y), 2) + ); + const relaxed = alterSnapGesture.indexOf('relax') !== -1; + const newSnappedAngle = + pixelDifference < config.rotateSnapInPixels && !relaxed ? closest45deg : newAngle; + const result = matrix.rotateZ(oldAngle - newSnappedAngle); + return { transforms: [result], shapes: [shape.id] }; +}; + +/* upcoming functionality +const centeredScaleManipulation = ({ shape, directShape, cursorPosition: { x, y } }) => { + // scaling such that the center remains in place (ie. the other side of the shape can grow/shrink) + if (!shape || !directShape) return { transforms: [], shapes: [] }; + const center = shape.transformMatrix; + const vector = matrix.mvMultiply( + matrix.multiply(center, directShape.localTransformMatrix), + matrix.ORIGIN + ); + const shapeCenter = matrix.mvMultiply(center, matrix.ORIGIN); + const horizontalRatio = + directShape.horizontalPosition === 'center' + ? 1 + : Math.max(0.5, (x - shapeCenter[0]) / (vector[0] - shapeCenter[0])); + const verticalRatio = + directShape.verticalPosition === 'center' + ? 1 + : Math.max(0.5, (y - shapeCenter[1]) / (vector[1] - shapeCenter[1])); + const result = matrix.scale(horizontalRatio, verticalRatio, 1); + return { transforms: [result], shapes: [shape.id] }; +}; +*/ + +const resizeMultiplierHorizontal = { left: -1, center: 0, right: 1 }; +const resizeMultiplierVertical = { top: -1, center: 0, bottom: 1 }; +const xNames = { '-1': 'left', '0': 'center', '1': 'right' }; +const yNames = { '-1': 'top', '0': 'center', '1': 'bottom' }; + +const minimumSize = ({ a, b, baseAB }, vector) => { + // don't allow an element size of less than the minimumElementSize + // todo switch to matrix algebra + const min = config.minimumElementSize; + return [ + Math.max(baseAB ? min - baseAB[0] : min - a, vector[0]), + Math.max(baseAB ? min - baseAB[1] : min - b, vector[1]), + ]; +}; + +const centeredResizeManipulation = ({ gesture, shape, directShape }) => { + const transform = gesture.cumulativeTransform; + // scaling such that the center remains in place (ie. the other side of the shape can grow/shrink) + if (!shape || !directShape) return { transforms: [], shapes: [] }; + // transform the incoming `transform` so that resizing is aligned with shape orientation + const vector = matrix.mvMultiply( + matrix.multiply( + matrix.invert(matrix.compositeComponent(shape.localTransformMatrix)), // rid the translate component + transform + ), + matrix.ORIGIN + ); + const orientationMask = [ + resizeMultiplierHorizontal[directShape.horizontalPosition], + resizeMultiplierVertical[directShape.verticalPosition], + 0, + ]; + const orientedVector = matrix2d.componentProduct(vector, orientationMask); + const cappedOrientedVector = minimumSize(shape, orientedVector); + return { + cumulativeTransforms: [], + cumulativeSizes: [gesture.sizes || matrix2d.translate(...cappedOrientedVector)], + shapes: [shape.id], + }; +}; + +const asymmetricResizeManipulation = ({ gesture, shape, directShape }) => { + const transform = gesture.cumulativeTransform; + // scaling such that the center remains in place (ie. the other side of the shape can grow/shrink) + if (!shape || !directShape) return { transforms: [], shapes: [] }; + // transform the incoming `transform` so that resizing is aligned with shape orientation + const compositeComponent = matrix.compositeComponent(shape.localTransformMatrix); + const inv = matrix.invert(compositeComponent); // rid the translate component + const vector = matrix.mvMultiply(matrix.multiply(inv, transform), matrix.ORIGIN); + const orientationMask = [ + resizeMultiplierHorizontal[directShape.horizontalPosition] / 2, + resizeMultiplierVertical[directShape.verticalPosition] / 2, + 0, + ]; + const orientedVector = matrix2d.componentProduct(vector, orientationMask); + const cappedOrientedVector = minimumSize(shape, orientedVector); + + const antiRotatedVector = matrix.mvMultiply( + matrix.multiply( + compositeComponent, + matrix.scale( + resizeMultiplierHorizontal[directShape.horizontalPosition], + resizeMultiplierVertical[directShape.verticalPosition], + 1 + ), + matrix.translate(cappedOrientedVector[0], cappedOrientedVector[1], 0) + ), + matrix.ORIGIN + ); + const sizeMatrix = gesture.sizes || matrix2d.translate(...cappedOrientedVector); + return { + cumulativeTransforms: [matrix.translate(antiRotatedVector[0], antiRotatedVector[1], 0)], + cumulativeSizes: [sizeMatrix], + shapes: [shape.id], + }; +}; + +const directShapeTranslateManipulation = (cumulativeTransforms, directShapes) => { + const shapes = directShapes + .map(shape => shape.type !== 'annotation' && shape.id) + .filter(identity); + return [{ cumulativeTransforms, shapes }]; +}; + +const rotationAnnotationManipulation = ( + directTransforms, + directShapes, + allShapes, + cursorPosition, + alterSnapGesture +) => { + const shapeIds = directShapes.map( + shape => + shape.type === 'annotation' && shape.subtype === config.rotationHandleName && shape.parent + ); + const shapes = shapeIds.map(id => id && allShapes.find(shape => shape.id === id)); + const tuples = unnest( + shapes.map((shape, i) => + directTransforms.map(transform => ({ + transform, + shape, + directShape: directShapes[i], + cursorPosition, + alterSnapGesture, + })) + ) + ); + return tuples.map(rotationManipulation); +}; + +const resizeAnnotationManipulation = (transformGestures, directShapes, allShapes, manipulator) => { + const shapeIds = directShapes.map( + shape => + shape.type === 'annotation' && shape.subtype === config.resizeHandleName && shape.parent + ); + const shapes = shapeIds.map(id => id && allShapes.find(shape => shape.id === id)); + const tuples = unnest( + shapes.map((shape, i) => + transformGestures.map(gesture => ({ gesture, shape, directShape: directShapes[i] })) + ) + ); + return tuples.map(manipulator); +}; + +const symmetricManipulation = optionHeld; // as in comparable software applications, todo: make configurable + +const resizeManipulator = select( + toggle => (toggle ? centeredResizeManipulation : asymmetricResizeManipulation) +)(symmetricManipulation); + +const transformIntents = select( + (transformGestures, directShapes, shapes, cursorPosition, alterSnapGesture, manipulator) => [ + ...directShapeTranslateManipulation( + transformGestures.map(g => g.cumulativeTransform), + directShapes + ), + ...rotationAnnotationManipulation( + transformGestures.map(g => g.transform), + directShapes, + shapes, + cursorPosition, + alterSnapGesture + ), + ...resizeAnnotationManipulation(transformGestures, directShapes, shapes, manipulator), + ] +)(transformGestures, selectedShapes, shapes, cursorPosition, alterSnapGesture, resizeManipulator); + +const fromScreen = currentTransform => transform => { + const isTranslate = transform[12] !== 0 || transform[13] !== 0; + if (isTranslate) { + const composite = matrix.compositeComponent(currentTransform); + const inverse = matrix.invert(composite); + const result = matrix.translateComponent(matrix.multiply(inverse, transform)); + return result; + } else { + return transform; + } +}; + +// "cumulative" is the effect of the ongoing interaction; "baseline" is sans "cumulative", plain "localTransformMatrix" +// is the composition of the baseline (previously absorbed transforms) and the cumulative (ie. ongoing interaction) +const shapeApplyLocalTransforms = intents => shape => { + const transformIntents = unnest( + intents + .map( + intent => + intent.transforms && + intent.transforms.length && + intent.shapes.find(id => id === shape.id) && + intent.transforms.map(fromScreen(shape.localTransformMatrix)) + ) + .filter(identity) + ); + const sizeIntents = unnest( + intents + .map( + intent => + intent.sizes && + intent.sizes.length && + intent.shapes.find(id => id === shape.id) && + intent.sizes + ) + .filter(identity) + ); + const cumulativeTransformIntents = unnest( + intents + .map( + intent => + intent.cumulativeTransforms && + intent.cumulativeTransforms.length && + intent.shapes.find(id => id === shape.id) && + intent.cumulativeTransforms.map(fromScreen(shape.localTransformMatrix)) + ) + .filter(identity) + ); + const cumulativeSizeIntents = unnest( + intents + .map( + intent => + intent.cumulativeSizes && + intent.cumulativeSizes.length && + intent.shapes.find(id => id === shape.id) && + intent.cumulativeSizes + ) + .filter(identity) + ); + + const baselineLocalTransformMatrix = matrix.multiply( + shape.baselineLocalTransformMatrix || shape.localTransformMatrix, + ...transformIntents + ); + const cumulativeTransformIntentMatrix = matrix.multiply(...cumulativeTransformIntents); + const baselineSizeMatrix = matrix2d.multiply(...sizeIntents) || matrix2d.UNITMATRIX; + const localTransformMatrix = cumulativeTransformIntents.length + ? matrix.multiply(baselineLocalTransformMatrix, cumulativeTransformIntentMatrix) + : baselineLocalTransformMatrix; + + const cumulativeSizeIntentMatrix = matrix2d.multiply(...cumulativeSizeIntents); + const sizeVector = matrix2d.mvMultiply( + cumulativeSizeIntents.length + ? matrix2d.multiply(baselineSizeMatrix, cumulativeSizeIntentMatrix) + : baselineSizeMatrix, + shape.baseAB ? [...shape.baseAB, 1] : [shape.a, shape.b, 1] + ); + + // Absorb changes if the gesture has ended + const absorbChanges = + !transformIntents.length && + !sizeIntents.length && + !cumulativeTransformIntents.length && + !cumulativeSizeIntents.length; + + return { + // update the preexisting shape: + ...shape, + // apply transforms: + baselineLocalTransformMatrix: absorbChanges ? null : baselineLocalTransformMatrix, + baselineSizeMatrix: absorbChanges ? null : baselineSizeMatrix, + localTransformMatrix: absorbChanges ? shape.localTransformMatrix : localTransformMatrix, + a: absorbChanges ? shape.a : sizeVector[0], + b: absorbChanges ? shape.b : sizeVector[1], + baseAB: absorbChanges ? null : shape.baseAB || [shape.a, shape.b], + }; +}; + +const applyLocalTransforms = (shapes, transformIntents) => { + return shapes.map(shapeApplyLocalTransforms(transformIntents)); +}; + +const getUpstreamTransforms = (shapes, shape) => + shape.parent + ? getUpstreamTransforms(shapes, shapes.find(s => s.id === shape.parent)).concat([ + shape.localTransformMatrix, + ]) + : [shape.localTransformMatrix]; + +const getUpstreams = (shapes, shape) => + shape.parent + ? getUpstreams(shapes, shapes.find(s => s.id === shape.parent)).concat([shape]) + : [shape]; + +const snappedA = shape => shape.a + (shape.snapResizeVector ? shape.snapResizeVector[0] : 0); +const snappedB = shape => shape.b + (shape.snapResizeVector ? shape.snapResizeVector[1] : 0); + +const shapeCascadeTransforms = shapes => shape => { + const upstreams = getUpstreams(shapes, shape); + const upstreamTransforms = upstreams.map(shape => { + return shape.snapDeltaMatrix + ? matrix.multiply(shape.localTransformMatrix, shape.snapDeltaMatrix) + : shape.localTransformMatrix; + }); + const cascadedTransforms = matrix.reduceTransforms(upstreamTransforms); + + return { + ...shape, + transformMatrix: cascadedTransforms, + width: 2 * snappedA(shape), + height: 2 * snappedB(shape), + }; +}; + +const cascadeTransforms = shapes => shapes.map(shapeCascadeTransforms(shapes)); + +const nextShapes = select((preexistingShapes, restated) => { + if (restated && restated.newShapes) return restated.newShapes; + + // this is the per-shape model update at the current PoC level + return preexistingShapes; +})(shapes, restateShapesEvent); + +const transformedShapes = select(applyLocalTransforms)(nextShapes, transformIntents); + +const alignmentGuides = (shapes, guidedShapes, draggedShape) => { + const result = {}; + let counter = 0; + const extremeHorizontal = resizeMultiplierHorizontal[draggedShape.horizontalPosition]; + const extremeVertical = resizeMultiplierVertical[draggedShape.verticalPosition]; + // todo replace for loops with [].map calls; DRY it up, break out parts; several of which to move to geometry.js + // todo switch to informative variable names + for (let i = 0; i < guidedShapes.length; i++) { + const d = guidedShapes[i]; + if (d.type === 'annotation') continue; // fixme avoid this by not letting annotations get in here + // key points of the dragged shape bounding box + for (let j = 0; j < shapes.length; j++) { + const s = shapes[j]; + if (d.id === s.id) continue; + if (s.type === 'annotation') continue; // fixme avoid this by not letting annotations get in here + // key points of the stationery shape + for (let k = -1; k < 2; k++) { + for (let l = -1; l < 2; l++) { + if ((k && !l) || (!k && l)) continue; // don't worry about midpoints of the edges, only the center + if ( + draggedShape.subtype === config.resizeHandleName && + !( + (extremeHorizontal === k && extremeVertical === l) || // moved corner + // moved midpoint on horizontal border + (extremeHorizontal === 0 && k !== 0 && extremeVertical === l) || + // moved midpoint on vertical border + (extremeVertical === 0 && l !== 0 && extremeHorizontal === k) + ) + ) + continue; + const D = landmarkPoint(d, k, l); + for (let m = -1; m < 2; m++) { + for (let n = -1; n < 2; n++) { + if ((m && !n) || (!m && n)) continue; // don't worry about midpoints of the edges, only the center + const S = landmarkPoint(s, m, n); + for (let dim = 0; dim < 2; dim++) { + const orthogonalDimension = 1 - dim; + const dd = D[dim]; + const ss = S[dim]; + const key = k + '|' + l + '|' + dim; + const signedDistance = dd - ss; + const distance = Math.abs(signedDistance); + const currentClosest = result[key]; + if ( + Math.round(distance) <= config.guideDistance && + (!currentClosest || distance <= currentClosest.distance) + ) { + const orthogonalValues = [ + D[orthogonalDimension], + S[orthogonalDimension], + ...(currentClosest ? [currentClosest.lowPoint, currentClosest.highPoint] : []), + ]; + const lowPoint = Math.min(...orthogonalValues); + const highPoint = Math.max(...orthogonalValues); + const midPoint = (lowPoint + highPoint) / 2; + const radius = midPoint - lowPoint; + result[key] = { + id: counter++, + localTransformMatrix: matrix.translate( + dim ? midPoint : ss, + dim ? ss : midPoint, + config.atopZ + ), + a: dim ? radius : 0.5, + b: dim ? 0.5 : radius, + lowPoint, + highPoint, + distance, + signedDistance, + dimension: dim ? 'vertical' : 'horizontal', + constrained: d.id, + constrainer: s.id, + }; + } + } + } + } + } + } + } + } + return Object.values(result); +}; + +/* upcoming functionality +const draggedShapes = select( + (shapes, selectedShapeIds, mouseIsDown) => + mouseIsDown ? shapes.filter(shape => selectedShapeIds.indexOf(shape.id) !== -1) : [] +)(nextShapes, selectedShapeIds, mouseIsDown); +*/ + +const isHorizontal = constraint => constraint.dimension === 'horizontal'; +const isVertical = constraint => constraint.dimension === 'vertical'; + +const closestConstraint = (prev = { distance: Infinity }, next) => + next.distance < prev.distance ? { constraint: next, distance: next.distance } : prev; + +const directionalConstraint = (constraints, filterFun) => { + const directionalConstraints = constraints.filter(filterFun); + const closest = directionalConstraints.reduce(closestConstraint, undefined); + return closest && closest.constraint; +}; + +const draggedPrimaryShape = select( + (shapes, draggedShape) => + draggedShape && shapes.find(shape => shape.id === primaryShape(draggedShape)) +)(shapes, draggedShape); + +const alignmentGuideAnnotations = select((shapes, draggedPrimaryShape, draggedShape) => { + const guidedShapes = draggedPrimaryShape + ? [shapes.find(s => s.id === draggedPrimaryShape.id)].filter(identity) + : []; + return guidedShapes.length + ? alignmentGuides(shapes, guidedShapes, draggedShape).map(shape => ({ + ...shape, + id: config.alignmentGuideName + '_' + shape.id, + type: 'annotation', + subtype: config.alignmentGuideName, + interactive: false, + backgroundColor: 'magenta', + })) + : []; +})(transformedShapes, draggedPrimaryShape, draggedShape); + +const hoverAnnotations = select((hoveredShape, selectedPrimaryShapeIds, draggedShape) => { + return hoveredShape && + hoveredShape.type !== 'annotation' && + selectedPrimaryShapeIds.indexOf(hoveredShape.id) === -1 && + !draggedShape + ? [ + { + ...hoveredShape, + id: config.hoverAnnotationName + '_' + hoveredShape.id, + type: 'annotation', + subtype: config.hoverAnnotationName, + interactive: false, + localTransformMatrix: matrix.multiply( + hoveredShape.localTransformMatrix, + matrix.translate(0, 0, 100) + ), + }, + ] + : []; +})(hoveredShape, selectedPrimaryShapeIds, draggedShape); + +const rotationAnnotation = (shapes, selectedShapes, shape, i) => { + const foundShape = shapes.find(s => shape.id === s.id); + if (!foundShape) return false; + + if (foundShape.type === 'annotation') { + return rotationAnnotation( + shapes, + selectedShapes, + shapes.find(s => foundShape.parent === s.id), + i + ); + } + const b = snappedB(foundShape); + const centerTop = matrix.translate(0, -b, 0); + const pixelOffset = matrix.translate(0, -config.rotateAnnotationOffset, config.atopZ); + const transform = matrix.multiply(centerTop, pixelOffset); + return { + id: config.rotationHandleName + '_' + i, + type: 'annotation', + subtype: config.rotationHandleName, + interactive: true, + parent: foundShape.id, + localTransformMatrix: transform, + backgroundColor: 'rgb(0,0,255,0.3)', + a: config.rotationHandleSize, + b: config.rotationHandleSize, + }; +}; + +const resizePointAnnotations = (parent, a, b) => ([x, y, cursorAngle]) => { + const markerPlace = matrix.translate(x * a, y * b, config.resizeAnnotationOffsetZ); + const pixelOffset = matrix.translate( + -x * config.resizeAnnotationOffset, + -y * config.resizeAnnotationOffset, + config.atopZ + 10 + ); + const transform = matrix.multiply(markerPlace, pixelOffset); + const xName = xNames[x]; + const yName = yNames[y]; + return { + id: [config.resizeHandleName, xName, yName, parent].join('_'), + type: 'annotation', + subtype: config.resizeHandleName, + horizontalPosition: xName, + verticalPosition: yName, + cursorAngle, + interactive: true, + parent, + localTransformMatrix: transform, + backgroundColor: 'rgb(0,255,0,1)', + a: config.resizeAnnotationSize, + b: config.resizeAnnotationSize, + }; +}; + +const resizeEdgeAnnotations = (parent, a, b) => ([[x0, y0], [x1, y1]]) => { + const x = a * mean(x0, x1); + const y = b * mean(y0, y1); + const markerPlace = matrix.translate(x, y, config.atopZ - 10); + const transform = markerPlace; // no offset etc. at the moment + const horizontal = y0 === y1; + const length = horizontal ? a * Math.abs(x1 - x0) : b * Math.abs(y1 - y0); + const sectionHalfLength = Math.max(0, length / 2 - config.resizeAnnotationConnectorOffset); + const width = 0.5; + return { + id: [config.resizeConnectorName, xNames[x0], yNames[y0], xNames[x1], yNames[y1], parent].join( + '_' + ), + type: 'annotation', + subtype: config.resizeConnectorName, + interactive: true, + parent, + localTransformMatrix: transform, + backgroundColor: config.devColor, + a: horizontal ? sectionHalfLength : width, + b: horizontal ? width : sectionHalfLength, + }; +}; + +function resizeAnnotation(shapes, selectedShapes, shape) { + const foundShape = shapes.find(s => shape.id === s.id); + const properShape = + foundShape && + (foundShape.subtype === config.resizeHandleName + ? shapes.find(s => shape.parent === s.id) + : foundShape); + if (!foundShape) return []; + + if (foundShape.subtype === config.resizeHandleName) { + // preserve any interactive annotation when handling + const result = foundShape.interactive + ? resizeAnnotationsFunction(shapes, [shapes.find(s => shape.parent === s.id)]) + : []; + return result; + } + if (foundShape.type === 'annotation') + return resizeAnnotation(shapes, selectedShapes, shapes.find(s => foundShape.parent === s.id)); + + // fixme left active: snap wobble. right active: opposite side wobble. + const a = snappedA(properShape); // properShape.width / 2;; + const b = snappedB(properShape); // properShape.height / 2; + const resizePoints = [ + [-1, -1, 315], + [1, -1, 45], + [1, 1, 135], + [-1, 1, 225], // corners + [0, -1, 0], + [1, 0, 90], + [0, 1, 180], + [-1, 0, 270], // edge midpoints + ].map(resizePointAnnotations(shape.id, a, b)); + const connectors = [ + [[-1, -1], [0, -1]], + [[0, -1], [1, -1]], + [[1, -1], [1, 0]], + [[1, 0], [1, 1]], + [[1, 1], [0, 1]], + [[0, 1], [-1, 1]], + [[-1, 1], [-1, 0]], + [[-1, 0], [-1, -1]], + ].map(resizeEdgeAnnotations(shape.id, a, b)); + return [...resizePoints, ...connectors]; +} + +function resizeAnnotationsFunction(shapes, selectedShapes) { + const shapesToAnnotate = selectedShapes; + return unnest( + shapesToAnnotate + .map(shape => { + return resizeAnnotation(shapes, selectedShapes, shape); + }) + .filter(identity) + ); +} + +// Once the interaction is over, ensure that the shape stays put where the constraint led it - distance is no longer relevant +// Note that this is what standard software (Adobe Illustrator, Google Slides, PowerPoint, Sketch etc.) do, but it's in +// stark contrast with the concept of StickyLines - whose central idea is that constraints remain applied until explicitly +// broken. +const crystallizeConstraint = shape => { + return { + ...shape, + snapDeltaMatrix: null, + snapResizeVector: null, + localTransformMatrix: shape.snapDeltaMatrix + ? matrix.multiply(shape.localTransformMatrix, shape.snapDeltaMatrix) + : shape.localTransformMatrix, + a: snappedA(shape), + b: snappedB(shape), + }; +}; + +const translateShapeSnap = (horizontalConstraint, verticalConstraint, draggedElement) => shape => { + const constrainedShape = draggedElement && shape.id === draggedElement.id; + const constrainedX = horizontalConstraint && horizontalConstraint.constrained === shape.id; + const constrainedY = verticalConstraint && verticalConstraint.constrained === shape.id; + const snapOffsetX = constrainedX ? -horizontalConstraint.signedDistance : 0; + const snapOffsetY = constrainedY ? -verticalConstraint.signedDistance : 0; + if (constrainedX || constrainedY) { + const snapOffset = matrix.translateComponent( + matrix.multiply( + matrix.rotateZ((matrix.matrixToAngle(draggedElement.localTransformMatrix) / 180) * Math.PI), + matrix.translate(snapOffsetX, snapOffsetY, 0) + ) + ); + return { + ...shape, + snapDeltaMatrix: snapOffset, + }; + } else if (constrainedShape) { + return { + ...shape, + snapDeltaMatrix: null, + }; + } else { + return crystallizeConstraint(shape); + } +}; + +const resizeShapeSnap = ( + horizontalConstraint, + verticalConstraint, + draggedElement, + symmetric, + horizontalPosition, + verticalPosition +) => shape => { + const constrainedShape = draggedElement && shape.id === draggedElement.id; + const constrainedX = horizontalConstraint && horizontalConstraint.constrained === shape.id; + const constrainedY = verticalConstraint && verticalConstraint.constrained === shape.id; + const snapOffsetX = constrainedX ? horizontalConstraint.signedDistance : 0; + const snapOffsetY = constrainedY ? -verticalConstraint.signedDistance : 0; + if (constrainedX || constrainedY) { + const multiplier = symmetric ? 1 : 0.5; + const angle = (matrix.matrixToAngle(draggedElement.localTransformMatrix) / 180) * Math.PI; + const horizontalSign = -resizeMultiplierHorizontal[horizontalPosition]; // fixme unify sign + const verticalSign = resizeMultiplierVertical[verticalPosition]; + // todo turn it into matrix algebra via matrix2d.js + const sin = Math.sin(angle); + const cos = Math.cos(angle); + const snapOffsetA = horizontalSign * (cos * snapOffsetX - sin * snapOffsetY); + const snapOffsetB = verticalSign * (sin * snapOffsetX + cos * snapOffsetY); + const snapTranslateOffset = matrix.translateComponent( + matrix.multiply( + matrix.rotateZ(angle), + matrix.translate((1 - multiplier) * -snapOffsetX, (1 - multiplier) * snapOffsetY, 0) + ) + ); + const snapSizeOffset = [multiplier * snapOffsetA, multiplier * snapOffsetB]; + return { + ...shape, + snapDeltaMatrix: snapTranslateOffset, + snapResizeVector: snapSizeOffset, + }; + } else if (constrainedShape) { + return { + ...shape, + snapDeltaMatrix: null, + snapResizeVector: null, + }; + } else { + return crystallizeConstraint(shape); + } +}; + +const snappedShapes = select( + ( + shapes, + draggedShape, + draggedElement, + alignmentGuideAnnotations, + alterSnapGesture, + symmetricManipulation + ) => { + const contentShapes = shapes.filter(shape => shape.type !== 'annotation'); + const constraints = alignmentGuideAnnotations; // fixme split concept of snap constraints and their annotations + const relaxed = alterSnapGesture.indexOf('relax') !== -1; + const constrained = config.snapConstraint && !relaxed; + const horizontalConstraint = constrained && directionalConstraint(constraints, isHorizontal); + const verticalConstraint = constrained && directionalConstraint(constraints, isVertical); + const snapper = draggedShape + ? { + [config.resizeHandleName]: resizeShapeSnap( + horizontalConstraint, + verticalConstraint, + draggedElement, + symmetricManipulation, + draggedShape.horizontalPosition, + draggedShape.verticalPosition + ), + [undefined]: translateShapeSnap(horizontalConstraint, verticalConstraint, draggedElement), + }[draggedShape.subtype] || (shape => shape) + : crystallizeConstraint; + return contentShapes.map(snapper); + } +)( + transformedShapes, + draggedShape, + draggedPrimaryShape, + alignmentGuideAnnotations, + alterSnapGesture, + symmetricManipulation +); + +const constrainedShapesWithPreexistingAnnotations = select((snapped, transformed) => + snapped.concat(transformed.filter(s => s.type === 'annotation')) +)(snappedShapes, transformedShapes); + +const resizeAnnotations = select(resizeAnnotationsFunction)( + constrainedShapesWithPreexistingAnnotations, + selectedShapes +); + +const rotationAnnotations = select((shapes, selectedShapes) => { + const shapesToAnnotate = selectedShapes; + return shapesToAnnotate + .map((shape, i) => rotationAnnotation(shapes, selectedShapes, shape, i)) + .filter(identity); +})(constrainedShapesWithPreexistingAnnotations, selectedShapes); + +const annotatedShapes = select( + (shapes, alignmentGuideAnnotations, hoverAnnotations, rotationAnnotations, resizeAnnotations) => { + const annotations = [].concat( + alignmentGuideAnnotations, + hoverAnnotations, + rotationAnnotations, + resizeAnnotations + ); + // remove preexisting annotations + const contentShapes = shapes.filter(shape => shape.type !== 'annotation'); + return contentShapes.concat(annotations); // add current annotations + } +)( + snappedShapes, + alignmentGuideAnnotations, + hoverAnnotations, + rotationAnnotations, + resizeAnnotations +); + +const globalTransformShapes = select(cascadeTransforms)(annotatedShapes); + +const bidirectionalCursors = { + '0': 'ns-resize', + '45': 'nesw-resize', + '90': 'ew-resize', + '135': 'nwse-resize', + '180': 'ns-resize', + '225': 'nesw-resize', + '270': 'ew-resize', + '315': 'nwse-resize', +}; + +const cursor = select((shape, draggedPrimaryShape) => { + if (!shape) return 'auto'; + switch (shape.subtype) { + case config.rotationHandleName: + return 'crosshair'; + case config.resizeHandleName: + const angle = (matrix.matrixToAngle(shape.transformMatrix) + 360) % 360; + const screenProjectedAngle = angle + shape.cursorAngle; + const discretizedAngle = (Math.round(screenProjectedAngle / 45) * 45 + 360) % 360; + return bidirectionalCursors[discretizedAngle]; + default: + return draggedPrimaryShape ? 'grabbing' : 'grab'; + } +})(focusedShape, draggedPrimaryShape); + +// this is the core scenegraph update invocation: upon new cursor position etc. emit the new scenegraph +// it's _the_ state representation (at a PoC level...) comprising of transient properties eg. draggedShape, and the +// collection of shapes themselves +const nextScene = select( + ( + hoveredShape, + selectedShapes, + selectedPrimaryShapes, + shapes, + gestureEnd, + draggedShape, + cursor + ) => { + return { + hoveredShape, + selectedShapes, + selectedPrimaryShapes, + shapes, + gestureEnd, + draggedShape, + cursor, + }; + } +)( + hoveredShape, + selectedShapeIds, + selectedPrimaryShapeIds, + globalTransformShapes, + gestureEnd, + draggedShape, + cursor +); + +module.exports = { + cursorPosition, + mouseIsDown, + dragVector, + nextScene, + focusedShape, + primaryUpdate, + shapes, + focusedShapes, + selectedShapes: selectedShapeIds, +}; + +/** + * General inputs to behaviors: + * + * 1. Mode: the mode the user is in. For example, clicking on a shape in 'edit' mode does something different (eg. highlight + * activation hotspots or show the object in a configuration tab) than in 'presentation' mode (eg. jump to a link, or just + * nothing). This is just an example and it can be a lot more granular, eg. a 2D vs 3D mode; perspective vs isometric; + * shape being translated vs resized vs whatever. Multiple modes can apply simultaneously. Modes themselves may have + * structure: simple, binary or multistate modes at a flat level; ring-like; tree etc. or some mix. Modes are generally + * not a good thing, so we should use it sparingly (see Bret Victor's reference to NOMODES as one of his examples in + * Inventing on Principle) + * + * 2. Focus: there's some notion of what the behaviors act on, for example, a shape we hover over or select; multiple + * shapes we select or lasso; or members of a group (direct descendants, or all descendants, or only all leafs). The + * focus can be implied, eg. act on whatever's currently in view. It can also arise hierarchical: eg. move shapes within + * a specific 'project' (normal way of working things, like editing one specific text file), or highlighting multiple + * shapes with a lasso within a previously focused group. There can be effects (color highlighting, autozooming etc.) that + * show what is currently in focus, as the user's mental model and the computer's notion of focus must go hand in hand. + * + * 3. Gesture: a primitive action that's raw input. Eg. moving the mouse a bit, clicking, holding down a modifier key or + * hitting a key. This is how the user acts on the scene. Can be for direct manipulation (eg. drag or resize) or it can + * be very modal (eg. a key acting in a specific mode, or a key or other gesture that triggers a new mode or cancels a + * preexisting mode). Gestures may be compose simultaneously (eg. clicking while holding down a modifier key) and/or + * temporally (eg. grab, drag, release). Ie. composition and finite state machine. But these could (should?) be modeled + * via submerging into specific modes. For example, grabbing an object and starting to move the mouse may induce the + * 'drag' mode (within whatever mode we're already in). Combining modes, foci and gestures give us the typical design + * software toolbars, menus, palettes. For example, clicking (gesture) on the pencil icon (focus, as we're above it) will + * put us in the freehand drawing mode. + * + * 4. External variables: can be time, or a sequence of things triggered by time (eg. animation, alerting, data fetch...) + * or random data (for simulation) or a new piece of data from the server (in the case of collaborative editing) + * + * 5. Memory: undo/redo, repeat action, keyboard macros and time travel require that successive states or actions be recorded + * so they're recoverable later. Sometimes the challenge is in determining what the right level is. For example, should + * `undo` undo the last letter typed, or a larger transaction (eg. filling a field), or something in between, eg. regroup + * the actions and delete the lastly entered word sentence. Also, in macro recording, is actual mouse movement used, or + * something arising from it, eg. the selection on an object? + * + * Action: actions are granular, discrete pieces of progress along some user intent. Actions are not primary, except + * gestures. They arise from the above primary inputs. They can be hierarchical in that a series of actions (eg. + * selecting multiple shapes and hitting `Group`) leads to the higher level action of "group all these elements". + * + * All these are input to how we deduce _user intent_, therefore _action_. There can be a whirl of these things leading to + * higher levels, eg. click (gesture) over an icon (focus) puts us in a new mode, which then alters what specific gestures, + * modes and foci are possible; it can be an arbitrary graph. Let's try to characterize this graph... + * + */ + +/** + * Selections + * + * On first sight, selection is simple. The user clicks on an Element, and thus the Element becomes selected; any previous + * selection is cleared. If the user clicks anywhere else on the Canvas, the selection goes away. + * + * There are however wrinkles so large, they dwarf the original shape of the cloth: + * + * 1. Selecting occluded items + * a. by sequentially meta+clicking at a location + * b. via some other means, eg. some modal or non-modal popup box listing the elements underneath one another + * 2. Selecting multiple items + * a. by option-clicking + * b. by rectangle selection or lasso selection, with requirement for point / line / area / volume touching an element + * c. by rectangle selection or lasso selection, with requirement for point / line / area / volume fully including an element + * d. select all elements of a group + * 3. How to combine occluded item selection with multiple item selection? + * a. separate the notion of vertical cycling and selection (naive, otoh known by user, implementations conflate them) + * b. resort to the dialog or form selection (multiple ticks) + * c. volume aware selection + * 4. Group related select + * a. select a group by its leaf node and drag the whole group with it + * b. select an element of a group and only move that (within the group) + * c. hierarchy aware select: eg. select all leaf nodes of a group at any level + * 5. Composite selections (generalization of selecting multiple items) + * a. additive selections: eg. multiple rectangular brushes + * b. subtractive selection: eg. selecting all but a few elements of a group + * 6. Annotation selection. Modeling controls eg. resize and rotate hotspots as annotations is useful because the + * display and interaction often goes hand in hand. In other words, a passive legend is but a special case of + * an active affordance: it just isn't interactive (noop). Also, annotations are useful to model as shapes + * because: + * a. they're part of the scenegraph + * b. hierarchical relations can be exploited, eg. a leaf shape or a group may have annotation that's locally + * positionable (eg. resize or rotate hotspots) + * c. the transform/projection math, and often, other facilities (eg. drag) can be shared (DRY) + * The complications are: + * a. clicking on and dragging a rotate handle shouldn't do the full selection, ie. it shouldn't get + * a 'selected' border, and the rotate handle shouldn't get a rotate handle of its own, recursively :-) + * b. clicking on a rotation handle, which is outside the element, should preserve the selected state of + * the element + * c. tbc + */ diff --git a/x-pack/plugins/canvas/public/lib/aeroelastic/matrix.js b/x-pack/plugins/canvas/public/lib/aeroelastic/matrix.js new file mode 100644 index 0000000000000..3760f4fbf906f --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/aeroelastic/matrix.js @@ -0,0 +1,342 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/** + * transpose + * + * Turns a row major ordered vector representation of a 4 x 4 matrix into a column major ordered vector representation, or + * the other way around. + * + * Must pass a row major ordered vector if the goal is to obtain a column major ordered vector. + * + * We're using row major order in the _source code_ as this results in the correct visual shape of the matrix, but + * `transform3d` needs column major order. + * + * This is what the matrix is: Eg. this is the equivalent matrix of `translate3d(${x}px, ${y}px, ${z}px)`: + * + * a e i m 1 0 0 x + * b f j n 0 1 0 y + * c g k o 0 0 1 z + * d h l p 0 0 0 1 + * + * but it's _not_ represented as a 2D array or array of arrays. CSS3 `transform3d` expects it as this vector: + * + * [a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p] + * + * so it's clear that the first _column vector_ corresponds to a, b, c, d but in source code, we must write a, e, i, m in + * the first row if we want to visually resemble the above 4x4 matrix, ie. if we don't want that us programmers transpose + * matrices in our heads. + * + */ +const transpose = ([a, e, i, m, b, f, j, n, c, g, k, o, d, h, l, p]) => [ + a, + b, + c, + d, + e, + f, + g, + h, + i, + j, + k, + l, + m, + n, + o, + p, +]; + +const ORIGIN = [0, 0, 0, 1]; + +const NULLVECTOR = [0, 0, 0, 0]; + +const NULLMATRIX = transpose([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + +const UNITMATRIX = transpose([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]); + +// currently these functions expensively transpose; in a future version we can have way more efficient matrix operations +// (eg. pre-transpose) +const translate = (x, y, z) => transpose([1, 0, 0, x, 0, 1, 0, y, 0, 0, 1, z, 0, 0, 0, 1]); + +const scale = (x, y, z) => transpose([x, 0, 0, 0, 0, y, 0, 0, 0, 0, z, 0, 0, 0, 0, 1]); + +const shear = (x, y) => transpose([1, x, 0, 0, y, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]); + +const perspective = d => transpose([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, -1 / d, 1]); + +/** + * rotate + * + * @param {number} x the x coordinate of the vector around which to rotate + * @param {number} y the y coordinate of the vector around which to rotate + * @param {number} z the z coordinate of the vector around which to rotate + * @param {number} a rotation angle in radians + * @returns {number[][]} a 4x4 transform matrix in column major order + */ +const rotate = (x, y, z, a) => { + // it looks like the formula but inefficient; common terms could be precomputed, transpose can be avoided. + // an optimizing compiler eg. Google Closure Advanced could perform most of the optimizations and JIT also watches out + // for eg. common expressions + + const sinA = Math.sin(a); + const coshAi = 1 - Math.cos(a); + + return transpose([ + 1 + coshAi * (x * x - 1), + z * sinA + x * y * coshAi, + -y * sinA + x * y * coshAi, + 0, + -z * sinA + x * y * coshAi, + 1 + coshAi * (y * y - 1), + x * sinA + y * x * coshAi, + 0, + y * sinA + x * z * coshAi, + -x * sinA + y * z * coshAi, + 1 + coshAi * (z * z - 1), + 0, + 0, + 0, + 0, + 1, + ]); +}; + +/** + * rotate_ functions + * + * @param {number} a + * @returns {number[][]} + * + * Should be replaced with more efficient direct versions rather than going through the generic `rotate3d` function. + */ +const rotateX = a => rotate(1, 0, 0, a); +const rotateY = a => rotate(0, 1, 0, a); +const rotateZ = a => rotate(0, 0, 1, a); + +/** + * multiply + * + * Matrix multiplies two matrices of column major format, returning the result in the same format + * + * + * A E I M + * B F J N + * C G K O + * D H L P + * + * a e i m . . . . + * b f j n . . . . + * c g k o . . . . + * d h l p . . . d * M + h * N + l * O + p * P + * + */ +const mult = ( + [a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p], + [A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P] +) => [ + a * A + e * B + i * C + m * D, + b * A + f * B + j * C + n * D, + c * A + g * B + k * C + o * D, + d * A + h * B + l * C + p * D, + + a * E + e * F + i * G + m * H, + b * E + f * F + j * G + n * H, + c * E + g * F + k * G + o * H, + d * E + h * F + l * G + p * H, + + a * I + e * J + i * K + m * L, + b * I + f * J + j * K + n * L, + c * I + g * J + k * K + o * L, + d * I + h * J + l * K + p * L, + + a * M + e * N + i * O + m * P, + b * M + f * N + j * O + n * P, + c * M + g * N + k * O + o * P, + d * M + h * N + l * O + p * P, +]; + +const multiply = (...elements) => + elements.slice(1).reduce((prev, next) => mult(prev, next), elements[0]); + +/** + * mvMultiply + * + * Multiplies a matrix and a vector + * + * + * A + * B + * C + * D + * + * a e i m . + * b f j n . + * c g k o . + * d h l p d * A + h * B + l * C + p * D + * + */ +const mvMultiply = ([a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p], [A, B, C, D]) => [ + a * A + e * B + i * C + m * D, + b * A + f * B + j * C + n * D, + c * A + g * B + k * C + o * D, + d * A + h * B + l * C + p * D, +]; + +const normalize = ([A, B, C, D]) => (D === 1 ? [A, B, C, D] : [A / D, B / D, C / D, 1]); + +/** + * invert + * + * Inverts the matrix + * + * a e i m + * b f j n + * c g k o + * d h l p + */ +const invert = ([a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p]) => { + const inv = [ + f * k * p - f * l * o - j * g * p + j * h * o + n * g * l - n * h * k, + -b * k * p + b * l * o + j * c * p - j * d * o - n * c * l + n * d * k, + b * g * p - b * h * o - f * c * p + f * d * o + n * c * h - n * d * g, + -b * g * l + b * h * k + f * c * l - f * d * k - j * c * h + j * d * g, + -e * k * p + e * l * o + i * g * p - i * h * o - m * g * l + m * h * k, + a * k * p - a * l * o - i * c * p + i * d * o + m * c * l - m * d * k, + -a * g * p + a * h * o + e * c * p - e * d * o - m * c * h + m * d * g, + a * g * l - a * h * k - e * c * l + e * d * k + i * c * h - i * d * g, + e * j * p - e * l * n - i * f * p + i * h * n + m * f * l - m * h * j, + -a * j * p + a * l * n + i * b * p - i * d * n - m * b * l + m * d * j, + a * f * p - a * h * n - e * b * p + e * d * n + m * b * h - m * d * f, + -a * f * l + a * h * j + e * b * l - e * d * j - i * b * h + i * d * f, + -e * j * o + e * k * n + i * f * o - i * g * n - m * f * k + m * g * j, + a * j * o - a * k * n - i * b * o + i * c * n + m * b * k - m * c * j, + -a * f * o + a * g * n + e * b * o - e * c * n - m * b * g + m * c * f, + a * f * k - a * g * j - e * b * k + e * c * j + i * b * g - i * c * f, + ]; + + const det = a * inv[0] + b * inv[4] + c * inv[8] + d * inv[12]; + + if (det === 0) { + return false; // no solution + } else { + const recDet = 1 / det; + + for (let index = 0; index < 16; index++) inv[index] *= recDet; + + return inv; + } +}; + +const translateComponent = a => [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, a[12], a[13], a[14], a[15]]; + +const compositeComponent = ([a, b, c, d, e, f, g, h, i, j, k, l]) => [ + a, + b, + c, + d, + e, + f, + g, + h, + i, + j, + k, + l, + 0, + 0, + 0, + 1, +]; + +const add = ( + [a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p], + [A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P] +) => [ + a + A, + b + B, + c + C, + d + D, + e + E, + f + F, + g + G, + h + H, + i + I, + j + J, + k + K, + l + L, + m + M, + n + N, + o + O, + p + P, +]; + +const subtract = ( + [a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p], + [A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P] +) => [ + a - A, + b - B, + c - C, + d - D, + e - E, + f - F, + g - G, + h - H, + i - I, + j - J, + k - K, + l - L, + m - M, + n - N, + o - O, + p - P, +]; + +const reduceTransforms = transforms => + transforms.length === 1 + ? transforms[0] + : transforms.slice(1).reduce((prev, next) => multiply(prev, next), transforms[0]); + +// applies an arbitrary number of transforms - left to right - to a preexisting transform matrix +const applyTransforms = (transforms, previousTransformMatrix) => + transforms.reduce((prev, next) => multiply(prev, next), previousTransformMatrix); + +const clamp = (low, high, value) => Math.min(high, Math.max(low, value)); + +// todo turn it into returning radians rather than degrees +const matrixToAngle = transformMatrix => { + // clamping is needed, otherwise inevitable floating point inaccuracies can cause NaN + const z0 = (Math.acos(clamp(-1, 1, transformMatrix[0])) * 180) / Math.PI; + const z1 = (Math.asin(clamp(-1, 1, transformMatrix[1])) * 180) / Math.PI; + return z1 > 0 ? z0 : -z0; +}; + +module.exports = { + ORIGIN, + NULLVECTOR, + NULLMATRIX, + UNITMATRIX, + transpose, + translate, + shear, + rotateX, + rotateY, + rotateZ, + scale, + perspective, + matrixToAngle, + multiply, + mvMultiply, + invert, + normalize, + applyTransforms, + reduceTransforms, + translateComponent, + compositeComponent, + add, + subtract, +}; diff --git a/x-pack/plugins/canvas/public/lib/aeroelastic/matrix2d.js b/x-pack/plugins/canvas/public/lib/aeroelastic/matrix2d.js new file mode 100644 index 0000000000000..0d75640125c66 --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/aeroelastic/matrix2d.js @@ -0,0 +1,159 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/** + * transpose + * + * Turns a row major ordered vector representation of a 4 x 4 matrix into a column major ordered vector representation, or + * the other way around. + * + * Must pass a row major ordered vector if the goal is to obtain a column major ordered vector. + * + * We're using row major order in the _source code_ as this results in the correct visual shape of the matrix, but + * `transform3d` needs column major order. + * + * This is what the matrix is: Eg. this is the equivalent matrix of `translate(${x}px, ${y}px)`: + * + * a d g 1 0 x + * b e h 0 1 y + * c f i 0 0 1 + * + * but it's _not_ represented as a 2D array or array of arrays. + * + * [a, b, c, d, e, f, g, h, i] + * + */ +const transpose = ([a, d, g, b, e, h, c, f, i]) => [a, b, c, d, e, f, g, h, i]; + +const ORIGIN = [0, 0, 1]; + +const NULLVECTOR = [0, 0, 0]; + +const NULLMATRIX = transpose([0, 0, 0, 0, 0, 0, 0, 0, 0]); + +const UNITMATRIX = transpose([1, 0, 0, 0, 1, 0, 0, 0, 1]); + +// currently these functions expensively transpose; in a future version we can have way more efficient matrix operations +// (eg. pre-transpose) +const translate = (x, y) => transpose([1, 0, x, 0, 1, y, 0, 0, 1]); + +const scale = (x, y) => transpose([x, 0, 0, 0, y, 0, 0, 0, 1]); + +const shear = (x, y) => transpose([1, x, 0, y, 1, 0, 0, 0, 1]); + +/** + * multiply + * + * Matrix multiplies two matrices of column major format, returning the result in the same format + * + * + * A D G + * B E H + * C F I + * + * a d g . . . + * b e h . . . + * c f i . . c * G + f * H + i * I + * + */ +const mult = ([a, b, c, d, e, f, g, h, i], [A, B, C, D, E, F, G, H, I]) => [ + a * A + d * B + g * C, + b * A + e * B + h * C, + c * A + f * B + i * C, + + a * D + d * E + g * F, + b * D + e * E + h * F, + c * D + f * E + i * F, + + a * G + d * H + g * I, + b * G + e * H + h * I, + c * G + f * H + i * I, +]; + +const multiply = (...elements) => + elements.slice(1).reduce((prev, next) => mult(prev, next), elements[0]); + +/** + * mvMultiply + * + * Multiplies a matrix and a vector + * + * + * A + * B + * C + * + * a d g . + * b e h . + * c f i c * A + f * B + i * C + * + */ +const mvMultiply = ([a, b, c, d, e, f, g, h, i], [A, B, C]) => [ + a * A + d * B + g * C, + b * A + e * B + h * C, + c * A + f * B + i * C, +]; + +const normalize = ([A, B, C]) => (C === 1 ? [A, B, C] : [A / C, B / C, 1]); + +const add = ([a, b, c, d, e, f, g, h, i], [A, B, C, D, E, F, G, H, I]) => [ + a + A, + b + B, + c + C, + d + D, + e + E, + f + F, + g + G, + h + H, + i + I, +]; + +const subtract = ([a, b, c, d, e, f, g, h, i], [A, B, C, D, E, F, G, H, I]) => [ + a - A, + b - B, + c - C, + d - D, + e - E, + f - F, + g - G, + h - H, + i - I, +]; + +const reduceTransforms = transforms => + transforms.length === 1 + ? transforms[0] + : transforms.slice(1).reduce((prev, next) => multiply(prev, next), transforms[0]); + +// applies an arbitrary number of transforms - left to right - to a preexisting transform matrix +const applyTransforms = (transforms, previousTransformMatrix) => + transforms.reduce((prev, next) => multiply(prev, next), previousTransformMatrix); + +/** + * + * componentProduct + * + */ +const componentProduct = ([a, b, c], [A, B, C]) => [a * A, b * B, c * C]; + +module.exports = { + ORIGIN, + NULLVECTOR, + NULLMATRIX, + UNITMATRIX, + transpose, + translate, + shear, + scale, + multiply, + mvMultiply, + normalize, + applyTransforms, + reduceTransforms, + add, + subtract, + componentProduct, +}; diff --git a/x-pack/plugins/canvas/public/lib/aeroelastic/state.js b/x-pack/plugins/canvas/public/lib/aeroelastic/state.js new file mode 100644 index 0000000000000..faefcc9e99c5c --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/aeroelastic/state.js @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +const { shallowEqual } = require('./functional'); + +/** + * PoC action dispatch + */ + +const makeUid = () => 1e11 + Math.floor((1e12 - 1e11) * Math.random()); + +const selectReduce = (fun, previousValue, mapFun = d => d, logFun) => (...inputs) => { + // last-value memoizing version of this single line function: + // (fun, previousValue) => (...inputs) => state => previousValue = fun(previousValue, ...inputs.map(input => input(state))) + let argumentValues = []; + let value = previousValue; + let prevValue = previousValue; + let mappedValue; + return state => { + if ( + shallowEqual(argumentValues, (argumentValues = inputs.map(input => input(state)))) && + value === prevValue + ) + return mappedValue; + + prevValue = value; + value = fun(prevValue, ...argumentValues); + if (logFun) logFun(value, argumentValues); + mappedValue = mapFun(value); + return mappedValue; + }; +}; + +const select = (fun, logFun) => (...inputs) => { + // last-value memoizing version of this single line function: + // fun => (...inputs) => state => fun(...inputs.map(input => input(state))) + let argumentValues = []; + let value; + let actionId; + return state => { + const lastActionId = state.primaryUpdate.payload.uid; + if ( + actionId === lastActionId || + shallowEqual(argumentValues, (argumentValues = inputs.map(input => input(state)))) + ) + return value; + + value = fun(...argumentValues); + actionId = lastActionId; + if (logFun) logFun(value, argumentValues); + return value; + }; +}; + +const createStore = (initialState, onChangeCallback = () => {}) => { + let currentState = initialState; + let updater = state => state; // default: no side effect + const getCurrentState = () => currentState; + // const setCurrentState = newState => (currentState = newState); + const setUpdater = updaterFunction => (updater = updaterFunction); + + const commit = (type, payload, meta = {}) => { + currentState = updater({ + ...currentState, + primaryUpdate: { + type, + payload: { ...payload, uid: makeUid() }, + }, + }); + if (!meta.silent) onChangeCallback({ type, state: currentState }, meta); + }; + + const dispatch = (type, payload) => setTimeout(() => commit(type, payload)); + + return { getCurrentState, setUpdater, commit, dispatch }; +}; + +module.exports = { + createStore, + select, + selectReduce, +}; diff --git a/x-pack/plugins/canvas/public/lib/aeroelastic_kibana.js b/x-pack/plugins/canvas/public/lib/aeroelastic_kibana.js new file mode 100644 index 0000000000000..9e00538ab37d8 --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/aeroelastic_kibana.js @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import aero from './aeroelastic'; + +const stores = new Map(); + +export const aeroelastic = { + matrix: aero.matrix, + + clearStores() { + stores.clear(); + }, + + createStore(initialState, onChangeCallback = () => {}, page) { + if (stores.has(page)) throw new Error('Only a single aeroelastic store per page should exist'); + + stores.set(page, aero.state.createStore(initialState, onChangeCallback)); + + const updateScene = aero.state.select((nextScene, primaryUpdate) => ({ + shapeAdditions: nextScene.shapes, + primaryUpdate, + currentScene: nextScene, + }))(aero.layout.nextScene, aero.layout.primaryUpdate); + + stores.get(page).setUpdater(updateScene); + }, + + removeStore(page) { + if (stores.has(page)) stores.delete(page); + }, + + getStore(page) { + const store = stores.get(page); + if (!store) throw new Error('An aeroelastic store should exist for page ' + page); + + return store.getCurrentState(); + }, + + commit(page, ...args) { + const store = stores.get(page); + return store && store.commit(...args); + }, +}; diff --git a/x-pack/plugins/canvas/public/lib/arg_helpers.js b/x-pack/plugins/canvas/public/lib/arg_helpers.js new file mode 100644 index 0000000000000..e53e26b62dd15 --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/arg_helpers.js @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { includes } from 'lodash'; +import { getType } from '../../common/lib/get_type'; + +/* + + IMPORTANT: These only work with simple values, eg string, number, boolean. + Getting or setting anything else will throw. + +*/ + +// TODO: With the removal of objectified literals in the AST I don't think we need this anymore. + +const allowedTypes = ['string', 'number', 'boolean']; +const badType = () => new Error(`Arg setting helpers only support ${allowedTypes.join(',')}`); + +const isAllowed = type => includes(allowedTypes, type); + +export function validateArg(value) { + const type = getType(value); + if (!isAllowed(type)) throw badType(); + return value; +} + +export function getSimpleArg(name, args) { + if (!args[name]) return []; + return args[name].map(astVal => { + if (!isAllowed(getType(astVal))) throw badType(); + return astVal; + }); +} + +export function setSimpleArg(name, value) { + value = Array.isArray(value) ? value : [value]; + return { [name]: value.map(validateArg) }; +} diff --git a/x-pack/plugins/canvas/public/lib/create_handlers.js b/x-pack/plugins/canvas/public/lib/create_handlers.js new file mode 100644 index 0000000000000..93247210eb291 --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/create_handlers.js @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export function createHandlers(/*socket*/) { + return { + environment: 'client', + }; +} diff --git a/x-pack/plugins/canvas/public/lib/default_header.png b/x-pack/plugins/canvas/public/lib/default_header.png new file mode 100644 index 0000000000000..0b5c5b8f58f9b Binary files /dev/null and b/x-pack/plugins/canvas/public/lib/default_header.png differ diff --git a/x-pack/plugins/canvas/public/lib/elastic_logo.js b/x-pack/plugins/canvas/public/lib/elastic_logo.js new file mode 100644 index 0000000000000..1ade7f1f269c0 --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/elastic_logo.js @@ -0,0 +1,2 @@ +/* eslint-disable */ +export const elasticLogo = 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgeG1sbnM6ZGM9Imh0dHA6Ly9wdXJsLm9yZy9kYy9lbGVtZW50cy8xLjEvIgogICB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIgogICB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiCiAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgdmlld0JveD0iMCAwIDI3MC42MDAwMSAyNjkuNTQ2NjYiCiAgIGhlaWdodD0iMjY5LjU0NjY2IgogICB3aWR0aD0iMjcwLjYwMDAxIgogICB4bWw6c3BhY2U9InByZXNlcnZlIgogICBpZD0ic3ZnMiIKICAgdmVyc2lvbj0iMS4xIj48bWV0YWRhdGEKICAgICBpZD0ibWV0YWRhdGE4Ij48cmRmOlJERj48Y2M6V29yawogICAgICAgICByZGY6YWJvdXQ9IiI+PGRjOmZvcm1hdD5pbWFnZS9zdmcreG1sPC9kYzpmb3JtYXQ+PGRjOnR5cGUKICAgICAgICAgICByZGY6cmVzb3VyY2U9Imh0dHA6Ly9wdXJsLm9yZy9kYy9kY21pdHlwZS9TdGlsbEltYWdlIiAvPjwvY2M6V29yaz48L3JkZjpSREY+PC9tZXRhZGF0YT48ZGVmcwogICAgIGlkPSJkZWZzNiIgLz48ZwogICAgIHRyYW5zZm9ybT0ibWF0cml4KDEuMzMzMzMzMywwLDAsLTEuMzMzMzMzMywwLDI2OS41NDY2NykiCiAgICAgaWQ9ImcxMCI+PGcKICAgICAgIHRyYW5zZm9ybT0ic2NhbGUoMC4xKSIKICAgICAgIGlkPSJnMTIiPjxwYXRoCiAgICAgICAgIGlkPSJwYXRoMTQiCiAgICAgICAgIHN0eWxlPSJmaWxsOiNmZmZmZmY7ZmlsbC1vcGFjaXR5OjE7ZmlsbC1ydWxlOm5vbnplcm87c3Ryb2tlOm5vbmUiCiAgICAgICAgIGQ9Im0gMjAyOS40OCw5NjIuNDQxIGMgMCwxNzAuMDk5IC0xMDUuNDYsMzE4Ljc5OSAtMjY0LjE3LDM3Ni42NTkgNi45OCwzNS44NiAxMC42Miw3MS43MSAxMC42MiwxMDkuMDUgMCwzMTYuMTkgLTI1Ny4yNCw1NzMuNDMgLTU3My40Nyw1NzMuNDMgLTE4NC43MiwwIC0zNTYuNTU4LC04OC41OSAtNDY0LjUzLC0yMzcuODUgLTUzLjA5LDQxLjE4IC0xMTguMjg1LDYzLjc1IC0xODYuMzA1LDYzLjc1IC0xNjcuODM2LDAgLTMwNC4zODMsLTEzNi41NCAtMzA0LjM4MywtMzA0LjM4IDAsLTM3LjA4IDYuNjE3LC03Mi41OCAxOS4wMzEsLTEwNi4wOCBDIDEwOC40ODgsMTM4MC4wOSAwLDEyMjcuODkgMCwxMDU4Ljg4IDAsODg3LjkxIDEwNS45NzcsNzM4LjUzOSAyNjUuMzk4LDY4MS4wOSBjIC02Ljc2OSwtMzUuNDQyIC0xMC40NiwtNzIuMDIgLTEwLjQ2LC0xMDkgQyAyNTQuOTM4LDI1Ni42MjEgNTExLjU2NiwwIDgyNy4wMjcsMCAxMDEyLjIsMCAxMTgzLjk0LDg4Ljk0MTQgMTI5MS4zLDIzOC44MzIgYyA1My40NSwtNDEuOTYxIDExOC44LC02NC45OTIgMTg2LjU2LC02NC45OTIgMTY3LjgzLDAgMzA0LjM4LDEzNi40OTIgMzA0LjM4LDMwNC4zMzIgMCwzNy4wNzggLTYuNjIsNzIuNjI5IC0xOS4wMywxMDYuMTI5IDE1Ny43OCw1Ni44NzkgMjY2LjI3LDIwOS4xMjkgMjY2LjI3LDM3OC4xNCIgLz48cGF0aAogICAgICAgICBpZD0icGF0aDE2IgogICAgICAgICBzdHlsZT0iZmlsbDojZmFjZjA5O2ZpbGwtb3BhY2l0eToxO2ZpbGwtcnVsZTpub256ZXJvO3N0cm9rZTpub25lIgogICAgICAgICBkPSJtIDc5Ny44OTgsMTE1MC45MyA0NDQuMDcyLC0yMDIuNDUgNDQ4LjA1LDM5Mi41OCBjIDYuNDksMzIuMzkgOS42Niw2NC42NyA5LjY2LDk4LjQ2IDAsMjc2LjIzIC0yMjQuNjgsNTAwLjk1IC01MDAuOSw1MDAuOTUgLTE2NS4yNCwwIC0zMTkuMzcsLTgxLjM2IC00MTMuMDUzLC0yMTcuNzkgbCAtNzQuNTI0LC0zODYuNjQgODYuNjk1LC0xODUuMTEiIC8+PHBhdGgKICAgICAgICAgaWQ9InBhdGgxOCIKICAgICAgICAgc3R5bGU9ImZpbGw6IzQ5YzFhZTtmaWxsLW9wYWNpdHk6MTtmaWxsLXJ1bGU6bm9uemVybztzdHJva2U6bm9uZSIKICAgICAgICAgZD0ibSAzMzguMjIzLDY4MC42NzIgYyAtNi40ODksLTMyLjM4MyAtOS44MDksLTY1Ljk4MSAtOS44MDksLTk5Ljk3MyAwLC0yNzYuOTI5IDIyNS4zMzYsLTUwMi4yNTc2IDUwMi4zMTMsLTUwMi4yNTc2IDE2Ni41OTMsMCAzMjEuNDczLDgyLjExNzYgNDE1LjAxMywyMTkuOTQ5NiBsIDczLjk3LDM4NS4zNDcgLTk4LjcyLDE4OC42MjEgTCA3NzUuMTU2LDEwNzUuNTcgMzM4LjIyMyw2ODAuNjcyIiAvPjxwYXRoCiAgICAgICAgIGlkPSJwYXRoMjAiCiAgICAgICAgIHN0eWxlPSJmaWxsOiNlZjI5OWI7ZmlsbC1vcGFjaXR5OjE7ZmlsbC1ydWxlOm5vbnplcm87c3Ryb2tlOm5vbmUiCiAgICAgICAgIGQ9Im0gMzM1LjQxLDE0NDkuMTggMzA0LjMzMiwtNzEuODYgNjYuNjgsMzQ2LjAyIGMgLTQxLjU4NiwzMS43OCAtOTIuOTMsNDkuMTggLTE0NS43MzEsNDkuMTggLTEzMi4yNSwwIC0yMzkuODEyLC0xMDcuNjEgLTIzOS44MTIsLTIzOS44NyAwLC0yOS4yMSA0Ljg3OSwtNTcuMjIgMTQuNTMxLC04My40NyIgLz48cGF0aAogICAgICAgICBpZD0icGF0aDIyIgogICAgICAgICBzdHlsZT0iZmlsbDojNGNhYmU0O2ZpbGwtb3BhY2l0eToxO2ZpbGwtcnVsZTpub256ZXJvO3N0cm9rZTpub25lIgogICAgICAgICBkPSJNIDMwOC45OTIsMTM3Ni43IEMgMTczLjAyLDEzMzEuNjQgNzguNDgwNSwxMjAxLjMgNzguNDgwNSwxMDU3LjkzIDc4LjQ4MDUsOTE4LjM0IDE2NC44Miw3OTMuNjggMjk0LjQwNiw3NDQuMzUyIGwgNDI2Ljk4MSwzODUuOTM4IC03OC4zOTUsMTY3LjUxIC0zMzQsNzguOSIgLz48cGF0aAogICAgICAgICBpZD0icGF0aDI0IgogICAgICAgICBzdHlsZT0iZmlsbDojODVjZTI2O2ZpbGwtb3BhY2l0eToxO2ZpbGwtcnVsZTpub256ZXJvO3N0cm9rZTpub25lIgogICAgICAgICBkPSJtIDEzMjMuOCwyOTguNDEgYyA0MS43NCwtMzIuMDkgOTIuODMsLTQ5LjU5IDE0NC45OCwtNDkuNTkgMTMyLjI1LDAgMjM5LjgxLDEwNy41NTkgMjM5LjgxLDIzOS44MjEgMCwyOS4xNiAtNC44OCw1Ny4xNjggLTE0LjUzLDgzLjQxOCBsIC0zMDQuMDgsNzEuMTYgLTY2LjE4LC0zNDQuODA5IiAvPjxwYXRoCiAgICAgICAgIGlkPSJwYXRoMjYiCiAgICAgICAgIHN0eWxlPSJmaWxsOiMzMTc3YTc7ZmlsbC1vcGFjaXR5OjE7ZmlsbC1ydWxlOm5vbnplcm87c3Ryb2tlOm5vbmUiCiAgICAgICAgIGQ9Im0gMTM4NS42Nyw3MjIuOTMgMzM0Ljc2LC03OC4zMDEgYyAxMzYuMDIsNDQuOTYxIDIzMC41NiwxNzUuMzUxIDIzMC41NiwzMTguNzYyIDAsMTM5LjMzOSAtODYuNTQsMjYzLjg1OSAtMjE2LjM4LDMxMy4wMzkgbCAtNDM3Ljg0LC0zODMuNTkgODguOSwtMTY5LjkxIiAvPjwvZz48L2c+PC9zdmc+'; diff --git a/x-pack/plugins/canvas/public/lib/elastic_outline.js b/x-pack/plugins/canvas/public/lib/elastic_outline.js new file mode 100644 index 0000000000000..7271f5b32d547 --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/elastic_outline.js @@ -0,0 +1,2 @@ +/* eslint-disable */ +export const elasticOutline = 'data:image/svg+xml,%3C%3Fxml%20version%3D%221.0%22%20encoding%3D%22utf-8%22%3F%3E%0A%3Csvg%20viewBox%3D%22-3.948730230331421%20-1.7549896240234375%20245.25946044921875%20241.40370178222656%22%20width%3D%22245.25946044921875%22%20height%3D%22241.40370178222656%22%20style%3D%22enable-background%3Anew%200%200%20686.2%20235.7%3B%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%3Cdefs%3E%0A%20%20%20%20%3Cstyle%20type%3D%22text%2Fcss%22%3E%0A%09.st0%7Bfill%3A%232D2D2D%3B%7D%0A%3C%2Fstyle%3E%0A%20%20%3C%2Fdefs%3E%0A%20%20%3Cg%20transform%3D%22matrix%281%2C%200%2C%200%2C%201%2C%200%2C%200%29%22%3E%0A%20%20%20%20%3Cg%3E%0A%20%20%20%20%20%20%3Cpath%20class%3D%22st0%22%20d%3D%22M329.4%2C160.3l4.7-0.5l0.3%2C9.6c-12.4%2C1.7-23%2C2.6-31.8%2C2.6c-11.7%2C0-20-3.4-24.9-10.2%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bc-4.9-6.8-7.3-17.4-7.3-31.7c0-28.6%2C11.4-42.9%2C34.1-42.9c11%2C0%2C19.2%2C3.1%2C24.6%2C9.2c5.4%2C6.1%2C8.1%2C15.8%2C8.1%2C28.9l-0.7%2C9.3h-53.8%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bc0%2C9%2C1.6%2C15.7%2C4.9%2C20c3.3%2C4.3%2C8.9%2C6.5%2C17%2C6.5C312.8%2C161.2%2C321.1%2C160.9%2C329.4%2C160.3z%20M325%2C124.9c0-10-1.6-17.1-4.8-21.2%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bc-3.2-4.1-8.4-6.2-15.6-6.2c-7.2%2C0-12.7%2C2.2-16.3%2C6.5c-3.6%2C4.3-5.5%2C11.3-5.6%2C20.9H325z%22%2F%3E%0A%20%20%20%20%20%20%3Cpath%20class%3D%22st0%22%20d%3D%22M354.3%2C171.4V64h12.2v107.4H354.3z%22%2F%3E%0A%20%20%20%20%20%20%3Cpath%20class%3D%22st0%22%20d%3D%22M443.5%2C113.5v41.1c0%2C4.1%2C10.1%2C3.9%2C10.1%2C3.9l-0.6%2C10.8c-8.6%2C0-15.7%2C0.7-20-3.4c-9.8%2C4.3-19.5%2C6.1-29.3%2C6.1%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bc-7.5%2C0-13.2-2.1-17.1-6.4c-3.9-4.2-5.9-10.3-5.9-18.3c0-7.9%2C2-13.8%2C6-17.5c4-3.7%2C10.3-6.1%2C18.9-6.9l25.6-2.4v-7%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bc0-5.5-1.2-9.5-3.6-11.9c-2.4-2.4-5.7-3.6-9.8-3.6l-32.1%2C0V87.2h31.3c9.2%2C0%2C15.9%2C2.1%2C20.1%2C6.4C441.4%2C97.8%2C443.5%2C104.5%2C443.5%2C113.5%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bz%20M393.3%2C146.7c0%2C10%2C4.1%2C15%2C12.4%2C15c7.4%2C0%2C14.7-1.2%2C21.8-3.7l3.7-1.3v-26.9l-24.1%2C2.3c-4.9%2C0.4-8.4%2C1.8-10.6%2C4.2%26%2310%3B%26%239%3B%26%239%3B%26%239%3BC394.4%2C138.7%2C393.3%2C142.2%2C393.3%2C146.7z%22%2F%3E%0A%20%20%20%20%20%20%3Cpath%20class%3D%22st0%22%20d%3D%22M491.2%2C98.2c-11.8%2C0-17.8%2C4.1-17.8%2C12.4c0%2C3.8%2C1.4%2C6.5%2C4.1%2C8.1c2.7%2C1.6%2C8.9%2C3.2%2C18.6%2C4.9%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bc9.7%2C1.7%2C16.5%2C4%2C20.5%2C7.1c4%2C3%2C6%2C8.7%2C6%2C17.1c0%2C8.4-2.7%2C14.5-8.1%2C18.4c-5.4%2C3.9-13.2%2C5.9-23.6%2C5.9c-6.7%2C0-29.2-2.5-29.2-2.5%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bl0.7-10.6c12.9%2C1.2%2C22.3%2C2.2%2C28.6%2C2.2c6.3%2C0%2C11.1-1%2C14.4-3c3.3-2%2C5-5.4%2C5-10.1c0-4.7-1.4-7.9-4.2-9.6c-2.8-1.7-9-3.3-18.6-4.8%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bc-9.6-1.5-16.4-3.7-20.4-6.7c-4-2.9-6-8.4-6-16.3c0-7.9%2C2.8-13.8%2C8.4-17.6c5.6-3.8%2C12.6-5.7%2C20.9-5.7c6.6%2C0%2C29.6%2C1.7%2C29.6%2C1.7%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bv10.7C508.1%2C99%2C498.2%2C98.2%2C491.2%2C98.2z%22%2F%3E%0A%20%20%20%20%20%20%3Cpath%20class%3D%22st0%22%20d%3D%22M581.7%2C99.5h-25.9v39c0%2C9.3%2C0.7%2C15.5%2C2%2C18.4c1.4%2C2.9%2C4.6%2C4.4%2C9.7%2C4.4l14.5-1l0.8%2C10.1%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bc-7.3%2C1.2-12.8%2C1.8-16.6%2C1.8c-8.5%2C0-14.3-2.1-17.6-6.2c-3.3-4.1-4.9-12-4.9-23.6V99.5h-11.6V88.9h11.6V63.9h12.1v24.9h25.9V99.5z%22%2F%3E%0A%20%20%20%20%20%20%3Cpath%20class%3D%22st0%22%20d%3D%22M598.7%2C78.4V64.3h12.2v14.2H598.7z%20M598.7%2C171.4V88.9h12.2v82.5H598.7z%22%2F%3E%0A%20%20%20%20%20%20%3Cpath%20class%3D%22st0%22%20d%3D%22M663.8%2C87.2c3.6%2C0%2C9.7%2C0.7%2C18.3%2C2l3.9%2C0.5l-0.5%2C9.9c-8.7-1-15.1-1.5-19.2-1.5c-9.2%2C0-15.5%2C2.2-18.8%2C6.6%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bc-3.3%2C4.4-5%2C12.6-5%2C24.5c0%2C11.9%2C1.5%2C20.2%2C4.6%2C24.9c3.1%2C4.7%2C9.5%2C7%2C19.3%2C7l19.2-1.5l0.5%2C10.1c-10.1%2C1.5-17.7%2C2.3-22.7%2C2.3%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bc-12.7%2C0-21.5-3.3-26.3-9.8c-4.8-6.5-7.3-17.5-7.3-33c0-15.5%2C2.6-26.4%2C7.8-32.6C643%2C90.4%2C651.7%2C87.2%2C663.8%2C87.2z%22%2F%3E%0A%20%20%20%20%3C%2Fg%3E%0A%20%20%20%20%3Cpath%20class%3D%22st0%22%20d%3D%22M236.6%2C123.5c0-19.8-12.3-37.2-30.8-43.9c0.8-4.2%2C1.2-8.4%2C1.2-12.7C207%2C30%2C177%2C0%2C140.2%2C0%26%2310%3B%26%239%3B%26%239%3BC118.6%2C0%2C98.6%2C10.3%2C86%2C27.7c-6.2-4.8-13.8-7.4-21.7-7.4c-19.6%2C0-35.5%2C15.9-35.5%2C35.5c0%2C4.3%2C0.8%2C8.5%2C2.2%2C12.4%26%2310%3B%26%239%3B%26%239%3BC12.6%2C74.8%2C0%2C92.5%2C0%2C112.2c0%2C19.9%2C12.4%2C37.3%2C30.9%2C44c-0.8%2C4.1-1.2%2C8.4-1.2%2C12.7c0%2C36.8%2C29.9%2C66.7%2C66.7%2C66.7%26%2310%3B%26%239%3B%26%239%3Bc21.6%2C0%2C41.6-10.4%2C54.1-27.8c6.2%2C4.9%2C13.8%2C7.6%2C21.7%2C7.6c19.6%2C0%2C35.5-15.9%2C35.5-35.5c0-4.3-0.8-8.5-2.2-12.4%26%2310%3B%26%239%3B%26%239%3BC223.9%2C160.9%2C236.6%2C143.2%2C236.6%2C123.5z%20M91.6%2C34.8c10.9-15.9%2C28.9-25.4%2C48.1-25.4c32.2%2C0%2C58.4%2C26.2%2C58.4%2C58.4%26%2310%3B%26%239%3B%26%239%3Bc0%2C3.9-0.4%2C7.7-1.1%2C11.5l-52.2%2C45.8L93%2C101.5L82.9%2C79.9L91.6%2C34.8z%20M65.4%2C29c6.2%2C0%2C12.1%2C2%2C17%2C5.7l-7.8%2C40.3l-35.5-8.4%26%2310%3B%26%239%3B%26%239%3Bc-1.1-3.1-1.7-6.3-1.7-9.7C37.4%2C41.6%2C49.9%2C29%2C65.4%2C29z%20M9.1%2C112.3c0-16.7%2C11-31.9%2C26.9-37.2L75%2C84.4l9.1%2C19.5l-49.8%2C45%26%2310%3B%26%239%3B%26%239%3BC19.2%2C143.1%2C9.1%2C128.6%2C9.1%2C112.3z%20M145.2%2C200.9c-10.9%2C16.1-29%2C25.6-48.4%2C25.6c-32.3%2C0-58.6-26.3-58.6-58.5c0-4%2C0.4-7.9%2C1.1-11.7%26%2310%3B%26%239%3B%26%239%3Bl50.9-46l52%2C23.7l11.5%2C22L145.2%2C200.9z%20M171.2%2C206.6c-6.1%2C0-12-2-16.9-5.8l7.7-40.2l35.4%2C8.3c1.1%2C3.1%2C1.7%2C6.3%2C1.7%2C9.7%26%2310%3B%26%239%3B%26%239%3BC199.2%2C194.1%2C186.6%2C206.6%2C171.2%2C206.6z%20M200.5%2C160.5l-39-9.1l-10.4-19.8l51-44.7c15.1%2C5.7%2C25.2%2C20.2%2C25.2%2C36.5%26%2310%3B%26%239%3B%26%239%3BC227.4%2C140.1%2C216.4%2C155.3%2C200.5%2C160.5z%22%2F%3E%0A%20%20%3C%2Fg%3E%0A%3C%2Fsvg%3E'; diff --git a/x-pack/plugins/canvas/public/lib/element.js b/x-pack/plugins/canvas/public/lib/element.js new file mode 100644 index 0000000000000..f2ebe9af8702b --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/element.js @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import defaultHeader from './default_header.png'; + +export function Element(config) { + // This must match the name of the function that is used to create the `type: render` object + this.name = config.name; + + // Use this to set a more friendly name + this.displayName = config.displayName || this.name; + + // An image to use in the element type selector + this.image = config.image || defaultHeader; + + // A sentence or few about what this element does + this.help = config.help; + + if (!config.expression) throw new Error('Element types must have a default expression'); + this.expression = config.expression; + this.filter = config.filter; + this.width = config.width || 500; + this.height = config.height || 300; +} diff --git a/x-pack/plugins/canvas/public/lib/elements_registry.js b/x-pack/plugins/canvas/public/lib/elements_registry.js new file mode 100644 index 0000000000000..898fba183c9f5 --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/elements_registry.js @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Registry } from '../../common/lib/registry'; +import { Element } from './element'; + +class ElementsRegistry extends Registry { + wrapper(obj) { + return new Element(obj); + } +} + +export const elementsRegistry = new ElementsRegistry(); diff --git a/x-pack/plugins/canvas/public/lib/es_service.js b/x-pack/plugins/canvas/public/lib/es_service.js new file mode 100644 index 0000000000000..879bf69624819 --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/es_service.js @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import chrome from 'ui/chrome'; +import { API_ROUTE } from '../../common/lib/constants'; +import { fetch } from '../../common/lib/fetch'; +import { notify } from './notify'; + +const basePath = chrome.getBasePath(); +const apiPath = basePath + API_ROUTE; + +export const getFields = (index = '_all') => { + return fetch + .get(`${apiPath}/es_fields?index=${index}`) + .then(({ data: mapping }) => + Object.keys(mapping) + .filter(field => !field.startsWith('_')) // filters out meta fields + .sort() + ) + .catch(err => + notify.error(err, { title: `Couldn't fetch Elasticsearch fields for '${index}'` }) + ); +}; + +export const getIndices = () => { + return fetch + .get(`${apiPath}/es_indices`) + .then(({ data: indices }) => indices) + .catch(err => notify.error(err, { title: `Couldn't fetch Elasticsearch indices` })); +}; diff --git a/x-pack/plugins/canvas/public/lib/find_expression_type.js b/x-pack/plugins/canvas/public/lib/find_expression_type.js new file mode 100644 index 0000000000000..c70a1ec719fe8 --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/find_expression_type.js @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { datasourceRegistry } from '../expression_types/datasource'; +import { transformRegistry } from '../expression_types/transform'; +import { modelRegistry } from '../expression_types/model'; +import { viewRegistry } from '../expression_types/view'; + +const expressionTypes = ['view', 'model', 'transform', 'datasource']; + +export function findExpressionType(name, type) { + const checkTypes = expressionTypes.filter( + expressionType => type == null || expressionType === type + ); + + const matches = checkTypes.reduce((acc, checkType) => { + let expression; + switch (checkType) { + case 'view': + expression = viewRegistry.get(name); + return !expression ? acc : acc.concat(expression); + case 'model': + expression = modelRegistry.get(name); + return !expression ? acc : acc.concat(expression); + case 'transform': + expression = transformRegistry.get(name); + return !expression ? acc : acc.concat(expression); + case 'datasource': + expression = datasourceRegistry.get(name); + return !expression ? acc : acc.concat(expression); + default: + return acc; + } + }, []); + + if (matches.length > 1) throw new Error(`Found multiple expressions with name "${name}"`); + return matches[0] || null; +} diff --git a/x-pack/plugins/canvas/public/lib/fullscreen.js b/x-pack/plugins/canvas/public/lib/fullscreen.js new file mode 100644 index 0000000000000..e367ae08ea851 --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/fullscreen.js @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const fullscreenClass = 'canvas-isFullscreen'; + +export function setFullscreen(fullscreen, doc = document) { + const enabled = Boolean(fullscreen); + const body = doc.querySelector('body'); + const bodyClassList = body.classList; + const isFullscreen = bodyClassList.contains(fullscreenClass); + + if (enabled && !isFullscreen) bodyClassList.add(fullscreenClass); + else if (!enabled && isFullscreen) bodyClassList.remove(fullscreenClass); +} diff --git a/x-pack/plugins/canvas/public/lib/functions_registry.js b/x-pack/plugins/canvas/public/lib/functions_registry.js new file mode 100644 index 0000000000000..3cc084d8ca66e --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/functions_registry.js @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// export the common registry here, so it's available in plugin public code +export { functionsRegistry } from '../../common/lib/functions_registry'; diff --git a/x-pack/plugins/canvas/public/lib/get_id.js b/x-pack/plugins/canvas/public/lib/get_id.js new file mode 100644 index 0000000000000..7518503e5f247 --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/get_id.js @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import uuid from 'uuid/v4'; + +export function getId(type) { + return `${type}-${uuid()}`; +} diff --git a/x-pack/plugins/canvas/public/lib/get_window.js b/x-pack/plugins/canvas/public/lib/get_window.js new file mode 100644 index 0000000000000..8b21ebd2531cb --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/get_window.js @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// return window if it exists, otherwise just return an object literal +const windowObj = {}; + +export const getWindow = () => { + return typeof window === 'undefined' ? windowObj : window; +}; diff --git a/x-pack/plugins/canvas/public/lib/history_provider.js b/x-pack/plugins/canvas/public/lib/history_provider.js new file mode 100644 index 0000000000000..9b7f907ceeedf --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/history_provider.js @@ -0,0 +1,164 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import chrome from 'ui/chrome'; +import lzString from 'lz-string'; +import { createBrowserHistory, createMemoryHistory, parsePath, createPath } from 'history'; +import { get } from 'lodash'; +import { APP_ROUTE } from '../../common/lib/constants'; +import { getWindow } from './get_window'; + +function wrapHistoryInstance(history) { + const historyState = { + onChange: [], + prevLocation: {}, + changeUnlisten: null, + }; + + const locationFormat = (location, action, parser) => ({ + pathname: location.pathname, + hash: location.hash, + state: parser(location.state), + action: action.toLowerCase(), + }); + + const wrappedHistory = { + undo() { + history.goBack(); + }, + + redo() { + history.goForward(); + }, + + go(idx) { + history.go(idx); + }, + + parse(payload) { + try { + const stateJSON = lzString.decompress(payload); + return JSON.parse(stateJSON); + } catch (e) { + return null; + } + }, + + encode(state) { + try { + const stateJSON = JSON.stringify(state); + return lzString.compress(stateJSON); + } catch (e) { + throw new Error('Could not encode state: ', e.message); + } + }, + + getLocation() { + const location = history.location; + return { + ...location, + state: this.parse(location.state), + }; + }, + + getPath(path) { + if (path != null) return createPath(parsePath(path)); + return createPath(this.getLocation()); + }, + + getFullPath(path) { + if (path != null) return history.createHref(parsePath(path)); + return history.createHref(this.getLocation()); + }, + + push(state, path) { + history.push(path || this.getPath(), this.encode(state)); + }, + + replace(state, path) { + history.replace(path || this.getPath(), this.encode(state)); + }, + + onChange(fn) { + // if no handler fn passed, do nothing + if (fn == null) return; + + // push onChange function onto listener stack and return a function to remove it + const pushedIndex = historyState.onChange.push(fn) - 1; + return (() => { + // only allow the unlisten function to be called once + let called = false; + return () => { + if (called) return; + historyState.onChange.splice(pushedIndex, 1); + called = true; + }; + })(); + }, + + resetOnChange() { + // splice to clear the onChange array, and remove listener for each fn + historyState.onChange.splice(0); + }, + + get historyInstance() { + // getter to get access to the underlying history instance + return history; + }, + }; + + // track the initial history location and create update listener + historyState.prevLocation = wrappedHistory.getLocation(); + historyState.changeUnlisten = history.listen((location, action) => { + const { prevLocation } = historyState; + const locationObj = locationFormat(location, action, wrappedHistory.parse); + const prevLocationObj = locationFormat(prevLocation, action, wrappedHistory.parse); + + // execute all listeners + historyState.onChange.forEach(fn => fn.call(null, locationObj, prevLocationObj)); + + // track the updated location + historyState.prevLocation = wrappedHistory.getLocation(); + }); + + return wrappedHistory; +} + +const instances = new WeakMap(); + +const getHistoryInstance = win => { + // if no window object, use memory module + if (typeof win === 'undefined' || !win.history) return createMemoryHistory(); + + const basePath = chrome.getBasePath(); + const basename = `${basePath}${APP_ROUTE}#/`; + + // hacky fix for initial page load so basename matches with the hash + if (win.location.hash === '') win.history.replaceState({}, '', `${basename}`); + + // if window object, create browser instance + return createBrowserHistory({ + basename, + }); +}; + +export const historyProvider = (win = getWindow()) => { + // return cached instance if one exists + const instance = instances.get(win); + if (instance) return instance; + + // temporary fix for search params before the hash; remove them via location redirect + // they can't be preserved given this upstream issue https://github.com/ReactTraining/history/issues/564 + if (get(win, 'location.search', '').length > 0) + win.location = `${chrome.getBasePath()}${APP_ROUTE}${win.location.hash}`; + + // create and cache wrapped history instance + const historyInstance = getHistoryInstance(win); + const wrappedInstance = wrapHistoryInstance(historyInstance); + instances.set(win, wrappedInstance); + + return wrappedInstance; +}; diff --git a/x-pack/plugins/canvas/public/lib/interpreter.js b/x-pack/plugins/canvas/public/lib/interpreter.js new file mode 100644 index 0000000000000..48491be48ef67 --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/interpreter.js @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { socketInterpreterProvider } from '../../common/interpreter/socket_interpret'; +import { serializeProvider } from '../../common/lib/serialize'; +import { socket } from '../socket'; +import { typesRegistry } from '../../common/lib/types_registry'; +import { createHandlers } from './create_handlers'; +import { functionsRegistry } from './functions_registry'; +import { loadBrowserPlugins } from './load_browser_plugins'; + +// Create the function list +socket.emit('getFunctionList'); +export const getServerFunctions = new Promise(resolve => socket.once('functionList', resolve)); + +// Use the above promise to seed the interpreter with the functions it can defer to +export function interpretAst(ast, context) { + // Load plugins before attempting to get functions, otherwise this gets racey + return Promise.all([getServerFunctions, loadBrowserPlugins()]) + .then(([serverFunctionList]) => { + return socketInterpreterProvider({ + types: typesRegistry.toJS(), + handlers: createHandlers(socket), + functions: functionsRegistry.toJS(), + referableFunctions: serverFunctionList, + socket: socket, + }); + }) + .then(interpretFn => interpretFn(ast, context)); +} + +socket.on('run', ({ ast, context, id }) => { + const types = typesRegistry.toJS(); + const { serialize, deserialize } = serializeProvider(types); + interpretAst(ast, deserialize(context)).then(value => { + socket.emit(`resp:${id}`, { value: serialize(value) }); + }); +}); diff --git a/x-pack/plugins/canvas/public/lib/keymap.js b/x-pack/plugins/canvas/public/lib/keymap.js new file mode 100644 index 0000000000000..dacbd08eee969 --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/keymap.js @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const keymap = { + EDITOR: { + UNDO: 'ctrl+z', + REDO: 'ctrl+shift+y', + NEXT: 'alt+]', + PREV: 'alt+[', + FULLSCREEN: ['alt+p', 'alt+f'], + FULLSCREEN_EXIT: ['escape'], + EDITING: ['alt+e'], + GRID: 'alt+g', + REFRESH: 'alt+r', + }, + ELEMENT: { + DELETE: 'del', + }, + PRESENTATION: { + NEXT: ['space', 'right', 'alt+]'], + PREV: ['left', 'alt+['], + REFRESH: 'alt+r', + }, +}; diff --git a/x-pack/plugins/canvas/public/lib/legend_options.js b/x-pack/plugins/canvas/public/lib/legend_options.js new file mode 100644 index 0000000000000..0b72f164c6dc3 --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/legend_options.js @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const legendOptions = [ + { + name: 'Hidden', + value: false, + }, + { + name: 'Top Left', + value: 'nw', + }, + { + name: 'Top Right', + value: 'ne', + }, + { + name: 'Bottom Left', + value: 'sw', + }, + { + name: 'Bottom Right', + value: 'se', + }, +]; diff --git a/x-pack/plugins/canvas/public/lib/load_browser_plugins.js b/x-pack/plugins/canvas/public/lib/load_browser_plugins.js new file mode 100644 index 0000000000000..8f1f5b2e90894 --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/load_browser_plugins.js @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import chrome from 'ui/chrome'; +import $script from 'scriptjs'; +import { typesRegistry } from '../../common/lib/types_registry'; +import { + argTypeRegistry, + datasourceRegistry, + transformRegistry, + modelRegistry, + viewRegistry, +} from '../expression_types'; +import { elementsRegistry } from './elements_registry'; +import { renderFunctionsRegistry } from './render_functions_registry'; +import { functionsRegistry as browserFunctions } from './functions_registry'; +import { loadPrivateBrowserFunctions } from './load_private_browser_functions'; + +const types = { + browserFunctions: browserFunctions, + commonFunctions: browserFunctions, + elements: elementsRegistry, + types: typesRegistry, + renderers: renderFunctionsRegistry, + transformUIs: transformRegistry, + datasourceUIs: datasourceRegistry, + modelUIs: modelRegistry, + viewUIs: viewRegistry, + argumentUIs: argTypeRegistry, +}; + +export const loadBrowserPlugins = () => + new Promise(resolve => { + loadPrivateBrowserFunctions(); + const remainingTypes = Object.keys(types); + function loadType() { + const type = remainingTypes.pop(); + window.canvas = window.canvas || {}; + window.canvas.register = d => types[type].register(d); + // Load plugins one at a time because each needs a different loader function + // $script will only load each of these once, we so can call this as many times as we need? + const pluginPath = chrome.addBasePath(`/api/canvas/plugins?type=${type}`); + $script(pluginPath, () => { + if (remainingTypes.length) loadType(); + else resolve(true); + }); + } + + loadType(); + }); diff --git a/x-pack/plugins/canvas/public/lib/load_expression_types.js b/x-pack/plugins/canvas/public/lib/load_expression_types.js new file mode 100644 index 0000000000000..e63b29eed2d58 --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/load_expression_types.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { argTypeSpecs } from '../expression_types/arg_types'; +import { datasourceSpecs } from '../expression_types/datasources'; +import { argTypeRegistry, datasourceRegistry } from '../expression_types'; + +// register default args, arg types, and expression types +argTypeSpecs.forEach(expFn => argTypeRegistry.register(expFn)); +datasourceSpecs.forEach(expFn => datasourceRegistry.register(expFn)); diff --git a/x-pack/plugins/canvas/public/lib/load_private_browser_functions.js b/x-pack/plugins/canvas/public/lib/load_private_browser_functions.js new file mode 100644 index 0000000000000..85b995c026a19 --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/load_private_browser_functions.js @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { commonFunctions } from '../../common/functions'; +import { clientFunctions } from '../functions'; +import { functionsRegistry } from './functions_registry'; + +/* + Functions loaded here use PRIVATE APIs + That is, they probably import a canvas singleton, eg a registry and + thus must be part of the main Canvas bundle. There should be *very* + few of these things as we can't thread them. +*/ + +function addFunction(fnDef) { + functionsRegistry.register(fnDef); +} + +export const loadPrivateBrowserFunctions = () => { + clientFunctions.forEach(addFunction); + commonFunctions.forEach(addFunction); +}; diff --git a/x-pack/plugins/canvas/public/lib/load_transitions.js b/x-pack/plugins/canvas/public/lib/load_transitions.js new file mode 100644 index 0000000000000..e3fea75cedc80 --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/load_transitions.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { transitions } from '../transitions'; +import { transitionsRegistry } from './transitions_registry'; + +transitions.forEach(spec => transitionsRegistry.register(spec)); diff --git a/x-pack/plugins/canvas/public/lib/modify_path.js b/x-pack/plugins/canvas/public/lib/modify_path.js new file mode 100644 index 0000000000000..b4b2354b4cae0 --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/modify_path.js @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import toPath from 'lodash.topath'; + +export function prepend(path, value) { + return toPath(value).concat(toPath(path)); +} + +export function append(path, value) { + return toPath(path).concat(toPath(value)); +} + +export function convert(path) { + return toPath(path); +} diff --git a/x-pack/plugins/canvas/public/lib/notify.js b/x-pack/plugins/canvas/public/lib/notify.js new file mode 100644 index 0000000000000..e824868f9541f --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/notify.js @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { toastNotifications } from 'ui/notify'; +import { formatMsg } from 'ui/notify/lib/format_msg'; +import { get } from 'lodash'; + +const getToast = (err, opts = {}) => { + const errData = get(err, 'response') || err; + const errMsg = formatMsg(errData); + const { title, ...rest } = opts; + let text = null; + + if (title) text = errMsg; + + return { + ...rest, + title: title || errMsg, + text, + }; +}; + +export const notify = { + /* + * @param {(string | Object)} err: message or Error object + * @param {Object} opts: option to override toast title or icon, see https://github.com/elastic/kibana/blob/master/src/ui/public/notify/toasts/TOAST_NOTIFICATIONS.md + */ + error(err, opts) { + toastNotifications.addDanger(getToast(err, opts)); + }, + warning(err, opts) { + toastNotifications.addWarning(getToast(err, opts)); + }, + info(err, opts) { + toastNotifications.add(getToast(err, opts)); + }, + success(err, opts) { + toastNotifications.addSuccess(getToast(err, opts)); + }, +}; diff --git a/x-pack/plugins/canvas/public/lib/parse_single_function_chain.js b/x-pack/plugins/canvas/public/lib/parse_single_function_chain.js new file mode 100644 index 0000000000000..f8eec880af624 --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/parse_single_function_chain.js @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get, mapValues, map } from 'lodash'; +import { fromExpression } from '../../common/lib/ast'; + +export function parseSingleFunctionChain(filterString) { + const ast = fromExpression(filterString); + + // Check if the current column is what we expect it to be. If the user changes column this will be called again, + // but we don't want to run setFilter() unless we have to because it will cause a data refresh + const name = get(ast, 'chain[0].function'); + if (!name) throw new Error('Could not find function name in chain'); + + const args = mapValues(get(ast, 'chain[0].arguments'), val => { + // TODO Check for literals only + return map(val, 'value'); + }); + + return { name, args }; +} diff --git a/x-pack/plugins/canvas/public/lib/readable_color.js b/x-pack/plugins/canvas/public/lib/readable_color.js new file mode 100644 index 0000000000000..0dd7e2dec1c55 --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/readable_color.js @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import chroma from 'chroma-js'; + +export function readableColor(background, light, dark) { + light = light || '#FFF'; + dark = dark || '#333'; + try { + return chroma.contrast(background, '#000') < 7 ? light : dark; + } catch (e) { + return dark; + } +} diff --git a/x-pack/plugins/canvas/public/lib/render_function.js b/x-pack/plugins/canvas/public/lib/render_function.js new file mode 100644 index 0000000000000..68a6d8485db83 --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/render_function.js @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export function RenderFunction(config) { + // This must match the name of the function that is used to create the `type: render` object + this.name = config.name; + + // Use this to set a more friendly name + this.displayName = config.displayName || this.name; + + // A sentence or few about what this element does + this.help = config.help; + + // used to validate the data before calling the render function + this.validate = config.validate || function validate() {}; + + // tell the renderer if the dom node should be reused, it's recreated each time by default + this.reuseDomNode = Boolean(config.reuseDomNode); + + // the function called to render the data + this.render = + config.render || + function render(domNode, data, done) { + done(); + }; +} diff --git a/x-pack/plugins/canvas/public/lib/render_functions_registry.js b/x-pack/plugins/canvas/public/lib/render_functions_registry.js new file mode 100644 index 0000000000000..3d040047aeb9a --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/render_functions_registry.js @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Registry } from '../../common/lib/registry'; +import { RenderFunction } from './render_function'; + +class RenderFunctionsRegistry extends Registry { + wrapper(obj) { + return new RenderFunction(obj); + } +} + +export const renderFunctionsRegistry = new RenderFunctionsRegistry(); diff --git a/x-pack/plugins/canvas/public/lib/resolved_arg.js b/x-pack/plugins/canvas/public/lib/resolved_arg.js new file mode 100644 index 0000000000000..5dc83625a7387 --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/resolved_arg.js @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get } from 'lodash'; + +export function getState(resolvedArg) { + return get(resolvedArg, 'state', null); +} + +export function getValue(resolvedArg) { + return get(resolvedArg, 'value', null); +} + +export function getError(resolvedArg) { + if (getState(resolvedArg) !== 'error') return null; + return get(resolvedArg, 'error', null); +} diff --git a/x-pack/plugins/canvas/public/lib/router_provider.js b/x-pack/plugins/canvas/public/lib/router_provider.js new file mode 100644 index 0000000000000..c62f020ac0151 --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/router_provider.js @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import createRouter from '@scant/router'; +import { getWindow } from './get_window'; +import { historyProvider } from './history_provider'; + +// used to make this provider a singleton +let router; + +export function routerProvider(routes) { + if (router) return router; + + const baseRouter = createRouter(routes); + const history = historyProvider(getWindow()); + let componentListener = null; + + const isPath = str => typeof str === 'string' && str.substr(0, 1) === '/'; + + const getState = (name, params, state) => { + // given a path, assuming params is the state + if (isPath(name)) return params || history.getLocation().state; + return state || history.getLocation().state; + }; + + // our router is an extended version of the imported router + // which mixes in history methods for navigation + router = { + ...baseRouter, + execute(path = history.getPath()) { + return this.parse(path); + }, + getPath: history.getPath, + getFullPath: history.getFullPath, + navigateTo(name, params, state) { + const currentState = getState(name, params, state); + // given a path, go there directly + if (isPath(name)) return history.push(currentState, name); + history.push(currentState, this.create(name, params)); + }, + redirectTo(name, params, state) { + const currentState = getState(name, params, state); + // given a path, go there directly, assuming params is state + if (isPath(name)) return history.replace(currentState, name); + history.replace(currentState, this.create(name, params)); + }, + onPathChange(fn) { + if (componentListener != null) + throw new Error('Only one route component listener is allowed'); + + const execOnMatch = location => { + const { pathname } = location; + const match = this.match(pathname); + + if (!match) { + // TODO: show some kind of error, or redirect somewhere; maybe home? + console.error('No route found for path: ', pathname); + return; + } + + fn({ ...match, location }); + }; + + // on path changes, fire the path change handler + componentListener = history.onChange((locationObj, prevLocationObj) => { + if (locationObj.pathname !== prevLocationObj.pathname) execOnMatch(locationObj); + }); + + // initially fire the path change handler + execOnMatch(history.getLocation()); + }, + }; + + return router; +} diff --git a/x-pack/plugins/canvas/public/lib/run_interpreter.js b/x-pack/plugins/canvas/public/lib/run_interpreter.js new file mode 100644 index 0000000000000..cc0d9a7544786 --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/run_interpreter.js @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { fromExpression } from '../../common/lib/ast'; +import { getType } from '../../common/lib/get_type'; +import { interpretAst } from './interpreter'; +import { notify } from './notify'; + +/** + * Runs interpreter, usually in the browser + * + * @param {object} ast - Executable AST + * @param {any} context - Initial context for AST execution + * @param {object} options + * @param {boolean} options.castToRender - try to cast to a type: render object? + * @param {boolean} options.retryRenderCasting - + * @returns {promise} + */ +export function runInterpreter(ast, context = null, options = {}) { + return interpretAst(ast, context) + .then(renderable => { + if (getType(renderable) === 'render') return renderable; + + if (options.castToRender) { + return runInterpreter(fromExpression('render'), renderable, { + castToRender: false, + }); + } + + return new Error(`Ack! I don't know how to render a '${getType(renderable)}'`); + }) + .catch(err => { + notify.error(err); + throw err; + }); +} diff --git a/x-pack/plugins/canvas/public/lib/shortcut_manager.js b/x-pack/plugins/canvas/public/lib/shortcut_manager.js new file mode 100644 index 0000000000000..78c70787ef4d7 --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/shortcut_manager.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ShortcutManager } from 'react-shortcuts'; +import { keymap } from './keymap'; + +export const shortcutManager = new ShortcutManager(keymap); diff --git a/x-pack/plugins/canvas/public/lib/template_from_react_component.js b/x-pack/plugins/canvas/public/lib/template_from_react_component.js new file mode 100644 index 0000000000000..d4462e94320b1 --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/template_from_react_component.js @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import ReactDom from 'react-dom'; +import PropTypes from 'prop-types'; +import { ErrorBoundary } from '../components/enhance/error_boundary'; + +export const templateFromReactComponent = Component => { + const WrappedComponent = props => ( + + {({ error }) => { + if (error) { + props.renderError(); + return null; + } + + return ; + }} + + ); + + WrappedComponent.propTypes = { + renderError: PropTypes.func, + }; + + return (domNode, config, handlers) => { + try { + const el = React.createElement(WrappedComponent, config); + ReactDom.render(el, domNode, () => { + handlers.done(); + }); + + handlers.onDestroy(() => { + ReactDom.unmountComponentAtNode(domNode); + }); + } catch (err) { + handlers.done(); + config.renderError(); + } + }; +}; diff --git a/x-pack/plugins/canvas/public/lib/time_duration.js b/x-pack/plugins/canvas/public/lib/time_duration.js new file mode 100644 index 0000000000000..46d73fb02100b --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/time_duration.js @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +const getLabel = (label, val) => (val > 1 || val === 0 ? `${label}s` : label); + +export const timeDuration = (time, format) => { + const seconds = time / 1000; + const minutes = seconds / 60; + const hours = minutes / 60; + const days = hours / 24; + + if (format === 'days' || days >= 1) return { length: days, format: getLabel('day', days) }; + if (format === 'hours' || hours >= 1) return { length: hours, format: getLabel('hour', hours) }; + if (format === 'minutes' || minutes >= 1) + return { length: seconds / 60, format: getLabel('minute', minutes) }; + return { length: seconds, format: getLabel('second', seconds) }; +}; + +export const timeDurationString = (time, format) => { + const { length, format: fmt } = timeDuration(time, format); + return `${length} ${fmt}`; +}; diff --git a/x-pack/plugins/canvas/public/lib/transitions_registry.js b/x-pack/plugins/canvas/public/lib/transitions_registry.js new file mode 100644 index 0000000000000..8d2e421b8233c --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/transitions_registry.js @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Registry } from '../../common/lib/registry'; +import { Transition } from '../transitions/transition'; + +class TransitionsRegistry extends Registry { + wrapper(obj) { + return new Transition(obj); + } +} + +export const transitionsRegistry = new TransitionsRegistry(); diff --git a/x-pack/plugins/canvas/public/lib/types_registry.js b/x-pack/plugins/canvas/public/lib/types_registry.js new file mode 100644 index 0000000000000..c1f13b1ae4612 --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/types_registry.js @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// export the common registry here, so it's available in plugin public code +export { typesRegistry } from '../../common/lib/types_registry'; diff --git a/x-pack/plugins/canvas/public/lib/window_error_handler.js b/x-pack/plugins/canvas/public/lib/window_error_handler.js new file mode 100644 index 0000000000000..0a96f9305bf14 --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/window_error_handler.js @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as knownErrors from '../../common/lib/errors'; + +const oldHandler = window.onerror; + +function showError(err) { + const body = document.querySelector('body'); + const notice = document.createElement('div'); + notice.classList.add('window-error'); + + const close = document.createElement('a'); + close.textContent = 'close'; + close.onclick = ev => { + ev.preventDefault(); + body.removeChild(notice); + }; + notice.appendChild(close); + + notice.insertAdjacentHTML('beforeend', '

Uncaught error swallowed in dev mode

'); + + const message = document.createElement('p'); + message.textContent = `Error: ${err.message}`; + notice.appendChild(message); + + if (err.stack) { + const stack = document.createElement('pre'); + stack.textContent = err.stack + .split('\n') + .slice(0, 2) + .concat('...') + .join('\n'); + notice.appendChild(stack); + } + + notice.insertAdjacentHTML('beforeend', `

Check console for more information

`); + body.appendChild(notice); +} + +// React will delegate to window.onerror, even when errors are caught with componentWillCatch, +// so check for a known custom error type and skip the default error handling when we find one +window.onerror = (...args) => { + const [message, , , , err] = args; + + const isKnownError = Object.keys(knownErrors).find(errorName => { + return err.constructor.name === errorName || message.indexOf(errorName) >= 0; + }); + if (isKnownError) return; + + // uncaught errors are silenced in dev mode + // NOTE: react provides no way I can tell to distingish that an error came from react, it just + // throws generic Errors. In development mode, it throws those errors even if you catch them in + // an error boundary. This uses in the stack trace to try to detect it, but that stack changes + // between development and production modes. Fortunately, beginWork exists in both, so it uses + // a mix of the runtime mode and checking for another react method (renderRoot) for development + // TODO: this is *super* fragile. If the React method names ever change, which seems kind of likely, + // this check will break. + const isProduction = process.env.NODE_ENV === 'production'; + if (!isProduction) { + // TODO: we should do something here to let the user know something failed, + // but we don't currently have an error logging service + console.error(err); + console.warn(`*** Uncaught error swallowed in dev mode *** + +Check and fix the above error. This will blow up Kibana when run in production mode!`); + showError(err); + return; + } + + // fall back to the default kibana uncaught error handler + oldHandler(...args); +}; diff --git a/x-pack/plugins/canvas/public/lib/workpad_service.js b/x-pack/plugins/canvas/public/lib/workpad_service.js new file mode 100644 index 0000000000000..78464a78cefc9 --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/workpad_service.js @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import chrome from 'ui/chrome'; +import { API_ROUTE_WORKPAD } from '../../common/lib/constants'; +import { fetch } from '../../common/lib/fetch'; + +const basePath = chrome.getBasePath(); +const apiPath = `${basePath}${API_ROUTE_WORKPAD}`; + +export function create(workpad) { + return fetch.post(apiPath, { ...workpad, assets: workpad.assets || {} }); +} + +export function get(workpadId) { + return fetch.get(`${apiPath}/${workpadId}`).then(({ data: workpad }) => workpad); +} + +export function update(id, workpad) { + return fetch.put(`${apiPath}/${id}`, workpad); +} + +export function remove(id) { + return fetch.delete(`${apiPath}/${id}`); +} + +export function find(searchTerm) { + const validSearchTerm = typeof searchTerm === 'string' && searchTerm.length > 0; + + return fetch + .get(`${apiPath}/find?name=${validSearchTerm ? searchTerm : ''}&perPage=10000`) + .then(({ data: workpads }) => workpads); +} diff --git a/x-pack/plugins/canvas/public/socket.js b/x-pack/plugins/canvas/public/socket.js new file mode 100644 index 0000000000000..eb381f17d1f9b --- /dev/null +++ b/x-pack/plugins/canvas/public/socket.js @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import chrome from 'ui/chrome'; +import io from 'socket.io-client'; +import { functionsRegistry } from '../common/lib/functions_registry'; +import { loadBrowserPlugins } from './lib/load_browser_plugins'; + +const basePath = chrome.getBasePath(); +export const socket = io(undefined, { path: `${basePath}/socket.io` }); + +socket.on('getFunctionList', () => { + const pluginsLoaded = loadBrowserPlugins(); + + pluginsLoaded.then(() => socket.emit('functionList', functionsRegistry.toJS())); +}); diff --git a/x-pack/plugins/canvas/public/state/actions/__tests__/elements.get_sibling_context.js b/x-pack/plugins/canvas/public/state/actions/__tests__/elements.get_sibling_context.js new file mode 100644 index 0000000000000..a1e5418b0daf7 --- /dev/null +++ b/x-pack/plugins/canvas/public/state/actions/__tests__/elements.get_sibling_context.js @@ -0,0 +1,107 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { getSiblingContext } from '../elements'; + +const state = { + transient: { + resolvedArgs: { + 'element-foo': { + expressionContext: { + '0': { + state: 'ready', + value: { + type: 'datatable', + columns: [ + { name: 'project', type: 'string' }, + { name: 'cost', type: 'string' }, + { name: 'age', type: 'string' }, + ], + rows: [ + { project: 'pandas', cost: '500', age: '18' }, + { project: 'tigers', cost: '200', age: '12' }, + ], + }, + error: null, + }, + '1': { + state: 'ready', + value: { + type: 'datatable', + columns: [ + { name: 'project', type: 'string' }, + { name: 'cost', type: 'string' }, + { name: 'age', type: 'string' }, + ], + rows: [ + { project: 'tigers', cost: '200', age: '12' }, + { project: 'pandas', cost: '500', age: '18' }, + ], + }, + error: null, + }, + '2': { + state: 'ready', + value: { + type: 'pointseries', + columns: { + x: { type: 'string', role: 'dimension', expression: 'cost' }, + y: { type: 'string', role: 'dimension', expression: 'project' }, + color: { type: 'string', role: 'dimension', expression: 'project' }, + }, + rows: [ + { x: '200', y: 'tigers', color: 'tigers' }, + { x: '500', y: 'pandas', color: 'pandas' }, + ], + }, + error: null, + }, + }, + }, + }, + }, +}; + +describe('actions/elements getSiblingContext', () => { + it('should find context when a previous context value is found', () => { + // pointseries map + expect(getSiblingContext(state, 'element-foo', 2)).to.eql({ + index: 2, + context: { + type: 'pointseries', + columns: { + x: { type: 'string', role: 'dimension', expression: 'cost' }, + y: { type: 'string', role: 'dimension', expression: 'project' }, + color: { type: 'string', role: 'dimension', expression: 'project' }, + }, + rows: [ + { x: '200', y: 'tigers', color: 'tigers' }, + { x: '500', y: 'pandas', color: 'pandas' }, + ], + }, + }); + }); + + it('should find context when a previous context value is not found', () => { + // pointseries map + expect(getSiblingContext(state, 'element-foo', 1000)).to.eql({ + index: 2, + context: { + type: 'pointseries', + columns: { + x: { type: 'string', role: 'dimension', expression: 'cost' }, + y: { type: 'string', role: 'dimension', expression: 'project' }, + color: { type: 'string', role: 'dimension', expression: 'project' }, + }, + rows: [ + { x: '200', y: 'tigers', color: 'tigers' }, + { x: '500', y: 'pandas', color: 'pandas' }, + ], + }, + }); + }); +}); diff --git a/x-pack/plugins/canvas/public/state/actions/app.js b/x-pack/plugins/canvas/public/state/actions/app.js new file mode 100644 index 0000000000000..1ca50eefe864c --- /dev/null +++ b/x-pack/plugins/canvas/public/state/actions/app.js @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createAction } from 'redux-actions'; + +// actions to set the application state +export const appReady = createAction('appReady'); +export const appError = createAction('appError'); diff --git a/x-pack/plugins/canvas/public/state/actions/assets.js b/x-pack/plugins/canvas/public/state/actions/assets.js new file mode 100644 index 0000000000000..422cd126d4385 --- /dev/null +++ b/x-pack/plugins/canvas/public/state/actions/assets.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createAction } from 'redux-actions'; + +export const createAsset = createAction('createAsset', (type, value, id) => ({ type, value, id })); +export const setAssetValue = createAction('setAssetContent', (id, value) => ({ id, value })); +export const removeAsset = createAction('removeAsset'); +export const setAssets = createAction('setAssets'); +export const resetAssets = createAction('resetAssets'); diff --git a/x-pack/plugins/canvas/public/state/actions/elements.js b/x-pack/plugins/canvas/public/state/actions/elements.js new file mode 100644 index 0000000000000..a2fc39f30987c --- /dev/null +++ b/x-pack/plugins/canvas/public/state/actions/elements.js @@ -0,0 +1,362 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createAction } from 'redux-actions'; +import { createThunk } from 'redux-thunks'; +import { set, del } from 'object-path-immutable'; +import { get, pick, cloneDeep, without } from 'lodash'; +import { getPages, getElementById, getSelectedPageIndex } from '../selectors/workpad'; +import { getValue as getResolvedArgsValue } from '../selectors/resolved_args'; +import { getDefaultElement } from '../defaults'; +import { toExpression, safeElementFromExpression } from '../../../common/lib/ast'; +import { notify } from '../../lib/notify'; +import { runInterpreter } from '../../lib/run_interpreter'; +import { interpretAst } from '../../lib/interpreter'; +import { selectElement } from './transient'; +import * as args from './resolved_args'; + +export function getSiblingContext(state, elementId, checkIndex) { + const prevContextPath = [elementId, 'expressionContext', checkIndex]; + const prevContextValue = getResolvedArgsValue(state, prevContextPath); + + // if a value is found, return it, along with the index it was found at + if (prevContextValue != null) { + return { + index: checkIndex, + context: prevContextValue, + }; + } + + // check previous index while we're still above 0 + const prevContextIndex = checkIndex - 1; + if (prevContextIndex < 0) return {}; + + // walk back up to find the closest cached context available + return getSiblingContext(state, elementId, prevContextIndex); +} + +function getBareElement(el, includeId = false) { + const props = ['position', 'expression', 'filter']; + if (includeId) return pick(el, props.concat('id')); + return cloneDeep(pick(el, props)); +} + +export const elementLayer = createAction('elementLayer'); + +export const setPosition = createAction('setPosition', (elementId, pageId, position) => ({ + pageId, + elementId, + position, +})); + +export const flushContext = createAction('flushContext'); +export const flushContextAfterIndex = createAction('flushContextAfterIndex'); + +export const fetchContext = createThunk( + 'fetchContext', + ({ dispatch, getState }, index, element, fullRefresh = false) => { + const chain = get(element, ['ast', 'chain']); + const invalidIndex = chain ? index >= chain.length : true; + + if (!element || !chain || invalidIndex) throw new Error(`Invalid argument index: ${index}`); + + // cache context as the previous index + const contextIndex = index - 1; + const contextPath = [element.id, 'expressionContext', contextIndex]; + + // set context state to loading + dispatch( + args.setLoading({ + path: contextPath, + }) + ); + + // function to walk back up to find the closest context available + const getContext = () => getSiblingContext(getState(), element.id, contextIndex - 1); + const { index: prevContextIndex, context: prevContextValue } = + fullRefresh !== true ? getContext() : {}; + + // modify the ast chain passed to the interpreter + const astChain = element.ast.chain.filter((exp, i) => { + if (prevContextValue != null) return i > prevContextIndex && i < index; + return i < index; + }); + + // get context data from a partial AST + return interpretAst( + { + ...element.ast, + chain: astChain, + }, + prevContextValue + ).then(value => { + dispatch( + args.setValue({ + path: contextPath, + value, + }) + ); + }); + } +); + +const fetchRenderableWithContextFn = ({ dispatch }, element, ast, context) => { + const argumentPath = [element.id, 'expressionRenderable']; + + dispatch( + args.setLoading({ + path: argumentPath, + }) + ); + + const getAction = renderable => + args.setValue({ + path: argumentPath, + value: renderable, + }); + + return runInterpreter(ast, context, { castToRender: true }) + .then(renderable => { + dispatch(getAction(renderable)); + }) + .catch(err => { + notify.error(err); + dispatch(getAction(err)); + }); +}; + +export const fetchRenderableWithContext = createThunk( + 'fetchRenderableWithContext', + fetchRenderableWithContextFn +); + +export const fetchRenderable = createThunk('fetchRenderable', ({ dispatch }, element) => { + const ast = element.ast || safeElementFromExpression(element.expression); + + dispatch(fetchRenderableWithContext(element, ast, null)); +}); + +export const fetchAllRenderables = createThunk( + 'fetchAllRenderables', + ({ dispatch, getState }, { onlyActivePage = false } = {}) => { + const workpadPages = getPages(getState()); + const currentPageIndex = getSelectedPageIndex(getState()); + + const currentPage = workpadPages[currentPageIndex]; + const otherPages = without(workpadPages, currentPage); + + dispatch(args.inFlightActive()); + + function fetchElementsOnPages(pages) { + const elements = []; + pages.forEach(page => { + page.elements.forEach(element => { + elements.push(element); + }); + }); + + const renderablePromises = elements.map(element => { + const ast = element.ast || safeElementFromExpression(element.expression); + const argumentPath = [element.id, 'expressionRenderable']; + + return runInterpreter(ast, null, { castToRender: true }) + .then(renderable => ({ path: argumentPath, value: renderable })) + .catch(err => { + notify.error(err); + return { path: argumentPath, value: err }; + }); + }); + + return Promise.all(renderablePromises).then(renderables => { + dispatch(args.setValues(renderables)); + }); + } + + if (onlyActivePage) { + fetchElementsOnPages([currentPage]).then(() => dispatch(args.inFlightComplete())); + } else { + fetchElementsOnPages([currentPage]) + .then(() => fetchElementsOnPages(otherPages)) + .then(() => dispatch(args.inFlightComplete())); + } + } +); + +export const duplicateElement = createThunk( + 'duplicateElement', + ({ dispatch, type }, element, pageId) => { + const newElement = { ...getDefaultElement(), ...getBareElement(element) }; + // move the element so users can see that it was added + newElement.position.top = newElement.position.top + 10; + newElement.position.left = newElement.position.left + 10; + const _duplicateElement = createAction(type); + dispatch(_duplicateElement({ pageId, element: newElement })); + + // refresh all elements if there's a filter, otherwise just render the new element + if (element.filter) dispatch(fetchAllRenderables()); + else dispatch(fetchRenderable(newElement)); + + // select the new element + dispatch(selectElement(newElement.id)); + } +); + +export const removeElement = createThunk( + 'removeElement', + ({ dispatch, getState }, elementId, pageId) => { + const element = getElementById(getState(), elementId, pageId); + const shouldRefresh = element.filter != null && element.filter.length > 0; + + const _removeElement = createAction('removeElement', (elementId, pageId) => ({ + pageId, + elementId, + })); + dispatch(_removeElement(elementId, pageId)); + + if (shouldRefresh) dispatch(fetchAllRenderables()); + } +); + +export const setFilter = createThunk( + 'setFilter', + ({ dispatch }, filter, elementId, pageId, doRender = true) => { + const _setFilter = createAction('setFilter'); + dispatch(_setFilter({ filter, elementId, pageId })); + + if (doRender === true) dispatch(fetchAllRenderables()); + } +); + +export const setExpression = createThunk('setExpression', setExpressionFn); +function setExpressionFn({ dispatch, getState }, expression, elementId, pageId, doRender = true) { + // dispatch action to update the element in state + const _setExpression = createAction('setExpression'); + dispatch(_setExpression({ expression, elementId, pageId })); + + // read updated element from state and fetch renderable + const updatedElement = getElementById(getState(), elementId, pageId); + if (doRender === true) dispatch(fetchRenderable(updatedElement)); +} + +const setAst = createThunk('setAst', ({ dispatch }, ast, element, pageId, doRender = true) => { + try { + const expression = toExpression(ast); + dispatch(setExpression(expression, element.id, pageId, doRender)); + } catch (err) { + notify.error(err); + + // TODO: remove this, may have been added just to cause a re-render, but why? + dispatch(setExpression(element.expression, element.id, pageId, doRender)); + } +}); + +// index here is the top-level argument in the expression. for example in the expression +// demodata().pointseries().plot(), demodata is 0, pointseries is 1, and plot is 2 +export const setAstAtIndex = createThunk( + 'setAstAtIndex', + ({ dispatch, getState }, index, ast, element, pageId) => { + // invalidate cached context for elements after this index + dispatch(flushContextAfterIndex({ elementId: element.id, index })); + + const newElement = set(element, ['ast', 'chain', index], ast); + const newAst = get(newElement, 'ast'); + + // fetch renderable using existing context, if available (value is null if not cached) + const { index: contextIndex, context: contextValue } = getSiblingContext( + getState(), + element.id, + index - 1 + ); + + // if we have a cached context, update the expression, but use cache when updating the renderable + if (contextValue) { + // set the expression, but skip the fetchRenderable step + dispatch(setAst(newAst, element, pageId, false)); + + // use context when updating the expression, it will be passed to the intepreter + const partialAst = { + ...newAst, + chain: newAst.chain.filter((exp, i) => { + if (contextValue) return i > contextIndex; + return i >= index; + }), + }; + return dispatch(fetchRenderableWithContext(newElement, partialAst, contextValue)); + } + + // if no cached context, update the ast like normal + dispatch(setAst(newAst, element, pageId)); + } +); + +// index here is the top-level argument in the expression. for example in the expression +// demodata().pointseries().plot(), demodata is 0, pointseries is 1, and plot is 2 +// argIndex is the index in multi-value arguments, and is optional. excluding it will cause +// the entire argument from be set to the passed value +export const setArgumentAtIndex = createThunk('setArgumentAtIndex', ({ dispatch }, args) => { + const { index, argName, value, valueIndex, element, pageId } = args; + const selector = ['ast', 'chain', index, 'arguments', argName]; + if (valueIndex != null) selector.push(valueIndex); + + const newElement = set(element, selector, value); + const newAst = get(newElement, ['ast', 'chain', index]); + dispatch(setAstAtIndex(index, newAst, element, pageId)); +}); + +// index here is the top-level argument in the expression. for example in the expression +// demodata().pointseries().plot(), demodata is 0, pointseries is 1, and plot is 2 +export const addArgumentValueAtIndex = createThunk( + 'addArgumentValueAtIndex', + ({ dispatch }, args) => { + const { index, argName, value, element } = args; + + const values = get(element, ['ast', 'chain', index, 'arguments', argName], []); + const newValue = values.concat(value); + + dispatch( + setArgumentAtIndex({ + ...args, + value: newValue, + }) + ); + } +); + +// index here is the top-level argument in the expression. for example in the expression +// demodata().pointseries().plot(), demodata is 0, pointseries is 1, and plot is 2 +// argIndex is the index in multi-value arguments, and is optional. excluding it will remove +// the entire argument from the expresion +export const deleteArgumentAtIndex = createThunk('deleteArgumentAtIndex', ({ dispatch }, args) => { + const { index, element, pageId, argName, argIndex } = args; + const curVal = get(element, ['ast', 'chain', index, 'arguments', argName]); + + const newElement = + argIndex != null && curVal.length > 1 + ? // if more than one val, remove the specified val + del(element, ['ast', 'chain', index, 'arguments', argName, argIndex]) + : // otherwise, remove the entire key + del(element, ['ast', 'chain', index, 'arguments', argName]); + + dispatch(setAstAtIndex(index, get(newElement, ['ast', 'chain', index]), element, pageId)); +}); + +/* + payload: element defaults. Eg {expression: 'foo'} +*/ +export const addElement = createThunk('addElement', ({ dispatch }, pageId, element) => { + const newElement = { ...getDefaultElement(), ...getBareElement(element) }; + if (element.width) newElement.position.width = element.width; + if (element.height) newElement.position.height = element.height; + const _addElement = createAction('addElement'); + dispatch(_addElement({ pageId, element: newElement })); + + // refresh all elements if there's a filter, otherwise just render the new element + if (element.filter) dispatch(fetchAllRenderables()); + else dispatch(fetchRenderable(newElement)); + + // select the new element + dispatch(selectElement(newElement.id)); +}); diff --git a/x-pack/plugins/canvas/public/state/actions/history.js b/x-pack/plugins/canvas/public/state/actions/history.js new file mode 100644 index 0000000000000..4d9c0722a782a --- /dev/null +++ b/x-pack/plugins/canvas/public/state/actions/history.js @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createAction } from 'redux-actions'; + +export const undoHistory = createAction('undoHistory'); +export const redoHistory = createAction('redoHistory'); +export const restoreHistory = createAction('restoreHistory'); diff --git a/x-pack/plugins/canvas/public/state/actions/pages.js b/x-pack/plugins/canvas/public/state/actions/pages.js new file mode 100644 index 0000000000000..ce3448d2b81a3 --- /dev/null +++ b/x-pack/plugins/canvas/public/state/actions/pages.js @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createAction } from 'redux-actions'; + +export const addPage = createAction('addPage'); +export const duplicatePage = createAction('duplicatePage'); +export const gotoPage = createAction('gotoPage'); +export const movePage = createAction('movePage', (id, position) => ({ id, position })); +export const removePage = createAction('removePage'); +export const stylePage = createAction('stylePage', (pageId, style) => ({ pageId, style })); +export const setPage = createAction('setPage'); +export const setPageTransition = createAction('setPageTransition', (pageId, transition) => ({ + pageId, + transition, +})); diff --git a/x-pack/plugins/canvas/public/state/actions/resolved_args.js b/x-pack/plugins/canvas/public/state/actions/resolved_args.js new file mode 100644 index 0000000000000..2bcea996f2f1e --- /dev/null +++ b/x-pack/plugins/canvas/public/state/actions/resolved_args.js @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createAction } from 'redux-actions'; + +export const setLoading = createAction('setResolvedLoading'); +export const setValue = createAction('setResolvedValue'); +export const setValues = createAction('setResolvedValues'); +export const clear = createAction('clearResolvedValue'); + +export const inFlightActive = createAction('inFlightActive'); +export const inFlightComplete = createAction('inFlightComplete'); diff --git a/x-pack/plugins/canvas/public/state/actions/transient.js b/x-pack/plugins/canvas/public/state/actions/transient.js new file mode 100644 index 0000000000000..19d9a76b6a497 --- /dev/null +++ b/x-pack/plugins/canvas/public/state/actions/transient.js @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createAction } from 'redux-actions'; + +export const setEditing = createAction('setEditing'); +export const setFullscreen = createAction('setFullscreen'); +export const selectElement = createAction('selectElement'); diff --git a/x-pack/plugins/canvas/public/state/actions/workpad.js b/x-pack/plugins/canvas/public/state/actions/workpad.js new file mode 100644 index 0000000000000..5f326176999ea --- /dev/null +++ b/x-pack/plugins/canvas/public/state/actions/workpad.js @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createAction } from 'redux-actions'; +import { createThunk } from 'redux-thunks'; +import { without, includes } from 'lodash'; +import { getWorkpadColors } from '../selectors/workpad'; +import { fetchAllRenderables } from './elements'; + +export const sizeWorkpad = createAction('sizeWorkpad'); +export const setName = createAction('setName'); +export const setColors = createAction('setColors'); +export const setRefreshInterval = createAction('setRefreshInterval'); + +export const initializeWorkpad = createThunk('initializeWorkpad', ({ dispatch }) => { + dispatch(fetchAllRenderables()); +}); + +export const addColor = createThunk('addColor', ({ dispatch, getState }, color) => { + const colors = getWorkpadColors(getState()).slice(0); + if (!includes(colors, color)) colors.push(color); + dispatch(setColors(colors)); +}); + +export const removeColor = createThunk('removeColor', ({ dispatch, getState }, color) => { + dispatch(setColors(without(getWorkpadColors(getState()), color))); +}); + +export const setWorkpad = createThunk( + 'setWorkpad', + ({ dispatch, type }, workpad, { loadPages = true } = {}) => { + dispatch(setRefreshInterval(0)); // disable refresh interval + dispatch(createAction(type)(workpad)); // set the workpad object in state + if (loadPages) dispatch(initializeWorkpad()); // load all the elements on the workpad + } +); diff --git a/x-pack/plugins/canvas/public/state/defaults.js b/x-pack/plugins/canvas/public/state/defaults.js new file mode 100644 index 0000000000000..41f79ca60490c --- /dev/null +++ b/x-pack/plugins/canvas/public/state/defaults.js @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getId } from '../lib/get_id'; + +export const getDefaultElement = () => { + return { + id: getId('element'), + position: { + top: 20, + left: 20, + height: 300, + width: 500, + angle: 0, + }, + expression: ` + demodata + | pointseries y="median(cost)" x=time color="project" + | plot defaultStyle={seriesStyle points=5} + `, + filter: null, + }; +}; + +export const getDefaultPage = () => { + return { + id: getId('page'), + style: { + background: '#fff', + }, + transition: {}, + elements: [], + }; +}; + +export const getDefaultWorkpad = () => { + const page = getDefaultPage(); + return { + name: 'Untitled Workpad', + id: getId('workpad'), + width: 1080, + height: 720, + page: 0, + pages: [page], + colors: [ + '#37988d', + '#c19628', + '#b83c6f', + '#3f9939', + '#1785b0', + '#ca5f35', + '#45bdb0', + '#f2bc33', + '#e74b8b', + '#4fbf48', + '#1ea6dc', + '#fd7643', + '#72cec3', + '#f5cc5d', + '#ec77a8', + '#7acf74', + '#4cbce4', + '#fd986f', + '#a1ded7', + '#f8dd91', + '#f2a4c5', + '#a6dfa2', + '#86d2ed', + '#fdba9f', + '#000000', + '#444444', + '#777777', + '#BBBBBB', + '#FFFFFF', + 'rgba(255,255,255,0)', // 'transparent' + ], + }; +}; diff --git a/x-pack/plugins/canvas/public/state/initial_state.js b/x-pack/plugins/canvas/public/state/initial_state.js new file mode 100644 index 0000000000000..c9bf04185bd17 --- /dev/null +++ b/x-pack/plugins/canvas/public/state/initial_state.js @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get } from 'lodash'; +import { getDefaultWorkpad } from './defaults'; + +export const getInitialState = path => { + const state = { + app: {}, // Kibana stuff in here + transient: { + editing: true, + fullscreen: false, + selectedElement: null, + resolvedArgs: {}, + refresh: { + interval: 0, + }, + // values in resolvedArgs should live under a unique index so they can be looked up. + // The ID of the element is a great example. + // In there will live an object with a status (string), value (any), and error (Error) property. + // If the state is 'error', the error proprty will be the error object, the value will not change + // See the resolved_args reducer for more information. + }, + persistent: { + schemaVersion: 1, + workpad: getDefaultWorkpad(), + }, + }; + + if (!path) return state; + + return get(state, path); +}; diff --git a/x-pack/plugins/canvas/public/state/middleware/aeroelastic.js b/x-pack/plugins/canvas/public/state/middleware/aeroelastic.js new file mode 100644 index 0000000000000..85647b4e9a64f --- /dev/null +++ b/x-pack/plugins/canvas/public/state/middleware/aeroelastic.js @@ -0,0 +1,233 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { shallowEqual } from 'recompose'; +import { aeroelastic as aero } from '../../lib/aeroelastic_kibana'; +import { matrixToAngle } from '../../lib/aeroelastic/matrix'; +import { + addElement, + removeElement, + duplicateElement, + elementLayer, + setPosition, + fetchAllRenderables, +} from '../actions/elements'; +import { restoreHistory } from '../actions/history'; +import { selectElement } from '../actions/transient'; +import { addPage, removePage, duplicatePage } from '../actions/pages'; +import { appReady } from '../actions/app'; +import { setWorkpad } from '../actions/workpad'; +import { getElements, getPages, getSelectedPage, getSelectedElement } from '../selectors/workpad'; + +/** + * elementToShape + * + * converts a `kibana-canvas` element to an `aeroelastic` shape. + * + * Shape: the layout algorithms need to deal with objects through their geometric properties, excluding other aspects, + * such as what's inside the element, eg. image or scatter plot. This representation is, at its core, a transform matrix + * that establishes a new local coordinate system https://drafts.csswg.org/css-transforms/#local-coordinate-system plus a + * size descriptor. There are two versions of the transform matrix: + * - `transformMatrix` is analogous to the SVG https://drafts.csswg.org/css-transforms/#current-transformation-matrix + * - `localTransformMatrix` is analogous to the SVG https://drafts.csswg.org/css-transforms/#transformation-matrix + * + * Element: it also needs to represent the geometry, primarily because of the need to persist it in `redux` and on the + * server, and to accept such data from the server. The redux and server representations will need to change as more general + * projections such as 3D are added. The element also needs to maintain its content, such as an image or a plot. + * + * While all elements on the current page also exist as shapes, there are shapes that are not elements: annotations. + * For example, `rotation_handle`, `border_resize_handle` and `border_connection` are modeled as shapes by the layout + * library, simply for generality. + */ +const elementToShape = (element, i) => { + const position = element.position; + const a = position.width / 2; + const b = position.height / 2; + const cx = position.left + a; + const cy = position.top + b; + const z = i; // painter's algo: latest item goes to top + // multiplying the angle with -1 as `transform: matrix3d` uses a left-handed coordinate system + const angleRadians = (-position.angle / 180) * Math.PI; + const transformMatrix = aero.matrix.multiply( + aero.matrix.translate(cx, cy, z), + aero.matrix.rotateZ(angleRadians) + ); + return { + id: element.id, + parent: null, // reserved for hierarchical (tree shaped) grouping, + localTransformMatrix: transformMatrix, + transformMatrix, + a, // we currently specify half-width, half-height as it leads to + b, // more regular math (like ellipsis radii rather than diameters) + }; +}; + +const updateGlobalPositions = (setPosition, { shapes, gestureEnd }, elems) => { + shapes.forEach((shape, i) => { + const elemPos = elems[i] && elems[i].position; + if (elemPos && gestureEnd) { + // get existing position information from element + const oldProps = { + left: elemPos.left, + top: elemPos.top, + width: elemPos.width, + height: elemPos.height, + angle: Math.round(elemPos.angle), + }; + + // cast shape into element-like object to compare + const newProps = { + left: shape.transformMatrix[12] - shape.a, + top: shape.transformMatrix[13] - shape.b, + width: shape.a * 2, + height: shape.b * 2, + angle: Math.round(matrixToAngle(shape.transformMatrix)), + }; + + if (!shallowEqual(oldProps, newProps)) setPosition(shape.id, newProps); + } + }); +}; + +const id = element => element.id; + +export const aeroelastic = ({ dispatch, getState }) => { + // When aeroelastic updates an element, we need to dispatch actions to notify redux of the changes + // dispatch(setPosition({ ... })); + + const onChangeCallback = ({ state }) => { + const nextScene = state.currentScene; + if (!nextScene.gestureEnd) return; // only update redux on gesture end + // TODO: check for gestureEnd on element selection + + // read current data out of redux + const page = getSelectedPage(getState()); + const elements = getElements(getState(), page); + const selectedElement = getSelectedElement(getState()); + + updateGlobalPositions( + (elementId, position) => dispatch(setPosition(elementId, page, position)), + nextScene, + elements + ); + + // set the selected element on the global store, if one element is selected + const selectedShape = nextScene.selectedPrimaryShapes[0]; + if (nextScene.selectedShapes.length === 1) { + if (selectedShape && selectedShape !== selectedElement) + dispatch(selectElement(selectedShape)); + } else { + // otherwise, clear the selected element state + dispatch(selectElement(null)); + } + }; + + const createStore = page => + aero.createStore( + { shapeAdditions: [], primaryUpdate: null, currentScene: { shapes: [] } }, + onChangeCallback, + page + ); + + const populateWithElements = page => + aero.commit( + page, + 'restateShapesEvent', + { newShapes: getElements(getState(), page).map(elementToShape) }, + { silent: true } + ); + + const selectShape = (page, id) => { + aero.commit(page, 'shapeSelect', { shapes: [id] }); + }; + + const unselectShape = page => { + aero.commit(page, 'shapeSelect', { shapes: [] }); + }; + + return next => action => { + // get information before the state is changed + const prevPage = getSelectedPage(getState()); + const prevElements = getElements(getState(), prevPage); + + if (action.type === setWorkpad.toString()) { + const pages = action.payload.pages; + aero.clearStores(); + // Create the aeroelastic store, which happens once per page creation; disposed on workbook change. + // TODO: consider implementing a store removal upon page removal to reclaim a small amount of storage + pages.map(p => p.id).forEach(createStore); + } + + if (action.type === appReady.toString()) { + const pages = getPages(getState()); + aero.clearStores(); + pages.map(p => p.id).forEach(createStore); + } + + let lastPageRemoved = false; + if (action.type === removePage.toString()) { + const preRemoveState = getState(); + if (getPages(preRemoveState).length <= 1) lastPageRemoved = true; + + aero.removeStore(action.payload); + } + + next(action); + + switch (action.type) { + case appReady.toString(): + case restoreHistory.toString(): + case setWorkpad.toString(): + // Populate the aeroelastic store, which only happens once per page creation; disposed on workbook change. + getPages(getState()) + .map(p => p.id) + .forEach(populateWithElements); + break; + + case addPage.toString(): + case duplicatePage.toString(): + const newPage = getSelectedPage(getState()); + createStore(newPage); + if (action.type === duplicatePage.toString()) dispatch(fetchAllRenderables()); + + populateWithElements(newPage); + break; + + case removePage.toString(): + const postRemoveState = getState(); + if (lastPageRemoved) { + const freshPage = getSelectedPage(postRemoveState); + createStore(freshPage); + } + break; + + case selectElement.toString(): + // without this condition, a mouse release anywhere will trigger it, leading to selection of whatever is + // underneath the pointer (maybe nothing) when the mouse is released + if (action.payload) selectShape(prevPage, action.payload); + else unselectShape(prevPage); + + break; + + case removeElement.toString(): + case addElement.toString(): + case duplicateElement.toString(): + case elementLayer.toString(): + case setPosition.toString(): + const page = getSelectedPage(getState()); + const elements = getElements(getState(), page); + + // TODO: add a better check for elements changing, including their position, ids, etc. + const shouldResetState = + prevPage !== page || !shallowEqual(prevElements.map(id), elements.map(id)); + if (shouldResetState) populateWithElements(page); + + if (action.type !== setPosition.toString()) unselectShape(prevPage); + + break; + } + }; +}; diff --git a/x-pack/plugins/canvas/public/state/middleware/app_ready.js b/x-pack/plugins/canvas/public/state/middleware/app_ready.js new file mode 100644 index 0000000000000..af3d713d762c7 --- /dev/null +++ b/x-pack/plugins/canvas/public/state/middleware/app_ready.js @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { isAppReady } from '../selectors/app'; +import { appReady as readyAction } from '../actions/app'; + +export const appReady = ({ dispatch, getState }) => next => action => { + // execute the action + next(action); + + // read the new state + const state = getState(); + + // if app is already ready, there's nothing more to do here + if (state.app.ready) return; + + // check for all conditions in the state that indicate that the app is ready + if (isAppReady(state)) dispatch(readyAction()); +}; diff --git a/x-pack/plugins/canvas/public/state/middleware/es_persist.js b/x-pack/plugins/canvas/public/state/middleware/es_persist.js new file mode 100644 index 0000000000000..e36f5d3586f20 --- /dev/null +++ b/x-pack/plugins/canvas/public/state/middleware/es_persist.js @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { isEqual } from 'lodash'; +import { getWorkpad, getWorkpadPersisted } from '../selectors/workpad'; +import { getAssetIds } from '../selectors/assets'; +import { setWorkpad } from '../actions/workpad'; +import { setAssets, resetAssets } from '../actions/assets'; +import * as transientActions from '../actions/transient'; +import * as resolvedArgsActions from '../actions/resolved_args'; +import { update } from '../../lib/workpad_service'; +import { notify } from '../../lib/notify'; + +const workpadChanged = (before, after) => { + const workpad = getWorkpad(before); + return getWorkpad(after) !== workpad; +}; + +const assetsChanged = (before, after) => { + const assets = getAssetIds(before); + return !isEqual(assets, getAssetIds(after)); +}; + +export const esPersistMiddleware = ({ getState }) => { + // these are the actions we don't want to trigger a persist call + const skippedActions = [ + setWorkpad, // used for loading and creating workpads + setAssets, // used when loading assets + resetAssets, // used when creating new workpads + ...Object.values(resolvedArgsActions), // no resolved args affect persisted values + ...Object.values(transientActions), // no transient actions cause persisted state changes + ].map(a => a.toString()); + + return next => action => { + // if the action is in the skipped list, do not persist + if (skippedActions.indexOf(action.type) >= 0) return next(action); + + // capture state before and after the action + const curState = getState(); + next(action); + const newState = getState(); + + // if the workpad changed, save it to elasticsearch + if (workpadChanged(curState, newState) || assetsChanged(curState, newState)) { + const persistedWorkpad = getWorkpadPersisted(getState()); + return update(persistedWorkpad.id, persistedWorkpad).catch(err => { + if (err.response.status === 400) { + return notify.error(err.response, { + title: `Couldn't save your changes to Elasticsearch`, + }); + } + + if (err.response.status === 413) { + return notify.error( + `The server gave a response that the workpad data was too large. This + usually means uploaded image assets that are too large for Kibana or + a proxy. Try removing some assets in the asset manager.`, + { + title: `Couldn't save your changes to Elasticsearch`, + } + ); + } + + return notify.error(err.response, { + title: `Couldn't update workpad`, + }); + }); + } + }; +}; diff --git a/x-pack/plugins/canvas/public/state/middleware/fullscreen.js b/x-pack/plugins/canvas/public/state/middleware/fullscreen.js new file mode 100644 index 0000000000000..5f3e319392713 --- /dev/null +++ b/x-pack/plugins/canvas/public/state/middleware/fullscreen.js @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { setFullscreen } from '../../lib/fullscreen'; +import { setFullscreen as setFullscreenAction } from '../actions/transient'; +import { getFullscreen } from '../selectors/app'; + +export const fullscreen = ({ getState }) => next => action => { + // execute the default action + next(action); + + // pass current state's fullscreen info to the fullscreen service + if (action.type === setFullscreenAction.toString()) { + const fullscreen = getFullscreen(getState()); + setFullscreen(fullscreen); + } +}; diff --git a/x-pack/plugins/canvas/public/state/middleware/history.js b/x-pack/plugins/canvas/public/state/middleware/history.js new file mode 100644 index 0000000000000..659b8a640c99c --- /dev/null +++ b/x-pack/plugins/canvas/public/state/middleware/history.js @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { isEqual } from 'lodash'; +import { routes } from '../../apps'; +import { historyProvider } from '../../lib/history_provider'; +import { routerProvider } from '../../lib/router_provider'; +import { restoreHistory, undoHistory, redoHistory } from '../actions/history'; +import { initializeWorkpad } from '../actions/workpad'; +import { isAppReady } from '../selectors/app'; + +export const historyMiddleware = ({ dispatch, getState }) => { + // iterate over routes, injecting redux to action handlers + const reduxInject = routes => { + return routes.map(route => { + if (route.children) { + return { + ...route, + children: reduxInject(route.children), + }; + } + + if (!route.action) return route; + + return { + ...route, + action: route.action(dispatch, getState), + }; + }); + }; + + const handlerState = { + pendingCount: 0, + }; + + // wrap up the application route actions in redux + const router = routerProvider(reduxInject(routes)); + const history = historyProvider(); + + // wire up history change handler (this only happens once) + const handleHistoryChanges = async (location, prevLocation) => { + const { pathname, state: historyState, action: historyAction } = location; + // pop state will fire on any hash-based url change, but only back/forward will have state + const isBrowserNav = historyAction === 'pop' && historyState != null; + const isUrlChange = + (!isBrowserNav && historyAction === 'pop') || + ((historyAction === 'push' || historyAction === 'replace') && + prevLocation.pathname !== pathname); + + // only restore the history on popState events with state + // this only happens when using back/forward with popState objects + if (isBrowserNav) return dispatch(restoreHistory(historyState)); + + // execute route action on pushState and popState events + if (isUrlChange) return await router.parse(pathname); + }; + + history.onChange(async (...args) => { + // use history replace until any async handlers are completed + handlerState.pendingCount += 1; + + try { + await handleHistoryChanges(...args); + } catch (e) { + // TODO: handle errors here + } finally { + // restore default history method + handlerState.pendingCount -= 1; + } + }); + + return next => action => { + const oldState = getState(); + + // deal with history actions + switch (action.type) { + case undoHistory.toString(): + return history.undo(); + case redoHistory.toString(): + return history.redo(); + case restoreHistory.toString(): + // skip state compare, simply execute the action + next(action); + // TODO: we shouldn't need to reset the entire workpad for undo/redo + dispatch(initializeWorkpad()); + return; + } + + // execute the action like normal + next(action); + const newState = getState(); + + // if the app is not ready, don't persist anything + if (!isAppReady(newState)) return; + + // if app switched from not ready to ready, replace current state + // this allows the back button to work correctly all the way to first page load + if (!isAppReady(oldState) && isAppReady(newState)) { + history.replace(newState.persistent); + return; + } + + // if the persistent state changed, push it into the history + if (!isEqual(newState.persistent, oldState.persistent)) { + const useReplaceState = handlerState.pendingCount !== 0; + useReplaceState ? history.replace(newState.persistent) : history.push(newState.persistent); + } + }; +}; diff --git a/x-pack/plugins/canvas/public/state/middleware/in_flight.js b/x-pack/plugins/canvas/public/state/middleware/in_flight.js new file mode 100644 index 0000000000000..e8b7105b43e82 --- /dev/null +++ b/x-pack/plugins/canvas/public/state/middleware/in_flight.js @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { convert } from '../../lib/modify_path'; +import { setLoading, setValue, inFlightActive, inFlightComplete } from '../actions/resolved_args'; + +export const inFlight = ({ dispatch }) => next => { + const pendingCache = []; + + return action => { + const isLoading = action.type === setLoading.toString(); + const isSetting = action.type === setValue.toString(); + + if (isLoading || isSetting) { + const cacheKey = convert(action.payload.path).join('/'); + + if (isLoading) { + pendingCache.push(cacheKey); + dispatch(inFlightActive()); + } else if (isSetting) { + const idx = pendingCache.indexOf(cacheKey); + pendingCache.splice(idx, 1); + if (pendingCache.length === 0) dispatch(inFlightComplete()); + } + } + + // execute the action + next(action); + }; +}; diff --git a/x-pack/plugins/canvas/public/state/middleware/index.js b/x-pack/plugins/canvas/public/state/middleware/index.js new file mode 100644 index 0000000000000..15bea92d5e321 --- /dev/null +++ b/x-pack/plugins/canvas/public/state/middleware/index.js @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { applyMiddleware, compose } from 'redux'; +import thunkMiddleware from 'redux-thunk'; +import { getWindow } from '../../lib/get_window'; +import { aeroelastic } from './aeroelastic'; +import { esPersistMiddleware } from './es_persist'; +import { fullscreen } from './fullscreen'; +import { historyMiddleware } from './history'; +import { inFlight } from './in_flight'; +import { workpadUpdate } from './workpad_update'; +import { workpadRefresh } from './workpad_refresh'; +import { appReady } from './app_ready'; + +const middlewares = [ + applyMiddleware( + thunkMiddleware, + esPersistMiddleware, + historyMiddleware, + aeroelastic, + fullscreen, + inFlight, + appReady, + workpadUpdate, + workpadRefresh + ), +]; + +// intitialize redux devtools if extension is installed +if (getWindow().__REDUX_DEVTOOLS_EXTENSION__) + middlewares.push(getWindow().__REDUX_DEVTOOLS_EXTENSION__()); + +export const middleware = compose(...middlewares); diff --git a/x-pack/plugins/canvas/public/state/middleware/workpad_refresh.js b/x-pack/plugins/canvas/public/state/middleware/workpad_refresh.js new file mode 100644 index 0000000000000..12752ab08734a --- /dev/null +++ b/x-pack/plugins/canvas/public/state/middleware/workpad_refresh.js @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { fetchAllRenderables } from '../actions/elements'; +import { setRefreshInterval } from '../actions/workpad'; +import { inFlightComplete } from '../actions/resolved_args'; +import { getInFlight } from '../selectors/resolved_args'; + +export const workpadRefresh = ({ dispatch, getState }) => next => { + let refreshTimeout; + let refreshInterval = 0; + + function updateWorkpad() { + if (refreshInterval === 0) return; + + // check the request in flight status + const inFlightActive = getInFlight(getState()); + if (inFlightActive) { + // if requests are in-flight, start the refresh delay again + startDelayedUpdate(); + } else { + // update the elements on the workpad + dispatch(fetchAllRenderables()); + } + } + + function startDelayedUpdate() { + clearTimeout(refreshTimeout); // cancel any pending update requests + refreshTimeout = setTimeout(() => { + updateWorkpad(); + }, refreshInterval); + } + + return action => { + next(action); + + // when in-flight requests are finished, update the workpad after a given delay + if (action.type === inFlightComplete.toString() && refreshInterval > 0) startDelayedUpdate(); // create new update request + + // This middleware creates or destroys an interval that will cause workpad elements to update + if (action.type === setRefreshInterval.toString()) { + // update the refresh interval + refreshInterval = action.payload; + + // clear any pending timeout + clearTimeout(refreshTimeout); + + // if interval is larger than 0, start the delayed update + if (refreshInterval > 0) startDelayedUpdate(); + } + }; +}; diff --git a/x-pack/plugins/canvas/public/state/middleware/workpad_update.js b/x-pack/plugins/canvas/public/state/middleware/workpad_update.js new file mode 100644 index 0000000000000..500f328b082a0 --- /dev/null +++ b/x-pack/plugins/canvas/public/state/middleware/workpad_update.js @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { duplicatePage } from '../actions/pages'; +import { fetchRenderable } from '../actions/elements'; +import { getPages } from '../selectors/workpad'; + +export const workpadUpdate = ({ dispatch, getState }) => next => action => { + next(action); + + // This middleware fetches all of the renderable elements on new, duplicate page + if (action.type === duplicatePage.toString()) { + // When a page has been duplicated, it will be added as the last page, so fetch it + const pages = getPages(getState()); + const newPage = pages[pages.length - 1]; + + // For each element on that page, dispatch the action to update it + return newPage.elements.forEach(element => dispatch(fetchRenderable(element))); + } +}; diff --git a/x-pack/plugins/canvas/public/state/reducers/__tests__/elements.js b/x-pack/plugins/canvas/public/state/reducers/__tests__/elements.js new file mode 100644 index 0000000000000..0a5db91198966 --- /dev/null +++ b/x-pack/plugins/canvas/public/state/reducers/__tests__/elements.js @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { get } from 'lodash'; +import { elementsReducer } from '../elements'; +import { actionCreator } from './fixtures/action_creator'; + +describe('elements reducer', () => { + let state; + + beforeEach(() => { + state = { + id: 'workpad-1', + pages: [ + { + id: 'page-1', + elements: [ + { + id: 'element-0', + expression: '', + }, + { + id: 'element-1', + expression: 'demodata', + }, + ], + }, + ], + }; + }); + + it('expressionActions update element expression by id', () => { + const expression = 'test expression'; + const expected = { + id: 'element-1', + expression, + }; + const action = actionCreator('setExpression')({ + expression, + elementId: 'element-1', + pageId: 'page-1', + }); + + const newState = elementsReducer(state, action); + const newElement = get(newState, ['pages', 0, 'elements', 1]); + + expect(newElement).to.eql(expected); + }); +}); diff --git a/x-pack/plugins/canvas/public/state/reducers/__tests__/fixtures/action_creator.js b/x-pack/plugins/canvas/public/state/reducers/__tests__/fixtures/action_creator.js new file mode 100644 index 0000000000000..b95186cdce99e --- /dev/null +++ b/x-pack/plugins/canvas/public/state/reducers/__tests__/fixtures/action_creator.js @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const actionCreator = type => { + return (payload, error = null, meta = null) => ({ type, payload, error, meta }); +}; diff --git a/x-pack/plugins/canvas/public/state/reducers/__tests__/resolved_args.js b/x-pack/plugins/canvas/public/state/reducers/__tests__/resolved_args.js new file mode 100644 index 0000000000000..6172f004cff4a --- /dev/null +++ b/x-pack/plugins/canvas/public/state/reducers/__tests__/resolved_args.js @@ -0,0 +1,194 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import * as actions from '../../actions/resolved_args'; +import { flushContextAfterIndex } from '../../actions/elements'; +import { resolvedArgsReducer } from '../resolved_args'; +import { actionCreator } from './fixtures/action_creator'; + +describe('resolved args reducer', () => { + let state; + + beforeEach(() => { + state = { + selectedPage: 'page-1', + selectedElement: 'element-1', + resolvedArgs: { + 'element-0': [ + { + state: 'ready', + value: 'testing', + error: null, + }, + { + state: 'error', + value: 'old value', + error: new Error('error test'), + }, + ], + }, + }; + }); + + describe('setLoading', () => { + it('sets state to loading, with string path', () => { + const action = actionCreator(actions.setLoading)({ + path: 'element-1.0', + }); + + const newState = resolvedArgsReducer(state, action); + expect(newState.resolvedArgs['element-1']).to.eql([ + { + state: 'pending', + value: null, + error: null, + }, + ]); + }); + + it('sets state to loading, with array path', () => { + const action = actionCreator(actions.setLoading)({ + path: ['element-1', 0], + }); + + const newState = resolvedArgsReducer(state, action); + expect(newState.resolvedArgs['element-1']).to.eql([ + { + state: 'pending', + value: null, + error: null, + }, + ]); + }); + }); + + describe('setValue', () => { + it('sets value and state', () => { + const value = 'hello world'; + const action = actionCreator(actions.setValue)({ + path: 'element-1.0', + value, + }); + + const newState = resolvedArgsReducer(state, action); + expect(newState.resolvedArgs['element-1']).to.eql([ + { + state: 'ready', + value, + error: null, + }, + ]); + }); + + it('handles error values', () => { + const err = new Error('farewell world'); + const action = actionCreator(actions.setValue)({ + path: 'element-1.0', + value: err, + }); + + const newState = resolvedArgsReducer(state, action); + expect(newState.resolvedArgs['element-1']).to.eql([ + { + state: 'error', + value: null, + error: err, + }, + ]); + }); + + it('preserves old value on error', () => { + const err = new Error('farewell world'); + const action = actionCreator(actions.setValue)({ + path: 'element-0.0', + value: err, + }); + + const newState = resolvedArgsReducer(state, action); + expect(newState.resolvedArgs['element-0'][0]).to.eql({ + state: 'error', + value: 'testing', + error: err, + }); + }); + }); + + describe('clear', () => { + it('removed resolved value at path', () => { + const action = actionCreator(actions.clear)({ + path: 'element-0.1', + }); + + const newState = resolvedArgsReducer(state, action); + expect(newState.resolvedArgs['element-0']).to.have.length(1); + expect(newState.resolvedArgs['element-0']).to.eql([ + { + state: 'ready', + value: 'testing', + error: null, + }, + ]); + }); + + it('deeply removes resolved values', () => { + const action = actionCreator(actions.clear)({ + path: 'element-0', + }); + + const newState = resolvedArgsReducer(state, action); + expect(newState.resolvedArgs['element-0']).to.be(undefined); + }); + }); + + describe('flushContextAfterIndex', () => { + it('removes expression context from a given index to the end', () => { + state = { + selectedPage: 'page-1', + selectedElement: 'element-1', + resolvedArgs: { + 'element-1': { + expressionContext: { + '1': { + state: 'ready', + value: 'test-1', + error: null, + }, + '2': { + state: 'ready', + value: 'test-2', + error: null, + }, + '3': { + state: 'ready', + value: 'test-3', + error: null, + }, + '4': { + state: 'ready', + value: 'test-4', + error: null, + }, + }, + }, + }, + }; + + const action = actionCreator(flushContextAfterIndex)({ + elementId: 'element-1', + index: 3, + }); + + const newState = resolvedArgsReducer(state, action); + expect(newState.resolvedArgs['element-1']).to.eql({ + expressionContext: { + '1': { state: 'ready', value: 'test-1', error: null }, + '2': { state: 'ready', value: 'test-2', error: null }, + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/canvas/public/state/reducers/app.js b/x-pack/plugins/canvas/public/state/reducers/app.js new file mode 100644 index 0000000000000..8027bac4e1b09 --- /dev/null +++ b/x-pack/plugins/canvas/public/state/reducers/app.js @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { handleActions } from 'redux-actions'; +import { appReady, appError } from '../actions/app'; + +export const appReducer = handleActions( + { + [appReady]: appState => ({ ...appState, ready: true }), + [appError]: (appState, { payload }) => ({ ...appState, ready: payload }), + }, + {} +); diff --git a/x-pack/plugins/canvas/public/state/reducers/assets.js b/x-pack/plugins/canvas/public/state/reducers/assets.js new file mode 100644 index 0000000000000..587f40dbe186f --- /dev/null +++ b/x-pack/plugins/canvas/public/state/reducers/assets.js @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { handleActions, combineActions } from 'redux-actions'; +import { set, assign, del } from 'object-path-immutable'; +import { get } from 'lodash'; +import { createAsset, setAssetValue, removeAsset, setAssets, resetAssets } from '../actions/assets'; +import { getId } from '../../lib/get_id'; + +export const assetsReducer = handleActions( + { + [createAsset]: (assetState, { payload }) => { + const asset = { + id: getId('asset'), + '@created': new Date().toISOString(), + ...payload, + }; + return set(assetState, asset.id, asset); + }, + + [setAssetValue]: (assetState, { payload }) => { + const { id, value } = payload; + const asset = get(assetState, [id]); + if (!asset) throw new Error(`Can not set asset data, id not found: ${id}`); + return assign(assetState, id, { value }); + }, + + [removeAsset]: (assetState, { payload: assetId }) => { + return del(assetState, assetId); + }, + + [combineActions(setAssets, resetAssets)]: (assetState, { payload }) => payload || {}, + }, + {} +); diff --git a/x-pack/plugins/canvas/public/state/reducers/elements.js b/x-pack/plugins/canvas/public/state/reducers/elements.js new file mode 100644 index 0000000000000..3dea1cb020485 --- /dev/null +++ b/x-pack/plugins/canvas/public/state/reducers/elements.js @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { handleActions } from 'redux-actions'; +import { assign, push, del, set } from 'object-path-immutable'; +import { get } from 'lodash'; +import * as actions from '../actions/elements'; + +function getPageIndexById(workpadState, pageId) { + return get(workpadState, 'pages', []).findIndex(page => page.id === pageId); +} + +function getElementIndexById(page, elementId) { + return page.elements.findIndex(element => element.id === elementId); +} + +function assignElementProperties(workpadState, pageId, elementId, props) { + const pageIndex = getPageIndexById(workpadState, pageId); + const elementsPath = ['pages', pageIndex, 'elements']; + const elementIndex = get(workpadState, elementsPath, []).findIndex( + element => element.id === elementId + ); + + if (pageIndex === -1 || elementIndex === -1) return workpadState; + + // remove any AST value from the element caused by https://github.com/elastic/kibana-canvas/issues/260 + // TODO: remove this after a bit of time + const cleanWorkpadState = del(workpadState, elementsPath.concat([elementIndex, 'ast'])); + + return assign(cleanWorkpadState, elementsPath.concat(elementIndex), props); +} + +function moveElementLayer(workpadState, pageId, elementId, movement) { + const pageIndex = getPageIndexById(workpadState, pageId); + const elementIndex = getElementIndexById(workpadState.pages[pageIndex], elementId); + const elements = get(workpadState, ['pages', pageIndex, 'elements']); + const from = elementIndex; + + const to = (function() { + if (movement < Infinity && movement > -Infinity) return elementIndex + movement; + if (movement === Infinity) return elements.length - 1; + if (movement === -Infinity) return 0; + throw new Error('Invalid element layer movement'); + })(); + + if (to > elements.length - 1 || to < 0) return workpadState; + + // Common + const newElements = elements.slice(0); + newElements.splice(to, 0, newElements.splice(from, 1)[0]); + + return set(workpadState, ['pages', pageIndex, 'elements'], newElements); +} + +export const elementsReducer = handleActions( + { + // TODO: This takes the entire element, which is not neccesary, it could just take the id. + [actions.setExpression]: (workpadState, { payload }) => { + const { expression, pageId, elementId } = payload; + return assignElementProperties(workpadState, pageId, elementId, { expression }); + }, + [actions.setFilter]: (workpadState, { payload }) => { + const { filter, pageId, elementId } = payload; + return assignElementProperties(workpadState, pageId, elementId, { filter }); + }, + [actions.setPosition]: (workpadState, { payload }) => { + const { position, pageId, elementId } = payload; + return assignElementProperties(workpadState, pageId, elementId, { position }); + }, + [actions.elementLayer]: (workpadState, { payload: { pageId, elementId, movement } }) => { + return moveElementLayer(workpadState, pageId, elementId, movement); + }, + [actions.addElement]: (workpadState, { payload: { pageId, element } }) => { + const pageIndex = getPageIndexById(workpadState, pageId); + if (pageIndex < 0) return workpadState; + + return push(workpadState, ['pages', pageIndex, 'elements'], element); + }, + [actions.duplicateElement]: (workpadState, { payload: { pageId, element } }) => { + const pageIndex = getPageIndexById(workpadState, pageId); + if (pageIndex < 0) return workpadState; + + return push(workpadState, ['pages', pageIndex, 'elements'], element); + }, + [actions.removeElement]: (workpadState, { payload: { pageId, elementId } }) => { + const pageIndex = getPageIndexById(workpadState, pageId); + if (pageIndex < 0) return workpadState; + + const elementIndex = getElementIndexById(workpadState.pages[pageIndex], elementId); + if (elementIndex < 0) return workpadState; + + return del(workpadState, ['pages', pageIndex, 'elements', elementIndex]); + }, + }, + {} +); diff --git a/x-pack/plugins/canvas/public/state/reducers/history.js b/x-pack/plugins/canvas/public/state/reducers/history.js new file mode 100644 index 0000000000000..dd99c18192911 --- /dev/null +++ b/x-pack/plugins/canvas/public/state/reducers/history.js @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { handleActions } from 'redux-actions'; +import { restoreHistory } from '../actions/history'; + +export const historyReducer = handleActions( + { + [restoreHistory]: (persistedState, { payload }) => payload, + }, + {} +); diff --git a/x-pack/plugins/canvas/public/state/reducers/index.js b/x-pack/plugins/canvas/public/state/reducers/index.js new file mode 100644 index 0000000000000..b60a0a3b32656 --- /dev/null +++ b/x-pack/plugins/canvas/public/state/reducers/index.js @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { combineReducers } from 'redux'; +import reduceReducers from 'reduce-reducers'; +import { get } from 'lodash'; + +import { appReducer } from './app'; +import { transientReducer } from './transient'; +import { resolvedArgsReducer } from './resolved_args'; +import { workpadReducer } from './workpad'; +import { pagesReducer } from './pages'; +import { elementsReducer } from './elements'; +import { assetsReducer } from './assets'; +import { historyReducer } from './history'; + +export function getRootReducer(initialState) { + return combineReducers({ + assets: assetsReducer, + app: appReducer, + transient: reduceReducers(transientReducer, resolvedArgsReducer), + persistent: reduceReducers( + historyReducer, + combineReducers({ + workpad: reduceReducers(workpadReducer, pagesReducer, elementsReducer), + schemaVersion: (state = get(initialState, 'persistent.schemaVersion')) => state, + }) + ), + }); +} diff --git a/x-pack/plugins/canvas/public/state/reducers/pages.js b/x-pack/plugins/canvas/public/state/reducers/pages.js new file mode 100644 index 0000000000000..8be944f253d30 --- /dev/null +++ b/x-pack/plugins/canvas/public/state/reducers/pages.js @@ -0,0 +1,149 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { handleActions } from 'redux-actions'; +import { set, del, insert } from 'object-path-immutable'; +import { getId } from '../../lib/get_id'; +import { routerProvider } from '../../lib/router_provider'; +import { getDefaultPage } from '../defaults'; +import * as actions from '../actions/pages'; + +function setPageIndex(workpadState, index) { + if (index < 0 || !workpadState.pages[index]) return workpadState; + return set(workpadState, 'page', index); +} + +function getPageIndexById(workpadState, id) { + return workpadState.pages.findIndex(page => page.id === id); +} + +function addPage(workpadState, payload, srcIndex = workpadState.pages.length - 1) { + return insert(workpadState, 'pages', payload || getDefaultPage(), srcIndex + 1); +} + +function clonePage(page) { + // TODO: would be nice if we could more reliably know which parameters need to get a unique id + // this makes a pretty big assumption about the shape of the page object + return { + ...page, + id: getId('page'), + elements: page.elements.map(element => ({ ...element, id: getId('element') })), + }; +} + +export const pagesReducer = handleActions( + { + [actions.addPage]: (workpadState, { payload }) => { + const { page: activePage } = workpadState; + const withNewPage = addPage(workpadState, payload, activePage); + const newState = setPageIndex(withNewPage, activePage + 1); + + // changes to the page require navigation + const router = routerProvider(); + router.navigateTo('loadWorkpad', { id: newState.id, page: newState.page + 1 }); + + return newState; + }, + + [actions.duplicatePage]: (workpadState, { payload }) => { + const srcPage = workpadState.pages.find(page => page.id === payload); + + // if the page id is invalid, don't change the state + if (!srcPage) return workpadState; + + const srcIndex = workpadState.pages.indexOf(srcPage); + const newPageIndex = srcIndex + 1; + const insertedWorkpadState = addPage(workpadState, clonePage(srcPage), srcIndex); + const newState = setPageIndex(insertedWorkpadState, newPageIndex); + + // changes to the page require navigation + const router = routerProvider(); + router.navigateTo('loadWorkpad', { id: newState.id, page: newPageIndex + 1 }); + + return newState; + }, + + [actions.setPage]: (workpadState, { payload }) => { + return setPageIndex(workpadState, payload); + }, + + [actions.gotoPage]: (workpadState, { payload }) => { + const newState = setPageIndex(workpadState, payload); + + // changes to the page require navigation + const router = routerProvider(); + router.navigateTo('loadWorkpad', { id: newState.id, page: newState.page + 1 }); + + return newState; + }, + + [actions.movePage]: (workpadState, { payload }) => { + const { id, position } = payload; + const pageIndex = getPageIndexById(workpadState, id); + const newIndex = pageIndex + position; + + // TODO: do something better when given an invalid page id + if (pageIndex < 0) return workpadState; + + // don't move pages past the first or last position + if (newIndex < 0 || newIndex >= workpadState.pages.length) return workpadState; + + // remove and re-insert the page + const page = { ...workpadState.pages[pageIndex] }; + let newState = insert(del(workpadState, `pages.${pageIndex}`), 'pages', page, newIndex); + + // adjust the selected page index and return the new state + const selectedId = workpadState.pages[workpadState.page].id; + const newSelectedIndex = newState.pages.findIndex(page => page.id === selectedId); + newState = set(newState, 'page', newSelectedIndex); + + // changes to the page require navigation + const router = routerProvider(); + router.navigateTo('loadWorkpad', { id: newState.id, page: newState.page + 1 }); + + return newState; + }, + + [actions.removePage]: (workpadState, { payload }) => { + const curIndex = workpadState.page; + const delIndex = getPageIndexById(workpadState, payload); + if (delIndex >= 0) { + let newState = del(workpadState, `pages.${delIndex}`); + const router = routerProvider(); + const wasSelected = curIndex === delIndex; + const wasOnlyPage = newState.pages.length === 0; + const newSelectedPage = curIndex >= delIndex ? curIndex - 1 : curIndex; + + // if we removed the only page, create a new empty one + if (wasOnlyPage) newState = addPage(newState); + + if (wasOnlyPage || wasSelected) { + // if we removed the only page or the selected one, select the first one + newState = set(newState, 'page', 0); + } else { + // set the adjusted selected page on new state + newState = set(newState, 'page', newSelectedPage); + } + + // changes to the page require navigation + router.navigateTo('loadWorkpad', { id: newState.id, page: newState.page + 1 }); + + return newState; + } + }, + + [actions.stylePage]: (workpadState, { payload }) => { + const pageIndex = workpadState.pages.findIndex(page => page.id === payload.pageId); + return set(workpadState, ['pages', pageIndex, 'style'], payload.style); + }, + + [actions.setPageTransition]: (workpadState, { payload }) => { + const pageIndex = workpadState.pages.findIndex(page => page.id === payload.pageId); + return set(workpadState, ['pages', pageIndex, 'transition'], payload.transition); + }, + }, + {} +); diff --git a/x-pack/plugins/canvas/public/state/reducers/resolved_args.js b/x-pack/plugins/canvas/public/state/reducers/resolved_args.js new file mode 100644 index 0000000000000..7404ece39136a --- /dev/null +++ b/x-pack/plugins/canvas/public/state/reducers/resolved_args.js @@ -0,0 +1,123 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { handleActions } from 'redux-actions'; +import { set, del } from 'object-path-immutable'; +import { get } from 'lodash'; +import { prepend } from '../../lib/modify_path'; +import * as actions from '../actions/resolved_args'; +import { flushContext, flushContextAfterIndex } from '../actions/elements'; + +/* + Resolved args are a way to handle async values. They track the status, value, and error + state thgouh the lifecycle of the request, and are an object that looks like this: + + { + status: 'pending', + value: null, + error: null, + } + + Here, the request is in flight, and the application is waiting for a value. Valid statuses + are `initializing`, `pending`, `ready`, and `error`. + + When status is `ready`, the value will be whatever came back in the response. + + When status is `error`, the value will not change, and the error property will be the error. +*/ + +function _getState(hasError, loading) { + if (hasError) return 'error'; + if (Boolean(loading)) return 'pending'; + return 'ready'; +} + +function _getValue(hasError, value, oldVal) { + if (hasError || value == null) return oldVal && oldVal.value; + return value; +} + +function getContext(value, loading = false, oldVal = null) { + const hasError = value instanceof Error; + return { + state: _getState(hasError, loading), + value: _getValue(hasError, value, oldVal), + error: hasError ? value : null, + }; +} + +function getFullPath(path) { + const isArray = Array.isArray(path); + const isString = typeof path === 'string'; + if (!isArray && !isString) throw new Error(`Resolved argument path is invalid: ${path}`); + return prepend(path, 'resolvedArgs'); +} + +export const resolvedArgsReducer = handleActions( + { + [actions.setLoading]: (transientState, { payload }) => { + const { path, loading = true } = payload; + const fullPath = getFullPath(path); + const oldVal = get(transientState, fullPath, null); + return set(transientState, fullPath, getContext(get(oldVal, 'value', null), loading)); + }, + + [actions.setValue]: (transientState, { payload }) => { + const { path, value } = payload; + const fullPath = getFullPath(path); + const oldVal = get(transientState, fullPath, null); + return set(transientState, fullPath, getContext(value, false, oldVal)); + }, + + [actions.setValues]: (transientState, { payload }) => { + return payload.reduce((acc, setValueObj) => { + const fullPath = getFullPath(setValueObj.path); + const oldVal = get(acc, fullPath, null); + return set(acc, fullPath, getContext(setValueObj.value, false, oldVal)); + }, transientState); + }, + + [actions.clear]: (transientState, { payload }) => { + const { path } = payload; + return del(transientState, getFullPath(path)); + }, + + [actions.inFlightActive]: transientState => { + return set(transientState, 'inFlight', true); + }, + + [actions.inFlightComplete]: transientState => { + return set(transientState, 'inFlight', false); + }, + + /* + * Flush all cached contexts + */ + [flushContext]: (transientState, { payload: elementId }) => { + return del(transientState, getFullPath([elementId, 'expressionContext'])); + }, + + /* + * Flush cached context indices from the given index to the last + */ + [flushContextAfterIndex]: (transientState, { payload }) => { + const { elementId, index } = payload; + const expressionContext = get(transientState, getFullPath([elementId, 'expressionContext'])); + + // if there is not existing context, there's nothing to do here + if (!expressionContext) return transientState; + + return Object.keys(expressionContext).reduce((state, indexKey) => { + const indexAsNum = parseInt(indexKey, 10); + if (indexAsNum >= index) + return del(state, getFullPath([elementId, 'expressionContext', indexKey])); + + return state; + }, transientState); + }, + }, + {} +); diff --git a/x-pack/plugins/canvas/public/state/reducers/transient.js b/x-pack/plugins/canvas/public/state/reducers/transient.js new file mode 100644 index 0000000000000..471c6095e2972 --- /dev/null +++ b/x-pack/plugins/canvas/public/state/reducers/transient.js @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { handleActions } from 'redux-actions'; +import { set, del } from 'object-path-immutable'; +import { restoreHistory } from '../actions/history'; +import * as actions from '../actions/transient'; +import { removeElement } from '../actions/elements'; +import { setRefreshInterval } from '../actions/workpad'; + +export const transientReducer = handleActions( + { + // clear all the resolved args when restoring the history + // TODO: we shouldn't need to reset the resolved args for history + [restoreHistory]: transientState => set(transientState, 'resolvedArgs', {}), + + [removeElement]: (transientState, { payload: { elementId } }) => { + const { selectedElement } = transientState; + return del( + { + ...transientState, + selectedElement: selectedElement === elementId ? null : selectedElement, + }, + ['resolvedArgs', elementId] + ); + }, + + [actions.setEditing]: (transientState, { payload }) => { + return set(transientState, 'editing', Boolean(payload)); + }, + + [actions.setFullscreen]: (transientState, { payload }) => { + return set(transientState, 'fullscreen', Boolean(payload)); + }, + + [actions.selectElement]: (transientState, { payload }) => { + return { + ...transientState, + selectedElement: payload || null, + }; + }, + + [setRefreshInterval]: (transientState, { payload }) => { + return { ...transientState, refresh: { interval: Number(payload) || 0 } }; + }, + }, + {} +); diff --git a/x-pack/plugins/canvas/public/state/reducers/workpad.js b/x-pack/plugins/canvas/public/state/reducers/workpad.js new file mode 100644 index 0000000000000..799444864fa41 --- /dev/null +++ b/x-pack/plugins/canvas/public/state/reducers/workpad.js @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { recentlyAccessed } from 'ui/persisted_log'; +import { handleActions } from 'redux-actions'; +import { setWorkpad, sizeWorkpad, setColors, setName } from '../actions/workpad'; +import { APP_ROUTE_WORKPAD } from '../../../common/lib/constants'; + +export const workpadReducer = handleActions( + { + [setWorkpad]: (workpadState, { payload }) => { + recentlyAccessed.add(`${APP_ROUTE_WORKPAD}/${payload.id}`, payload.name, payload.id); + return payload; + }, + + [sizeWorkpad]: (workpadState, { payload }) => { + return { ...workpadState, ...payload }; + }, + + [setColors]: (workpadState, { payload }) => { + return { ...workpadState, colors: payload }; + }, + + [setName]: (workpadState, { payload }) => { + recentlyAccessed.add(`${APP_ROUTE_WORKPAD}/${workpadState.id}`, payload, workpadState.id); + return { ...workpadState, name: payload }; + }, + }, + {} +); diff --git a/x-pack/plugins/canvas/public/state/selectors/__tests__/resolved_args.js b/x-pack/plugins/canvas/public/state/selectors/__tests__/resolved_args.js new file mode 100644 index 0000000000000..6fa3b4e35db0c --- /dev/null +++ b/x-pack/plugins/canvas/public/state/selectors/__tests__/resolved_args.js @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import * as selector from '../resolved_args'; + +describe('resolved args selector', () => { + let state; + + beforeEach(() => { + state = { + transient: { + resolvedArgs: { + test1: { + state: 'ready', + value: 'test value', + error: null, + }, + test2: { + state: 'pending', + value: null, + error: null, + }, + test3: { + state: 'error', + value: 'some old value', + error: new Error('i have failed'), + }, + }, + }, + }; + }); + + it('getValue returns the state', () => { + expect(selector.getState(state, 'test1')).to.equal('ready'); + expect(selector.getState(state, 'test2')).to.equal('pending'); + expect(selector.getState(state, 'test3')).to.equal('error'); + }); + + it('getValue returns the value', () => { + expect(selector.getValue(state, 'test1')).to.equal('test value'); + expect(selector.getValue(state, 'test2')).to.equal(null); + expect(selector.getValue(state, 'test3')).to.equal('some old value'); + }); + + it('getError returns the error', () => { + expect(selector.getError(state, 'test1')).to.equal(null); + expect(selector.getError(state, 'test2')).to.equal(null); + expect(selector.getError(state, 'test3')).to.be.an(Error); + expect(selector.getError(state, 'test3').toString()).to.match(/i\ have\ failed$/); + }); +}); diff --git a/x-pack/plugins/canvas/public/state/selectors/__tests__/workpad.js b/x-pack/plugins/canvas/public/state/selectors/__tests__/workpad.js new file mode 100644 index 0000000000000..0c459fc3faa84 --- /dev/null +++ b/x-pack/plugins/canvas/public/state/selectors/__tests__/workpad.js @@ -0,0 +1,198 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import * as selector from '../workpad'; + +describe('workpad selectors', () => { + let asts; + let state; + + beforeEach(() => { + asts = { + 'element-0': { + type: 'expression', + chain: [ + { + type: 'function', + function: 'markdown', + arguments: {}, + }, + ], + }, + 'element-1': { + type: 'expression', + chain: [ + { + type: 'function', + function: 'demodata', + arguments: {}, + }, + ], + }, + }; + + state = { + transient: { + selectedElement: 'element-1', + resolvedArgs: { + 'element-0': 'test resolved arg, el 0', + 'element-1': 'test resolved arg, el 1', + 'element-2': { + example1: 'first thing', + example2: ['why not', 'an array?'], + example3: { + deeper: { + object: true, + }, + }, + }, + }, + }, + persistent: { + workpad: { + id: 'workpad-1', + page: 0, + pages: [ + { + id: 'page-1', + elements: [ + { + id: 'element-0', + expression: 'markdown', + }, + { + id: 'element-1', + expression: 'demodata', + }, + ], + }, + ], + }, + }, + }; + }); + + describe('empty state', () => { + it('returns undefined', () => { + expect(selector.getSelectedPage({})).to.be(undefined); + expect(selector.getPageById({}, 'page-1')).to.be(undefined); + expect(selector.getSelectedElement({})).to.be(undefined); + expect(selector.getSelectedElementId({})).to.be(undefined); + expect(selector.getElementById({}, 'element-1')).to.be(undefined); + expect(selector.getResolvedArgs({}, 'element-1')).to.be(undefined); + expect(selector.getSelectedResolvedArgs({})).to.be(undefined); + }); + }); + + describe('getSelectedPage', () => { + it('returns the selected page', () => { + expect(selector.getSelectedPage(state)).to.equal('page-1'); + }); + }); + + describe('getPages', () => { + it('return an empty array with no pages', () => { + expect(selector.getPages({})).to.eql([]); + }); + + it('returns all pages in persisent state', () => { + expect(selector.getPages(state)).to.eql(state.persistent.workpad.pages); + }); + }); + + describe('getPageById', () => { + it('should return matching page', () => { + expect(selector.getPageById(state, 'page-1')).to.eql(state.persistent.workpad.pages[0]); + }); + }); + + describe('getSelectedElement', () => { + it('returns selected element', () => { + const { elements } = state.persistent.workpad.pages[0]; + expect(selector.getSelectedElement(state)).to.eql({ + ...elements[1], + ast: asts['element-1'], + }); + }); + }); + + describe('getSelectedElementId', () => { + it('returns selected element id', () => { + expect(selector.getSelectedElementId(state)).to.equal('element-1'); + }); + }); + + describe('getElements', () => { + it('is an empty array with no state', () => { + expect(selector.getElements({})).to.eql([]); + }); + + it('returns all elements on the page', () => { + const { elements } = state.persistent.workpad.pages[0]; + const expected = elements.map(element => ({ + ...element, + ast: asts[element.id], + })); + expect(selector.getElements(state)).to.eql(expected); + }); + }); + + describe('getElementById', () => { + it('returns element matching id', () => { + const { elements } = state.persistent.workpad.pages[0]; + expect(selector.getElementById(state, 'element-0')).to.eql({ + ...elements[0], + ast: asts['element-0'], + }); + expect(selector.getElementById(state, 'element-1')).to.eql({ + ...elements[1], + ast: asts['element-1'], + }); + }); + }); + + describe('getResolvedArgs', () => { + it('returns resolved args by element id', () => { + expect(selector.getResolvedArgs(state, 'element-0')).to.equal('test resolved arg, el 0'); + }); + + it('returns resolved args at given path', () => { + const arg = selector.getResolvedArgs(state, 'element-2', 'example1'); + expect(arg).to.equal('first thing'); + }); + }); + + describe('getSelectedResolvedArgs', () => { + it('returns resolved args for selected element', () => { + expect(selector.getSelectedResolvedArgs(state)).to.equal('test resolved arg, el 1'); + }); + + it('returns resolved args at given path', () => { + const tmpState = { + ...state, + transient: { + ...state.transient, + selectedElement: 'element-2', + }, + }; + const arg = selector.getSelectedResolvedArgs(tmpState, 'example2'); + expect(arg).to.eql(['why not', 'an array?']); + }); + + it('returns resolved args at given deep path', () => { + const tmpState = { + ...state, + transient: { + ...state.transient, + selectedElement: 'element-2', + }, + }; + const arg = selector.getSelectedResolvedArgs(tmpState, ['example3', 'deeper', 'object']); + expect(arg).to.be(true); + }); + }); +}); diff --git a/x-pack/plugins/canvas/public/state/selectors/app.js b/x-pack/plugins/canvas/public/state/selectors/app.js new file mode 100644 index 0000000000000..3114cb2063440 --- /dev/null +++ b/x-pack/plugins/canvas/public/state/selectors/app.js @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get } from 'lodash'; + +// page getters +export function getEditing(state) { + return get(state, 'transient.editing', false); +} + +export function getFullscreen(state) { + return get(state, 'transient.fullscreen', false); +} + +export function getFunctionDefinitions(state) { + return get(state, 'app.functionDefinitions'); +} + +export function getAppReady(state) { + return get(state, 'app.ready'); +} + +export function getBasePath(state) { + return get(state, 'app.basePath'); +} + +export function getReportingBrowserType(state) { + return get(state, 'app.reportingBrowserType'); +} + +// return true only when the required parameters are in the state +export function isAppReady(state) { + const appReady = getAppReady(state); + return appReady === true; +} diff --git a/x-pack/plugins/canvas/public/state/selectors/assets.js b/x-pack/plugins/canvas/public/state/selectors/assets.js new file mode 100644 index 0000000000000..50e6ea2abc908 --- /dev/null +++ b/x-pack/plugins/canvas/public/state/selectors/assets.js @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get } from 'lodash'; + +const assetRoot = 'assets'; + +export function getAssets(state) { + return get(state, assetRoot, {}); +} + +export function getAssetIds(state) { + return Object.keys(getAssets(state)); +} + +export function getAssetById(state, id) { + return get(state, [assetRoot, id]); +} diff --git a/x-pack/plugins/canvas/public/state/selectors/resolved_args.js b/x-pack/plugins/canvas/public/state/selectors/resolved_args.js new file mode 100644 index 0000000000000..6ab02f73291eb --- /dev/null +++ b/x-pack/plugins/canvas/public/state/selectors/resolved_args.js @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get } from 'lodash'; +import * as argHelper from '../../lib/resolved_arg'; +import { prepend } from '../../lib/modify_path'; + +export function getArg(state, path) { + return get(state, prepend(path, ['transient', 'resolvedArgs'])); +} + +export function getValue(state, path) { + return argHelper.getValue(getArg(state, path)); +} + +export function getState(state, path) { + return argHelper.getState(getArg(state, path)); +} + +export function getError(state, path) { + return argHelper.getError(getArg(state, path)); +} + +export function getInFlight(state) { + return get(state, ['transient', 'inFlight'], false); +} diff --git a/x-pack/plugins/canvas/public/state/selectors/workpad.js b/x-pack/plugins/canvas/public/state/selectors/workpad.js new file mode 100644 index 0000000000000..4dff29be51c01 --- /dev/null +++ b/x-pack/plugins/canvas/public/state/selectors/workpad.js @@ -0,0 +1,126 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get, omit } from 'lodash'; +import { safeElementFromExpression } from '../../../common/lib/ast'; +import { append } from '../../lib/modify_path'; +import { getAssets } from './assets'; + +const workpadRoot = 'persistent.workpad'; + +const appendAst = element => ({ + ...element, + ast: safeElementFromExpression(element.expression), +}); + +// workpad getters +export function getWorkpad(state) { + return get(state, workpadRoot); +} + +export function getWorkpadPersisted(state) { + return { + ...getWorkpad(state), + assets: getAssets(state), + }; +} +export function getWorkpadInfo(state) { + return omit(getWorkpad(state), ['pages']); +} + +// page getters +export function getSelectedPageIndex(state) { + return get(state, append(workpadRoot, 'page')); +} + +export function getSelectedPage(state) { + const pageIndex = getSelectedPageIndex(state); + const pages = getPages(state); + return get(pages, `[${pageIndex}].id`); +} + +export function getPages(state) { + return get(state, append(workpadRoot, 'pages'), []); +} + +export function getPageById(state, id) { + const pages = getPages(state); + return pages.find(page => page.id === id); +} + +export function getPageIndexById(state, id) { + const pages = getPages(state); + return pages.findIndex(page => page.id === id); +} + +export function getWorkpadName(state) { + return get(state, append(workpadRoot, 'name')); +} + +export function getWorkpadColors(state) { + return get(state, append(workpadRoot, 'colors')); +} + +export function getAllElements(state) { + return getPages(state).reduce((elements, page) => elements.concat(page.elements), []); +} + +export function getGlobalFilterExpression(state) { + return getAllElements(state) + .map(el => el.filter) + .filter(str => str != null && str.length) + .join(' | '); +} + +// element getters +export function getSelectedElementId(state) { + return get(state, 'transient.selectedElement'); +} + +export function getSelectedElement(state) { + return getElementById(state, getSelectedElementId(state)); +} + +export function getElements(state, pageId, withAst = true) { + const id = pageId || getSelectedPage(state); + if (!id) return []; + + const page = getPageById(state, id); + const elements = get(page, 'elements'); + + if (!elements) return []; + + // explicitely strip the ast, basically a fix for corrupted workpads + // due to https://github.com/elastic/kibana-canvas/issues/260 + // TODO: remove this once it's been in the wild a bit + if (!withAst) return elements.map(el => omit(el, ['ast'])); + + return elements.map(appendAst); +} + +export function getElementById(state, id, pageId) { + const element = getElements(state, pageId, []).find(el => el.id === id); + if (element) return appendAst(element); +} + +export function getResolvedArgs(state, elementId, path) { + if (!elementId) return; + const args = get(state, ['transient', 'resolvedArgs', elementId]); + if (path) return get(args, path); + return args; +} + +export function getSelectedResolvedArgs(state, path) { + return getResolvedArgs(state, getSelectedElementId(state), path); +} + +export function getContextForIndex(state, index) { + return getSelectedResolvedArgs(state, ['expressionContext', index - 1]); +} + +export function getRefreshInterval(state) { + return get(state, 'transient.refresh.interval', 0); +} diff --git a/x-pack/plugins/canvas/public/state/store.js b/x-pack/plugins/canvas/public/state/store.js new file mode 100644 index 0000000000000..f8d548e335de8 --- /dev/null +++ b/x-pack/plugins/canvas/public/state/store.js @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createStore as createReduxStore } from 'redux'; +import { isPlainObject } from 'lodash'; +import { middleware } from './middleware'; +import { getRootReducer } from './reducers'; + +let store; + +export function createStore(initialState) { + if (typeof store !== 'undefined') throw new Error('Redux store can only be initialized once'); + + if (!isPlainObject(initialState)) throw new Error('Initial state must be a plain object'); + + const rootReducer = getRootReducer(initialState); + store = createReduxStore(rootReducer, initialState, middleware); + return store; +} + +export function getState() { + return store.getState(); +} diff --git a/x-pack/plugins/canvas/public/style/hackery.scss b/x-pack/plugins/canvas/public/style/hackery.scss new file mode 100644 index 0000000000000..7ce2fd1435188 --- /dev/null +++ b/x-pack/plugins/canvas/public/style/hackery.scss @@ -0,0 +1,37 @@ +/* + This file contains all the global scope garbage that should be removed + when the UI framework implements everything we need +*/ + +// Give buttons some room to the right +.euiAccordion__childWrapper { + overflow-x: hidden; +} + +.clickable { + cursor: pointer; +} + +// Temp EUI issue. +.canvasPalettePicker__swatchesPopover { + display: block; + + .euiPopover__anchor { + display: block; + } +} + +// TODO: remove if fix is provided for https://github.com/elastic/eui/issues/833 +// temp fix for SVGs not appearing in the EuiImage fullscreen view +.euiImageFullScreen { + min-width: 100px; +} + +// TODO: remove this once a fixed height prop is added to EuiModal +// Issue for adding height prop https://github.com/elastic/eui/issues/1154 +// this prevents height jumpiness when using search inside a modal +.canvasModal--fixedSize { + width: 75vw; + height: 75vh; + max-height: 680px; // limit for large screen displays +} \ No newline at end of file diff --git a/x-pack/plugins/canvas/public/style/index.scss b/x-pack/plugins/canvas/public/style/index.scss new file mode 100644 index 0000000000000..8b586dbed2056 --- /dev/null +++ b/x-pack/plugins/canvas/public/style/index.scss @@ -0,0 +1,56 @@ +// EUI global scope +@import '@elastic/eui/src/themes/k6/k6_globals'; +@import '@elastic/eui/src/themes/k6/k6_colors_light'; +@import '@elastic/eui/src/global_styling/functions/index'; +@import '@elastic/eui/src/global_styling/variables/index'; +@import '@elastic/eui/src/global_styling/mixins/index'; + +// Canvas core +@import 'hackery'; +@import 'main'; + +// Canvas apps +@import '../apps/home/home_app'; +@import '../apps/workpad/workpad_app/workpad_app'; +@import '../apps/export/export/export_app'; + +// Canvas components +@import '../components/alignment_guide/alignment_guide'; +@import '../components/arg_add/arg_add'; +@import '../components/arg_add_popover/arg_add_popover'; +@import '../components/arg_form/arg_form'; +@import '../components/asset_manager/asset_manager'; +@import '../components/border_connection/border_connection'; +@import '../components/border_resize_handle/border_resize_handle'; +@import '../components/color_dot/color_dot'; +@import '../components/color_palette/color_palette'; +@import '../components/color_picker_mini/color_picker_mini'; +@import '../components/context_menu/context_menu'; +@import '../components/datasource/datasource'; +@import '../components/datasource/datasource_preview/datasource_preview'; +@import '../components/datatable/datatable'; +@import '../components/debug/debug'; +@import '../components/dom_preview/dom_preview'; +@import '../components/element_content/element_content'; +@import '../components/expression_input/suggestion'; +@import '../components/font_picker/font_picker'; +@import '../components/fullscreen/fullscreen'; +@import '../components/function_form/function_form'; +@import '../components/hover_annotation/hover_annotation'; +@import '../components/loading/loading'; +@import '../components/navbar/navbar'; +@import '../components/page_manager/page_manager'; +@import '../components/palette_picker/palette_picker'; +@import '../components/palette_swatch/palette_swatch'; +@import '../components/positionable/positionable'; +@import '../components/refresh_control/refresh_control'; +@import '../components/remove_icon/remove_icon'; +@import '../components/rotation_handle/rotation_handle'; +@import '../components/shape_preview/shape_preview'; +@import '../components/shape_picker_mini/shape_picker_mini'; +@import '../components/sidebar/sidebar'; +@import '../components/toolbar/toolbar'; +@import '../components/toolbar/tray/tray'; +@import '../components/workpad/workpad'; +@import '../components/workpad_loader/workpad_dropzone/workpad_dropzone'; +@import '../components/workpad_page/workpad_page'; \ No newline at end of file diff --git a/x-pack/plugins/canvas/public/style/main.scss b/x-pack/plugins/canvas/public/style/main.scss new file mode 100644 index 0000000000000..d6079ec13e3f9 --- /dev/null +++ b/x-pack/plugins/canvas/public/style/main.scss @@ -0,0 +1,52 @@ +.canvas.canvasContainer { + display: flex; + flex-grow: 1; + background-color: $euiColorLightestShade; +} + +.canvasContainer--loading { + position: fixed; + top: 50%; + left: 50%; + transform: translateY(-50%); + transform: translateX(-50%); + text-align: center; +} + +.canvasCheckered { + background-image: linear-gradient(45deg, #ddd 25%, transparent 25%), + linear-gradient(-45deg, #ddd 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, #ddd 75%), + linear-gradient(-45deg, transparent 75%, #ddd 75%); + background-size: 8px 8px; +} + +.canvasTextArea--code { + @include euiScrollBar; + font-size: $euiFontSize; + font-family: monospace; + width: 100%; + max-width: 100%; +} + +body { + overflow-y: hidden; + + // Todo: replace this with EuiToast + .window-error { + position: absolute; + bottom: 10px; + right: 10px; + background-color: red; + color: white; + display: block; + z-index: 2000; + padding: 10px; + max-width: 500px; + + a { + color: white; + font-weight: bold; + } + } +} diff --git a/x-pack/plugins/canvas/public/transitions/fade/fade.css b/x-pack/plugins/canvas/public/transitions/fade/fade.css new file mode 100644 index 0000000000000..505ca4a58889c --- /dev/null +++ b/x-pack/plugins/canvas/public/transitions/fade/fade.css @@ -0,0 +1,27 @@ +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +.fadeIn { + animation-name: 'fadeIn'; +} + +@keyframes fadeOut { + from { + opacity: 1; + } + + to { + opacity: 0; + } +} + +.fadeOut { + animation-name: 'fadeOut'; +} diff --git a/x-pack/plugins/canvas/public/transitions/fade/index.js b/x-pack/plugins/canvas/public/transitions/fade/index.js new file mode 100644 index 0000000000000..12f95425f7338 --- /dev/null +++ b/x-pack/plugins/canvas/public/transitions/fade/index.js @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import './fade.css'; + +export const fade = () => ({ + name: 'fade', + displayName: 'Fade', + help: 'Fade from one page to the next', + enter: 'fadeIn', + exit: 'fadeOut', +}); diff --git a/x-pack/plugins/canvas/public/transitions/index.js b/x-pack/plugins/canvas/public/transitions/index.js new file mode 100644 index 0000000000000..2fa6f2c9a7979 --- /dev/null +++ b/x-pack/plugins/canvas/public/transitions/index.js @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { fade } from './fade'; +import { rotate } from './rotate'; +import { slide } from './slide'; +import { zoom } from './zoom'; + +export const transitions = [fade, rotate, slide, zoom]; diff --git a/x-pack/plugins/canvas/public/transitions/rotate/index.js b/x-pack/plugins/canvas/public/transitions/rotate/index.js new file mode 100644 index 0000000000000..aa344938f94a4 --- /dev/null +++ b/x-pack/plugins/canvas/public/transitions/rotate/index.js @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import './rotate.css'; + +export const rotate = () => ({ + name: 'rotate', + displayName: 'Rotate', + help: 'Rotate from one page to the next', + enter: 'rotateIn', + exit: 'rotateOut', +}); diff --git a/x-pack/plugins/canvas/public/transitions/rotate/rotate.css b/x-pack/plugins/canvas/public/transitions/rotate/rotate.css new file mode 100644 index 0000000000000..4bded7d35d430 --- /dev/null +++ b/x-pack/plugins/canvas/public/transitions/rotate/rotate.css @@ -0,0 +1,34 @@ +@keyframes rotateIn { + from { + transform-origin: left bottom; + transform: rotate3d(0, 0, 1, 45deg); + opacity: 0; + } + + to { + transform-origin: left bottom; + transform: translate3d(0, 0, 0); + opacity: 1; + } +} + +.rotateIn { + animation-name: 'rotateIn'; +} + +@keyframes rotateOut { + from { + transform-origin: left bottom; + opacity: 1; + } + + to { + transform-origin: left bottom; + transform: rotate3d(0, 0, 1, -45deg); + opacity: 0; + } +} + +.rotateOut { + animation-name: 'rotateOut'; +} diff --git a/x-pack/plugins/canvas/public/transitions/slide/index.js b/x-pack/plugins/canvas/public/transitions/slide/index.js new file mode 100644 index 0000000000000..367d9dcc6db12 --- /dev/null +++ b/x-pack/plugins/canvas/public/transitions/slide/index.js @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import './slide.css'; + +export const slide = () => ({ + name: 'slide', + displayName: 'Slide', + help: 'Slide from one page to the next', + enter: 'slideIn', + exit: 'slideOut', +}); diff --git a/x-pack/plugins/canvas/public/transitions/slide/slide.css b/x-pack/plugins/canvas/public/transitions/slide/slide.css new file mode 100644 index 0000000000000..3b01cc8daceba --- /dev/null +++ b/x-pack/plugins/canvas/public/transitions/slide/slide.css @@ -0,0 +1,29 @@ +@keyframes slideIn { + from { + transform: translate3d(100%, 0, 0); + visibility: visible; + } + + to { + transform: translate3d(0, 0, 0); + } +} + +.slideIn { + animation-name: 'slideIn'; +} + +@keyframes slideOut { + from { + transform: translate3d(0, 0, 0); + } + + to { + visibility: hidden; + transform: translate3d(-100%, 0, 0); + } +} + +.slideOut { + animation-name: 'slideOut'; +} diff --git a/x-pack/plugins/canvas/public/transitions/transition.js b/x-pack/plugins/canvas/public/transitions/transition.js new file mode 100644 index 0000000000000..b8ffce0eff304 --- /dev/null +++ b/x-pack/plugins/canvas/public/transitions/transition.js @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export function Transition(config) { + // The name of the transition that is stored in the page object + this.name = config.name; + + // Use this to set a more friendly name + this.displayName = config.displayName || this.name; + + // A sentence or few about what this element does + this.help = config.help || ''; + + // The CSS class corresponding to the page enter transition + this.enter = config.enter || ''; + + // The CSS class corresponding to the page exit transition + this.exit = config.exit || ''; +} diff --git a/x-pack/plugins/canvas/public/transitions/zoom/index.js b/x-pack/plugins/canvas/public/transitions/zoom/index.js new file mode 100644 index 0000000000000..796975eac17a9 --- /dev/null +++ b/x-pack/plugins/canvas/public/transitions/zoom/index.js @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import './zoom.css'; + +export const zoom = () => ({ + name: 'zoom', + displayName: 'Zoom', + help: 'Zoom from one page to the next', + enter: 'zoomIn', + exit: 'zoomOut', +}); diff --git a/x-pack/plugins/canvas/public/transitions/zoom/zoom.css b/x-pack/plugins/canvas/public/transitions/zoom/zoom.css new file mode 100644 index 0000000000000..6811a6f178907 --- /dev/null +++ b/x-pack/plugins/canvas/public/transitions/zoom/zoom.css @@ -0,0 +1,33 @@ +@keyframes zoomIn { + from { + opacity: 0; + transform: scale3d(0.3, 0.3, 0.3); + } + + 50% { + opacity: 1; + } +} + +.zoomIn { + animation-name: 'zoomIn'; +} + +@keyframes zoomOut { + from { + opacity: 1; + } + + 50% { + opacity: 0; + transform: scale3d(0.3, 0.3, 0.3); + } + + to { + opacity: 0; + } +} + +.zoomOut { + animation-name: 'zoomOut'; +} diff --git a/x-pack/plugins/canvas/server/lib/build_bool_array.js b/x-pack/plugins/canvas/server/lib/build_bool_array.js new file mode 100644 index 0000000000000..2dc6447753526 --- /dev/null +++ b/x-pack/plugins/canvas/server/lib/build_bool_array.js @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getESFilter } from './get_es_filter'; + +const compact = arr => (Array.isArray(arr) ? arr.filter(val => Boolean(val)) : []); + +export function buildBoolArray(canvasQueryFilterArray) { + return compact( + canvasQueryFilterArray.map(clause => { + try { + return getESFilter(clause); + } catch (e) { + return; + } + }) + ); +} diff --git a/x-pack/plugins/canvas/server/lib/build_es_request.js b/x-pack/plugins/canvas/server/lib/build_es_request.js new file mode 100644 index 0000000000000..037138cc11576 --- /dev/null +++ b/x-pack/plugins/canvas/server/lib/build_es_request.js @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { buildBoolArray } from './build_bool_array'; + +export function buildESRequest(esRequest, canvasQuery) { + if (canvasQuery.size) esRequest = { ...esRequest, size: canvasQuery.size }; + + if (canvasQuery.and) esRequest.body.query.bool.must = buildBoolArray(canvasQuery.and); + + return esRequest; +} diff --git a/x-pack/plugins/canvas/server/lib/create_handlers.js b/x-pack/plugins/canvas/server/lib/create_handlers.js new file mode 100644 index 0000000000000..0825804960dbb --- /dev/null +++ b/x-pack/plugins/canvas/server/lib/create_handlers.js @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { partial } from 'lodash'; + +export const createHandlers = (request, server) => { + const { callWithRequest } = server.plugins.elasticsearch.getCluster('data'); + const config = server.config(); + + return { + environment: 'server', + serverUri: + config.has('server.rewriteBasePath') && config.get('server.rewriteBasePath') + ? `${server.info.uri}${config.get('server.basePath')}` + : server.info.uri, + httpHeaders: request.headers, + elasticsearchClient: partial(callWithRequest, request), + }; +}; diff --git a/x-pack/plugins/canvas/server/lib/filters.js b/x-pack/plugins/canvas/server/lib/filters.js new file mode 100644 index 0000000000000..5e4c2c64845c6 --- /dev/null +++ b/x-pack/plugins/canvas/server/lib/filters.js @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* + TODO: This could be pluggable +*/ + +export function time(filter) { + if (!filter.column) throw new Error('column is required for Elasticsearch range filters'); + return { + range: { + [filter.column]: { gte: filter.from, lte: filter.to }, + }, + }; +} + +export function luceneQueryString(filter) { + return { + query_string: { + query: filter.query || '*', + }, + }; +} + +export function exactly(filter) { + return { + term: { + [filter.column]: { + value: filter.value, + }, + }, + }; +} diff --git a/x-pack/plugins/canvas/server/lib/get_es_filter.js b/x-pack/plugins/canvas/server/lib/get_es_filter.js new file mode 100644 index 0000000000000..ccc296bc550f9 --- /dev/null +++ b/x-pack/plugins/canvas/server/lib/get_es_filter.js @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* + boolArray is the array of bool filter clauses to push filters into. Usually this would be + the value of must, should or must_not. + filter is the abstracted canvas filter. +*/ + +/*eslint import/namespace: ['error', { allowComputed: true }]*/ +import * as filters from './filters'; + +export function getESFilter(filter) { + if (!filters[filter.type]) throw new Error(`Unknown filter type: ${filter.type}`); + + try { + return filters[filter.type](filter); + } catch (e) { + throw new Error(`Could not create elasticsearch filter from ${filter.type}`); + } +} diff --git a/x-pack/plugins/canvas/server/lib/get_plugin_paths.js b/x-pack/plugins/canvas/server/lib/get_plugin_paths.js new file mode 100644 index 0000000000000..6ebb5574e57d5 --- /dev/null +++ b/x-pack/plugins/canvas/server/lib/get_plugin_paths.js @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import path from 'path'; +import fs from 'fs'; +import { promisify } from 'util'; +import { flatten } from 'lodash'; +import { pluginPaths } from './plugin_paths'; + +const lstat = promisify(fs.lstat); +const readdir = promisify(fs.readdir); + +const kibanaPluginPath = path.resolve(__dirname, '..', '..', '..'); +const canvasPluginDirectoryName = 'canvas_plugin'; + +export const getPluginPaths = type => { + const typePath = pluginPaths[type]; + if (!typePath) throw new Error(`Unknown type: ${type}`); + + return readdir(kibanaPluginPath) // Get names of everything in Kibana plugin path + .then(names => names.filter(name => name[0] !== '.')) // Filter out names that start with . + .then(names => { + // Get full paths to stuff that might have a canvas plugin of the provided type + return names.map(name => + path.resolve(kibanaPluginPath, name, canvasPluginDirectoryName, ...typePath) + ); + }) + .then(possibleCanvasPlugins => { + // Check how many are directories. If lstat fails it doesn't exist anyway. + return Promise.all( + // An array + possibleCanvasPlugins.map( + pluginPath => + lstat(pluginPath) + .then(stat => stat.isDirectory()) // Exists and is a directory + .catch(() => false) // I guess it doesn't exist, so whaevs + ) + ).then(isDirectory => possibleCanvasPlugins.filter((pluginPath, i) => isDirectory[i])); + }) + .then(canvasPluginDirectories => { + return Promise.all( + canvasPluginDirectories.map(dir => + // Get the full path of all files in the directory + readdir(dir).then(files => files.map(file => path.resolve(dir, file))) + ) + ).then(flatten); + }); +}; diff --git a/x-pack/plugins/canvas/server/lib/get_plugin_stream.js b/x-pack/plugins/canvas/server/lib/get_plugin_stream.js new file mode 100644 index 0000000000000..51f3d234afdb1 --- /dev/null +++ b/x-pack/plugins/canvas/server/lib/get_plugin_stream.js @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import fs from 'fs'; +import ss from 'stream-stream'; +import { getPluginPaths } from './get_plugin_paths'; + +export const getPluginStream = type => { + const stream = ss(); + + getPluginPaths(type).then(files => { + files.forEach(file => { + stream.write(fs.createReadStream(file)); + }); + stream.end(); + }); + + return stream; +}; diff --git a/x-pack/plugins/canvas/server/lib/load_server_plugins.js b/x-pack/plugins/canvas/server/lib/load_server_plugins.js new file mode 100644 index 0000000000000..0373261e96067 --- /dev/null +++ b/x-pack/plugins/canvas/server/lib/load_server_plugins.js @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { typesRegistry } from '../../common/lib/types_registry'; +import { functionsRegistry as serverFunctions } from '../../common/lib/functions_registry'; +import { getPluginPaths } from './get_plugin_paths'; + +const types = { + serverFunctions: serverFunctions, + commonFunctions: serverFunctions, + types: typesRegistry, +}; + +const loaded = new Promise(resolve => { + const remainingTypes = Object.keys(types); + + const loadType = () => { + const type = remainingTypes.pop(); + getPluginPaths(type).then(paths => { + global.canvas = global.canvas || {}; + global.canvas.register = d => types[type].register(d); + + paths.forEach(path => { + require(path); + }); + + global.canvas = undefined; + if (remainingTypes.length) loadType(); + else resolve(true); + }); + }; + + loadType(); +}); + +export const loadServerPlugins = () => loaded; diff --git a/x-pack/plugins/canvas/server/lib/normalize_type.js b/x-pack/plugins/canvas/server/lib/normalize_type.js new file mode 100644 index 0000000000000..7dd20b94096ba --- /dev/null +++ b/x-pack/plugins/canvas/server/lib/normalize_type.js @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export function normalizeType(type) { + const normalTypes = { + string: ['string', 'text', 'keyword', '_type', '_id', '_index'], + number: [ + 'float', + 'half_float', + 'scaled_float', + 'double', + 'integer', + 'long', + 'short', + 'byte', + 'token_count', + '_version', + ], + date: ['date'], + boolean: ['boolean'], + null: ['null'], + }; + + const normalizedType = Object.keys(normalTypes).find(t => normalTypes[t].includes(type)); + + if (normalizedType) return normalizedType; + throw new Error(`Canvas does not yet support type: ${type}`); +} diff --git a/x-pack/plugins/canvas/server/lib/plugin_paths.js b/x-pack/plugins/canvas/server/lib/plugin_paths.js new file mode 100644 index 0000000000000..cb90cc0c0f06c --- /dev/null +++ b/x-pack/plugins/canvas/server/lib/plugin_paths.js @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const pluginPaths = { + serverFunctions: ['functions', 'server'], + browserFunctions: ['functions', 'browser'], + commonFunctions: ['functions', 'common'], + types: ['types'], + elements: ['elements'], + renderers: ['renderers'], + interfaces: ['interfaces'], + transformUIs: ['uis', 'transforms'], + datasourceUIs: ['uis', 'datasources'], + modelUIs: ['uis', 'models'], + viewUIs: ['uis', 'views'], + argumentUIs: ['uis', 'arguments'], +}; diff --git a/x-pack/plugins/canvas/server/lib/sanitize_name.js b/x-pack/plugins/canvas/server/lib/sanitize_name.js new file mode 100644 index 0000000000000..623b64ca04ae8 --- /dev/null +++ b/x-pack/plugins/canvas/server/lib/sanitize_name.js @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export function sanitizeName(name) { + // blacklisted characters + const blacklist = ['(', ')']; + const pattern = blacklist.map(v => escapeRegExp(v)).join('|'); + const regex = new RegExp(pattern, 'g'); + return name.replace(regex, '_'); +} + +function escapeRegExp(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/x-pack/plugins/canvas/server/mappings.js b/x-pack/plugins/canvas/server/mappings.js new file mode 100644 index 0000000000000..739e54fdb3959 --- /dev/null +++ b/x-pack/plugins/canvas/server/mappings.js @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CANVAS_TYPE } from '../common/lib/constants'; + +export const mappings = { + [CANVAS_TYPE]: { + dynamic: false, + properties: { + name: { + type: 'text', + fields: { + keyword: { + type: 'keyword', + }, + }, + }, + id: { type: 'text', index: false }, + '@timestamp': { type: 'date' }, + '@created': { type: 'date' }, + }, + }, +}; diff --git a/x-pack/plugins/canvas/server/routes/es_fields/get_es_field_types.js b/x-pack/plugins/canvas/server/routes/es_fields/get_es_field_types.js new file mode 100644 index 0000000000000..8e454a595f99c --- /dev/null +++ b/x-pack/plugins/canvas/server/routes/es_fields/get_es_field_types.js @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { mapValues, keys } from 'lodash'; +import { normalizeType } from '../../lib/normalize_type'; + +export function getESFieldTypes(index, fields, elasticsearchClient) { + const config = { + index: index, + fields: fields || '*', + }; + + if (fields && fields.length === 0) return Promise.resolve({}); + + return elasticsearchClient('fieldCaps', config).then(resp => { + return mapValues(resp.fields, types => { + if (keys(types).length > 1) return 'conflict'; + + try { + return normalizeType(keys(types)[0]); + } catch (e) { + return 'unsupported'; + } + }); + }); +} diff --git a/x-pack/plugins/canvas/server/routes/es_fields/index.js b/x-pack/plugins/canvas/server/routes/es_fields/index.js new file mode 100644 index 0000000000000..9ceca324cc017 --- /dev/null +++ b/x-pack/plugins/canvas/server/routes/es_fields/index.js @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { partial } from 'lodash'; +import { getESFieldTypes } from './get_es_field_types'; + +// TODO: Error handling, note: esErrors +export function esFields(server) { + const { callWithRequest } = server.plugins.elasticsearch.getCluster('data'); + + server.route({ + method: 'GET', + path: '/api/canvas/es_fields', + handler: function(request, reply) { + const { index, fields } = request.query; + if (!index) return reply({ error: '"index" query is required' }).code(400); + + reply(getESFieldTypes(index, fields, partial(callWithRequest, request))); + }, + }); +} diff --git a/x-pack/plugins/canvas/server/routes/es_indices/get_es_indices.js b/x-pack/plugins/canvas/server/routes/es_indices/get_es_indices.js new file mode 100644 index 0000000000000..9a28d3da42409 --- /dev/null +++ b/x-pack/plugins/canvas/server/routes/es_indices/get_es_indices.js @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { map } from 'lodash'; + +export function getESIndices(kbnIndex, elasticsearchClient) { + const config = { + index: kbnIndex, + _source: 'index-pattern.title', + q: 'type:"index-pattern"', + size: 100, + }; + + return elasticsearchClient('search', config).then(resp => { + return map(resp.hits.hits, '_source["index-pattern"].title'); + }); +} diff --git a/x-pack/plugins/canvas/server/routes/es_indices/index.js b/x-pack/plugins/canvas/server/routes/es_indices/index.js new file mode 100644 index 0000000000000..67a92fa72aecd --- /dev/null +++ b/x-pack/plugins/canvas/server/routes/es_indices/index.js @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { partial } from 'lodash'; +import { getESIndices } from './get_es_indices'; + +// TODO: Error handling, note: esErrors +// TODO: Allow filtering by pattern name +export function esIndices(server) { + const kbnIndex = server.config().get('kibana.index'); + const { callWithRequest } = server.plugins.elasticsearch.getCluster('data'); + + server.route({ + method: 'GET', + path: '/api/canvas/es_indices', + handler: function(request, reply) { + reply(getESIndices(kbnIndex, partial(callWithRequest, request))); + }, + }); +} diff --git a/x-pack/plugins/canvas/server/routes/get_auth/get_auth_header.js b/x-pack/plugins/canvas/server/routes/get_auth/get_auth_header.js new file mode 100644 index 0000000000000..466c601d02204 --- /dev/null +++ b/x-pack/plugins/canvas/server/routes/get_auth/get_auth_header.js @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { insecureAuthRoute } from './insecure_auth_route'; + +// TODO: OMG. No. Need a better way of setting to this than our wacky route thing. +export function getAuthHeader(request, server) { + const basePath = server.config().get('server.basePath') || ''; + const fullPath = `${basePath}${insecureAuthRoute}`; + + return server + .inject({ + method: 'GET', + url: fullPath, + headers: request.headers, + }) + .then(res => res.result); +} diff --git a/x-pack/plugins/canvas/server/routes/get_auth/index.js b/x-pack/plugins/canvas/server/routes/get_auth/index.js new file mode 100644 index 0000000000000..bdc8e596f6f8b --- /dev/null +++ b/x-pack/plugins/canvas/server/routes/get_auth/index.js @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { insecureAuthRoute } from './insecure_auth_route'; + +// TODO: Fix this first. This route returns decrypts the cookie and returns the basic auth header. It is used because +// the pre-route hapi hook doesn't work on the socket and there are no exposed methods for doing the conversion from cookie +// to auth header. We will need to add that to x-pack security +// In theory this is pretty difficult to exploit, but not impossible. +// +export function getAuth(server) { + server.route({ + method: 'GET', + path: insecureAuthRoute, + handler: function(request, reply) { + reply(request.headers.authorization); + }, + }); +} diff --git a/x-pack/plugins/canvas/server/routes/get_auth/insecure_auth_route.js b/x-pack/plugins/canvas/server/routes/get_auth/insecure_auth_route.js new file mode 100644 index 0000000000000..62d78e4eab4a7 --- /dev/null +++ b/x-pack/plugins/canvas/server/routes/get_auth/insecure_auth_route.js @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import uuid from 'uuid/v4'; + +export const insecureAuthRoute = `/api/canvas/ar-${uuid()}`; diff --git a/x-pack/plugins/canvas/server/routes/index.js b/x-pack/plugins/canvas/server/routes/index.js new file mode 100644 index 0000000000000..6d8cc6aefd839 --- /dev/null +++ b/x-pack/plugins/canvas/server/routes/index.js @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { workpad } from './workpad'; +import { socketApi } from './socket'; +import { translate } from './translate'; +import { esFields } from './es_fields'; +import { esIndices } from './es_indices'; +import { getAuth } from './get_auth'; +import { plugins } from './plugins'; + +export function routes(server) { + workpad(server); + socketApi(server); + translate(server); + esFields(server); + esIndices(server); + getAuth(server); + plugins(server); +} diff --git a/x-pack/plugins/canvas/server/routes/plugins.js b/x-pack/plugins/canvas/server/routes/plugins.js new file mode 100644 index 0000000000000..1f4f9e0d26ffa --- /dev/null +++ b/x-pack/plugins/canvas/server/routes/plugins.js @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getPluginStream } from '../lib/get_plugin_stream'; +import { pluginPaths } from '../lib/plugin_paths'; + +export function plugins(server) { + server.route({ + method: 'GET', + path: '/api/canvas/plugins', + handler: function(request, reply) { + const { type } = request.query; + + if (!pluginPaths[type]) return reply({ error: 'Invalid type' }).code(400); + + reply(getPluginStream(type)); + }, + }); +} diff --git a/x-pack/plugins/canvas/server/routes/socket.js b/x-pack/plugins/canvas/server/routes/socket.js new file mode 100644 index 0000000000000..bc94da8232835 --- /dev/null +++ b/x-pack/plugins/canvas/server/routes/socket.js @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import socket from 'socket.io'; +import { createHandlers } from '../lib/create_handlers'; +import { socketInterpreterProvider } from '../../common/interpreter/socket_interpret'; +import { serializeProvider } from '../../common/lib/serialize'; +import { functionsRegistry } from '../../common/lib/functions_registry'; +import { typesRegistry } from '../../common/lib/types_registry'; +import { loadServerPlugins } from '../lib/load_server_plugins'; +import { getAuthHeader } from './get_auth/get_auth_header'; + +export function socketApi(server) { + const io = socket(server.listener, { path: '/socket.io' }); + + io.on('connection', socket => { + console.log('User connected, attaching handlers'); + + // This is the HAPI request object + const request = socket.handshake; + + const authHeader = getAuthHeader(request, server); + + // Create the function list + socket.emit('getFunctionList'); + const getClientFunctions = new Promise(resolve => socket.once('functionList', resolve)); + + socket.on('getFunctionList', () => { + loadServerPlugins().then(() => socket.emit('functionList', functionsRegistry.toJS())); + }); + + const handler = ({ ast, context, id }) => { + Promise.all([getClientFunctions, authHeader]).then(([clientFunctions, authHeader]) => { + if (server.plugins.security) request.headers.authorization = authHeader; + + const types = typesRegistry.toJS(); + const interpret = socketInterpreterProvider({ + types, + functions: functionsRegistry.toJS(), + handlers: createHandlers(request, server), + referableFunctions: clientFunctions, + socket: socket, + }); + + const { serialize, deserialize } = serializeProvider(types); + return interpret(ast, deserialize(context)) + .then(value => { + socket.emit(`resp:${id}`, { value: serialize(value) }); + }) + .catch(e => { + socket.emit(`resp:${id}`, { + error: e.message, + stack: e.stack, + }); + }); + }); + }; + + socket.on('run', handler); + socket.on('disconnect', () => { + console.log('User disconnected, removing handlers.'); + socket.removeListener('run', handler); + }); + }); +} diff --git a/x-pack/plugins/canvas/server/routes/translate.js b/x-pack/plugins/canvas/server/routes/translate.js new file mode 100644 index 0000000000000..f2b412c37e9b9 --- /dev/null +++ b/x-pack/plugins/canvas/server/routes/translate.js @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { fromExpression, toExpression } from '../../common/lib/ast'; + +export function translate(server) { + /* + Get AST from expression + */ + server.route({ + method: 'GET', + path: '/api/canvas/ast', + handler: function(request, reply) { + if (!request.query.expression) + return reply({ error: '"expression" query is required' }).code(400); + reply(fromExpression(request.query.expression)); + }, + }); + + server.route({ + method: 'POST', + path: '/api/canvas/expression', + handler: function(request, reply) { + try { + const exp = toExpression(request.payload); + reply(exp); + } catch (e) { + reply({ error: e.message }).code(400); + } + }, + }); +} diff --git a/x-pack/plugins/canvas/server/routes/workpad.js b/x-pack/plugins/canvas/server/routes/workpad.js new file mode 100644 index 0000000000000..b1d2cfa63477b --- /dev/null +++ b/x-pack/plugins/canvas/server/routes/workpad.js @@ -0,0 +1,165 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import boom from 'boom'; +import { CANVAS_TYPE, API_ROUTE_WORKPAD } from '../../common/lib/constants'; +import { getId } from '../../public/lib/get_id'; + +export function workpad(server) { + //const config = server.config(); + const { errors: esErrors } = server.plugins.elasticsearch.getCluster('data'); + const routePrefix = API_ROUTE_WORKPAD; + + function formatResponse(reply, returnResponse = false) { + return resp => { + if (resp.isBoom) return reply(resp); // can't wrap it if it's already a boom error + + if (resp instanceof esErrors['400']) return reply(boom.badRequest(resp)); + + if (resp instanceof esErrors['401']) return reply(boom.unauthorized()); + + if (resp instanceof esErrors['403']) + return reply(boom.forbidden("Sorry, you don't have access to that")); + + if (resp instanceof esErrors['404']) return reply(boom.wrap(resp, 404)); + + return returnResponse ? resp : reply(resp); + }; + } + + function createWorkpad(req, id) { + const savedObjectsClient = req.getSavedObjectsClient(); + + if (!req.payload) return Promise.resolve(boom.badRequest('A workpad payload is required')); + + const now = new Date().toISOString(); + return savedObjectsClient.create( + CANVAS_TYPE, + { + ...req.payload, + '@timestamp': now, + '@created': now, + }, + { id: id || req.payload.id || getId('workpad') } + ); + } + + function updateWorkpad(req) { + const savedObjectsClient = req.getSavedObjectsClient(); + const { id } = req.params; + + const now = new Date().toISOString(); + + return savedObjectsClient.get(CANVAS_TYPE, id).then(workpad => { + // TODO: Using create with force over-write because of version conflict issues with update + return savedObjectsClient.create( + CANVAS_TYPE, + { + ...req.payload, + '@timestamp': now, + '@created': workpad.attributes['@created'], + }, + { overwrite: true, id } + ); + }); + } + + function deleteWorkpad(req) { + const savedObjectsClient = req.getSavedObjectsClient(); + const { id } = req.params; + + return savedObjectsClient.delete(CANVAS_TYPE, id); + } + + function findWorkpad(req) { + const savedObjectsClient = req.getSavedObjectsClient(); + const { name, page, perPage } = req.query; + + return savedObjectsClient.find({ + type: CANVAS_TYPE, + sortField: '@timestamp', + sortOrder: 'desc', + search: name ? `${name}* | ${name}` : '*', + searchFields: ['name'], + fields: ['id', 'name', '@created', '@timestamp'], + page, + perPage, + }); + } + + // get workpad + server.route({ + method: 'GET', + path: `${routePrefix}/{id}`, + handler: function(req, reply) { + const savedObjectsClient = req.getSavedObjectsClient(); + const { id } = req.params; + + return savedObjectsClient + .get(CANVAS_TYPE, id) + .then(obj => obj.attributes) + .then(formatResponse(reply)) + .catch(formatResponse(reply)); + }, + }); + + // create workpad + server.route({ + method: 'POST', + path: routePrefix, + config: { payload: { allow: 'application/json', maxBytes: 26214400 } }, // 25MB payload limit + handler: function(request, reply) { + createWorkpad(request) + .then(() => reply({ ok: true })) + .catch(formatResponse(reply)); + }, + }); + + // update workpad + server.route({ + method: 'PUT', + path: `${routePrefix}/{id}`, + config: { payload: { allow: 'application/json', maxBytes: 26214400 } }, // 25MB payload limit + handler: function(request, reply) { + updateWorkpad(request) + .then(() => reply({ ok: true })) + .catch(formatResponse(reply)); + }, + }); + + // delete workpad + server.route({ + method: 'DELETE', + path: `${routePrefix}/{id}`, + handler: function(request, reply) { + deleteWorkpad(request) + .then(() => reply({ ok: true })) + .catch(formatResponse(reply)); + }, + }); + + // find workpads + server.route({ + method: 'GET', + path: `${routePrefix}/find`, + handler: function(request, reply) { + findWorkpad(request) + .then(formatResponse(reply, true)) + .then(resp => { + reply({ + total: resp.total, + workpads: resp.saved_objects.map(hit => hit.attributes), + }); + }) + .catch(() => { + reply({ + total: 0, + workpads: [], + }); + }); + }, + }); +} diff --git a/x-pack/plugins/canvas/server/usage/__tests__/collector.handle_response.js b/x-pack/plugins/canvas/server/usage/__tests__/collector.handle_response.js new file mode 100644 index 0000000000000..06c12ca614936 --- /dev/null +++ b/x-pack/plugins/canvas/server/usage/__tests__/collector.handle_response.js @@ -0,0 +1,123 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { handleResponse } from '../collector'; +import { workpads } from '../../../__tests__/fixtures/workpads'; + +const getMockResponse = (mocks = workpads) => ({ + hits: { + hits: mocks.map(workpad => ({ + _source: { + 'canvas-workpad': workpad, + }, + })), + }, +}); + +describe('usage collector handle es response data', () => { + it('should summarize workpads, pages, and elements', () => { + const usage = handleResponse(getMockResponse()); + expect(usage).to.eql({ + workpads: { + total: 6, // num workpad documents in .kibana index + }, + pages: { + total: 16, // num pages in all the workpads + per_workpad: { avg: 2.6666666666666665, min: 1, max: 4 }, + }, + elements: { + total: 34, // num elements in all the pages + per_page: { avg: 2.125, min: 1, max: 5 }, + }, + functions: { + per_element: { avg: 4, min: 2, max: 7 }, + total: 36, + in_use: [ + 'demodata', + 'ply', + 'rowCount', + 'as', + 'staticColumn', + 'math', + 'mapColumn', + 'sort', + 'pointseries', + 'plot', + 'seriesStyle', + 'filters', + 'markdown', + 'render', + 'getCell', + 'repeatImage', + 'pie', + 'table', + 'image', + 'shape', + ], + }, + }); + }); + + it('should collect correctly if an expression has null as an argument (possible sub-expression)', () => { + const mockEsResponse = getMockResponse([ + { + name: 'Tweet Data Workpad 1', + id: 'workpad-ae00567f-5510-4d68-b07f-6b1661948e03', + width: 792, + height: 612, + page: 0, + pages: [ + { + elements: [ + { + expression: 'toast butter=null', + }, + ], + }, + ], + '@timestamp': '2018-07-26T02:29:00.964Z', + '@created': '2018-07-25T22:56:31.460Z', + assets: {}, + }, + ]); + const usage = handleResponse(mockEsResponse); + expect(usage).to.eql({ + workpads: { total: 1 }, + pages: { total: 1, per_workpad: { avg: 1, min: 1, max: 1 } }, + elements: { total: 1, per_page: { avg: 1, min: 1, max: 1 } }, + functions: { total: 1, in_use: ['toast'], per_element: { avg: 1, min: 1, max: 1 } }, + }); + }); + + it('should fail gracefully if workpad has 0 pages (corrupted workpad)', () => { + const mockEsResponseCorrupted = getMockResponse([ + { + name: 'Tweet Data Workpad 2', + id: 'workpad-ae00567f-5510-4d68-b07f-6b1661948e03', + width: 792, + height: 612, + page: 0, + pages: [], // pages should never be empty, and *may* prevent the ui from rendering properly + '@timestamp': '2018-07-26T02:29:00.964Z', + '@created': '2018-07-25T22:56:31.460Z', + assets: {}, + }, + ]); + const usage = handleResponse(mockEsResponseCorrupted); + expect(usage).to.eql({ + workpads: { total: 1 }, + pages: { total: 0, per_workpad: { avg: 0, min: 0, max: 0 } }, + elements: undefined, + functions: undefined, + }); + }); + + it('should fail gracefully in general', () => { + const usage = handleResponse({ hits: { total: 0 } }); + expect(usage).to.eql(undefined); + }); +}); diff --git a/x-pack/plugins/canvas/server/usage/collector.js b/x-pack/plugins/canvas/server/usage/collector.js new file mode 100644 index 0000000000000..f0c55e6ae0a62 --- /dev/null +++ b/x-pack/plugins/canvas/server/usage/collector.js @@ -0,0 +1,150 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { sum as arraySum, min as arrayMin, max as arrayMax, get } from 'lodash'; +import { CANVAS_USAGE_TYPE, CANVAS_TYPE } from '../../common/lib/constants'; +import { fromExpression } from '../../common/lib/ast'; + +/* + * @param ast: an ast that includes functions to track + * @param cb: callback to do something with a function that has been found + */ +const collectFns = (ast, cb) => { + if (ast.type === 'expression') { + ast.chain.forEach(({ function: cFunction, arguments: cArguments }) => { + cb(cFunction); + + // recurse the argumetns and update the set along the way + Object.keys(cArguments).forEach(argName => { + cArguments[argName].forEach(subAst => { + if (subAst != null) collectFns(subAst, cb); + }); + }); + }); + } +}; + +export function handleResponse({ hits }) { + const workpadDocs = get(hits, 'hits', null); + if (workpadDocs == null) return; + + const functionSet = new Set(); + + // make a summary of info about each workpad + const workpadsInfo = workpadDocs.map(hit => { + const workpad = hit._source[CANVAS_TYPE]; + + let pages; + try { + pages = { count: workpad.pages.length }; + } catch (err) { + console.warn(err, workpad); + } + const elementCounts = workpad.pages.reduce( + (accum, page) => accum.concat(page.elements.length), + [] + ); + const functionCounts = workpad.pages.reduce((accum, page) => { + return page.elements.map(element => { + const ast = fromExpression(element.expression); + collectFns(ast, cFunction => { + functionSet.add(cFunction); + }); + return ast.chain.length; // get the number of parts in the expression + }); + }, []); + return { pages, elementCounts, functionCounts }; + }); + + // combine together info from across the workpads + const combinedWorkpadsInfo = workpadsInfo.reduce( + (accum, pageInfo) => { + const { pages, elementCounts, functionCounts } = pageInfo; + + return { + pageMin: pages.count < accum.pageMin ? pages.count : accum.pageMin, + pageMax: pages.count > accum.pageMax ? pages.count : accum.pageMax, + pageCounts: accum.pageCounts.concat(pages.count), + elementCounts: accum.elementCounts.concat(elementCounts), + functionCounts: accum.functionCounts.concat(functionCounts), + }; + }, + { + pageMin: Infinity, + pageMax: -Infinity, + pageCounts: [], + elementCounts: [], + functionCounts: [], + } + ); + const { pageCounts, pageMin, pageMax, elementCounts, functionCounts } = combinedWorkpadsInfo; + + const pageTotal = arraySum(pageCounts); + const elementsTotal = arraySum(elementCounts); + const functionsTotal = arraySum(functionCounts); + const pagesInfo = + workpadsInfo.length > 0 + ? { + total: pageTotal, + per_workpad: { + avg: pageTotal / pageCounts.length, + min: pageMin, + max: pageMax, + }, + } + : undefined; + const elementsInfo = + pageTotal > 0 + ? { + total: elementsTotal, + per_page: { + avg: elementsTotal / elementCounts.length, + min: arrayMin(elementCounts), + max: arrayMax(elementCounts), + }, + } + : undefined; + const functionsInfo = + elementsTotal > 0 + ? { + total: functionsTotal, + in_use: Array.from(functionSet), + per_element: { + avg: functionsTotal / functionCounts.length, + min: arrayMin(functionCounts), + max: arrayMax(functionCounts), + }, + } + : undefined; + + return { + workpads: { total: workpadsInfo.length }, + pages: pagesInfo, + elements: elementsInfo, + functions: functionsInfo, + }; +} + +export function registerCanvasUsageCollector(server) { + const index = server.config().get('kibana.index'); + const collector = server.usage.collectorSet.makeUsageCollector({ + type: CANVAS_USAGE_TYPE, + fetch: async callCluster => { + const searchParams = { + size: 10000, // elasticsearch index.max_result_window default value + index, + ignoreUnavailable: true, + filterPath: ['hits.hits._source.canvas-workpad'], + body: { query: { term: { type: { value: CANVAS_TYPE } } } }, + }; + + const esResponse = await callCluster('search', searchParams); + if (get(esResponse, 'hits.hits.length') > 0) return handleResponse(esResponse); + }, + }); + + server.usage.collectorSet.register(collector); +} diff --git a/x-pack/plugins/canvas/server/usage/index.js b/x-pack/plugins/canvas/server/usage/index.js new file mode 100644 index 0000000000000..b3d16a2b7be31 --- /dev/null +++ b/x-pack/plugins/canvas/server/usage/index.js @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { registerCanvasUsageCollector } from './collector'; diff --git a/x-pack/plugins/canvas/tasks/dev.js b/x-pack/plugins/canvas/tasks/dev.js new file mode 100644 index 0000000000000..99ca40fbde59d --- /dev/null +++ b/x-pack/plugins/canvas/tasks/dev.js @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export default (gulp, { multiProcess }) => { + gulp.task('canvas:dev', done => { + return multiProcess(['canvas:plugins:dev', 'dev'], done, true); + }); +}; diff --git a/x-pack/plugins/canvas/tasks/helpers/babelhook.js b/x-pack/plugins/canvas/tasks/helpers/babelhook.js new file mode 100644 index 0000000000000..5afe3b1d4507e --- /dev/null +++ b/x-pack/plugins/canvas/tasks/helpers/babelhook.js @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +const { resolve } = require('path'); +const register = require('babel-register'); + +const options = { + sourceMaps: false, + plugins: [ + [ + 'mock-imports', + [ + { + pattern: 'scriptjs', + location: resolve(__dirname, '..', 'mocks', 'noop'), + }, + { + pattern: 'ui/chrome', + location: resolve(__dirname, '..', 'mocks', 'uiChrome'), + }, + { + pattern: 'ui/notify', + location: resolve(__dirname, '..', 'mocks', 'uiNotify'), + }, + { + pattern: 'ui/url/absolute_to_parsed_url', + location: resolve(__dirname, '..', 'mocks', 'absoluteToParsedUrl'), + }, + { + pattern: 'socket.io-client', + location: resolve(__dirname, '..', 'mocks', 'socketClient'), + }, + { + // ugly hack so that importing non-js files works, required for the function docs + pattern: '.(less|png|svg)$', + location: resolve(__dirname, '..', 'mocks', 'noop'), + }, + { + pattern: 'plugins/canvas/apps', + location: resolve(__dirname, '..', 'mocks', 'noop'), + }, + { + pattern: '/state/store', + location: resolve(__dirname, '..', 'mocks', 'stateStore'), + }, + ], + ], + ], +}; + +register(options); diff --git a/x-pack/plugins/canvas/tasks/helpers/dom_setup.js b/x-pack/plugins/canvas/tasks/helpers/dom_setup.js new file mode 100644 index 0000000000000..386c764ce23db --- /dev/null +++ b/x-pack/plugins/canvas/tasks/helpers/dom_setup.js @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { JSDOM } from 'jsdom'; +import { APP_ROUTE } from '../../common/lib/constants'; +import chrome from '../mocks/uiChrome'; + +const basePath = chrome.getBasePath(); +const basename = `${basePath}${APP_ROUTE}`; + +const { window } = new JSDOM('', { + url: `http://localhost:5601/${basename}`, + pretendToBeVisual: true, +}); +global.window = window; +global.document = window.document; +global.navigator = window.navigator; +global.requestAnimationFrame = window.requestAnimationFrame; +global.HTMLElement = window.HTMLElement; diff --git a/x-pack/plugins/canvas/tasks/helpers/enzyme_setup.js b/x-pack/plugins/canvas/tasks/helpers/enzyme_setup.js new file mode 100644 index 0000000000000..290e3d220aa4b --- /dev/null +++ b/x-pack/plugins/canvas/tasks/helpers/enzyme_setup.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { configure } from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; + +configure({ adapter: new Adapter() }); diff --git a/x-pack/plugins/canvas/tasks/helpers/webpack.plugins.js b/x-pack/plugins/canvas/tasks/helpers/webpack.plugins.js new file mode 100644 index 0000000000000..405a99a507196 --- /dev/null +++ b/x-pack/plugins/canvas/tasks/helpers/webpack.plugins.js @@ -0,0 +1,136 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +const path = require('path'); + +const sourceDir = path.resolve(__dirname, '../../canvas_plugin_src'); +const buildDir = path.resolve(__dirname, '../../canvas_plugin'); + +module.exports = { + entry: { + 'elements/all': path.join(sourceDir, 'elements/register.js'), + 'renderers/all': path.join(sourceDir, 'renderers/register.js'), + 'uis/transforms/all': path.join(sourceDir, 'uis/transforms/register.js'), + 'uis/models/all': path.join(sourceDir, 'uis/models/register.js'), + 'uis/views/all': path.join(sourceDir, 'uis/views/register.js'), + 'uis/datasources/all': path.join(sourceDir, 'uis/datasources/register.js'), + 'uis/arguments/all': path.join(sourceDir, 'uis/arguments/register.js'), + 'functions/browser/all': path.join(sourceDir, 'functions/browser/register.js'), + 'functions/common/all': path.join(sourceDir, 'functions/common/register.js'), + 'functions/server/all': path.join(sourceDir, 'functions/server/register.js'), + 'types/all': path.join(sourceDir, 'types/register.js'), + }, + target: 'webworker', + + output: { + path: buildDir, + filename: '[name].js', // Need long paths here. + libraryTarget: 'umd', + }, + + resolve: { + extensions: ['.js', '.json'], + }, + + plugins: [ + function loaderFailHandler() { + // bails on error, including loader errors + // see https://github.com/webpack/webpack/issues/708, which does not fix loader errors + let isWatch = true; + + this.plugin('run', function(compiler, callback) { + isWatch = false; + callback.call(compiler); + }); + + this.plugin('done', function(stats) { + if (stats.compilation.errors && stats.compilation.errors.length && !isWatch) + throw stats.compilation.errors[0]; + }); + }, + ], + + module: { + rules: [ + // There's some React 15 propTypes funny business in EUI, this strips out propTypes and fixes it + { + test: /(@elastic\/eui|moment)\/.*\.js$/, + loaders: 'babel-loader', + options: { + babelrc: false, + presets: [ + 'react', + [ + 'env', + { + targets: { + node: 'current', + }, + }, + ], + ], + plugins: [ + 'transform-react-remove-prop-types', // specifically this, strips out propTypes + 'pegjs-inline-precompile', + 'transform-object-rest-spread', + 'transform-async-to-generator', + 'transform-class-properties', + [ + 'inline-react-svg', + { + ignorePattern: 'images/*', + svgo: { + plugins: [{ cleanupIDs: false }, { removeViewBox: false }], + }, + }, + ], + ], + }, + }, + { + test: /\.js$/, + loaders: 'babel-loader', + options: { + plugins: [ + 'transform-object-rest-spread', + 'transform-async-to-generator', + 'transform-class-properties', + ], + presets: [ + 'react', + [ + 'env', + { + targets: { + node: 'current', + }, + }, + ], + ], + }, + exclude: [/node_modules/], + }, + { + test: /\.(png|jpg|gif|jpeg|svg)$/, + loaders: ['url-loader'], + }, + { + test: /\.(css|scss)$/, + loaders: ['style-loader', 'css-loader', 'sass-loader'], + }, + ], + }, + + node: { + // Don't replace built-in globals + __filename: false, + __dirname: false, + }, + + watchOptions: { + ignored: [/node_modules/], + }, +}; diff --git a/x-pack/plugins/canvas/tasks/index.js b/x-pack/plugins/canvas/tasks/index.js new file mode 100644 index 0000000000000..9835f76f12609 --- /dev/null +++ b/x-pack/plugins/canvas/tasks/index.js @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import dev from './dev'; +import peg from './peg'; +import plugins from './plugins'; +import prepare from './prepare'; +import test from './test'; + +export default function canvasTasks(gulp, gulpHelpers) { + dev(gulp, gulpHelpers); + peg(gulp, gulpHelpers); + plugins(gulp, gulpHelpers); + prepare(gulp, gulpHelpers); + test(gulp, gulpHelpers); +} diff --git a/x-pack/plugins/canvas/tasks/mocks/absoluteToParsedUrl.js b/x-pack/plugins/canvas/tasks/mocks/absoluteToParsedUrl.js new file mode 100644 index 0000000000000..d73885ef0cc28 --- /dev/null +++ b/x-pack/plugins/canvas/tasks/mocks/absoluteToParsedUrl.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const absoluteToParsedUrl = () => { + getAbsoluteUrl: () => + 'http://localhost:5601/kbp/app/canvas#/workpad/workpad-24d56dad-ae70-42b8-9ef1-c5350ecd426c/page/1'; +}; // noop diff --git a/x-pack/plugins/canvas/tasks/mocks/noop.js b/x-pack/plugins/canvas/tasks/mocks/noop.js new file mode 100644 index 0000000000000..8d6abb810be9b --- /dev/null +++ b/x-pack/plugins/canvas/tasks/mocks/noop.js @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export default function() {} diff --git a/x-pack/plugins/canvas/tasks/mocks/socketClient.js b/x-pack/plugins/canvas/tasks/mocks/socketClient.js new file mode 100644 index 0000000000000..93270c0801d8d --- /dev/null +++ b/x-pack/plugins/canvas/tasks/mocks/socketClient.js @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +const noop = () => {}; + +// arguments: url, options +// https://github.com/socketio/socket.io-client/blob/master/docs/API.md#iourl-options +export default function mockIo() { + return { + on: noop, + emit: noop, + once: noop, + }; +} diff --git a/x-pack/plugins/canvas/tasks/mocks/stateStore.js b/x-pack/plugins/canvas/tasks/mocks/stateStore.js new file mode 100644 index 0000000000000..9d3df08fffe09 --- /dev/null +++ b/x-pack/plugins/canvas/tasks/mocks/stateStore.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export function getState() { + return { + assets: { + yay: { value: 'here is your image' }, + }, + }; +} diff --git a/x-pack/plugins/canvas/tasks/mocks/uiChrome.js b/x-pack/plugins/canvas/tasks/mocks/uiChrome.js new file mode 100644 index 0000000000000..c4366bd23645b --- /dev/null +++ b/x-pack/plugins/canvas/tasks/mocks/uiChrome.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export default { + getBasePath: () => '/abc', + trackSubUrlForApp: () => undefined, // noop +}; diff --git a/x-pack/plugins/canvas/tasks/mocks/uiNotify.js b/x-pack/plugins/canvas/tasks/mocks/uiNotify.js new file mode 100644 index 0000000000000..ad313bc69da45 --- /dev/null +++ b/x-pack/plugins/canvas/tasks/mocks/uiNotify.js @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +const notifierProto = { + error: msg => `error: ${msg}`, + warning: msg => `warning: ${msg}`, + info: msg => `info: ${msg}`, +}; + +export class Notifier { + constructor() { + Object.assign(this, notifierProto); + } +} diff --git a/x-pack/plugins/canvas/tasks/peg.js b/x-pack/plugins/canvas/tasks/peg.js new file mode 100644 index 0000000000000..0d202c4ac79dd --- /dev/null +++ b/x-pack/plugins/canvas/tasks/peg.js @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { resolve } from 'path'; + +const grammarDir = resolve(__dirname, '..', 'common', 'lib'); + +export default function pegTask(gulp, { pegjs }) { + gulp.task('canvas:peg:build', function() { + return gulp + .src(`${grammarDir}/*.peg`) + .pipe( + pegjs({ + format: 'commonjs', + allowedStartRules: ['expression', 'argument'], + }) + ) + .pipe(gulp.dest(grammarDir)); + }); + + gulp.task('canvas:peg:dev', function() { + gulp.watch(`${grammarDir}/*.peg`, ['peg']); + }); +} diff --git a/x-pack/plugins/canvas/tasks/plugins.js b/x-pack/plugins/canvas/tasks/plugins.js new file mode 100644 index 0000000000000..0c4c7cec686e9 --- /dev/null +++ b/x-pack/plugins/canvas/tasks/plugins.js @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import webpack from 'webpack'; +import webpackConfig from './helpers/webpack.plugins'; + +const devtool = 'inline-cheap-module-source-map'; + +export default function pluginsTasks(gulp, { log, colors }) { + const onComplete = done => (err, stats) => { + if (err) { + done && done(err); + } else { + const seconds = ((stats.endTime - stats.startTime) / 1000).toFixed(2); + log(`${colors.green.bold('canvas:plugins')} Plugins built in ${seconds} seconds`); + done && done(); + } + }; + + gulp.task('canvas:plugins:build', function(done) { + webpack({ ...webpackConfig, devtool }, onComplete(done)); + }); + + // eslint-disable-next-line no-unused-vars + gulp.task('canvas:plugins:dev', function(done /* added to make gulp async */) { + log('Starting initial build of plugins. This will take awhile.'); + webpack({ ...webpackConfig, devtool, watch: true }, (err, stats) => { + onComplete()(err, stats); + }); + }); + + gulp.task('canvas:plugins:build-prod', done => webpack(webpackConfig, onComplete(done))); +} diff --git a/x-pack/plugins/canvas/tasks/prepare.js b/x-pack/plugins/canvas/tasks/prepare.js new file mode 100644 index 0000000000000..a165cd7c7ed5a --- /dev/null +++ b/x-pack/plugins/canvas/tasks/prepare.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export default gulp => { + // anything that needs to happen pre-build or pre-dev + gulp.task('canvas:prepare', ['canvas:plugins:build-prod']); +}; diff --git a/x-pack/plugins/canvas/tasks/test.js b/x-pack/plugins/canvas/tasks/test.js new file mode 100644 index 0000000000000..df9aecd9dc398 --- /dev/null +++ b/x-pack/plugins/canvas/tasks/test.js @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { resolve, join } from 'path'; + +export default function testTasks(gulp, { mocha }) { + const canvasRoot = resolve(__dirname, '..'); + + function runMocha(globs, { withEnzyme = false, withDOM = false } = {}) { + const requires = [join(canvasRoot, 'tasks/helpers/babelhook')]; + + if (withDOM) requires.push(join(canvasRoot, 'tasks/helpers/dom_setup')); + if (withEnzyme) requires.push(join(canvasRoot, 'tasks/helpers/enzyme_setup')); + + return gulp.src(globs, { read: false }).pipe( + mocha({ + ui: 'bdd', + require: requires, + }) + ); + } + + const getTestGlobs = rootPath => [ + join(canvasRoot, `${rootPath}/**/__tests__/**/*.js`), + join(canvasRoot, `!${rootPath}/**/__tests__/fixtures/**/*.js`), + ]; + + const getRootGlobs = rootPath => [join(canvasRoot, `${rootPath}/**/*.js`)]; + + gulp.task('canvas:test:common', () => { + return runMocha(getTestGlobs('common'), { withDOM: true }); + }); + + gulp.task('canvas:test:server', () => { + return runMocha(getTestGlobs('server')); + }); + + gulp.task('canvas:test:browser', () => { + return runMocha(getTestGlobs('public'), { withEnzyme: true, withDOM: true }); + }); + + gulp.task('canvas:test:plugins', () => { + return runMocha(getTestGlobs('canvas_plugin_src')); + }); + + gulp.task('canvas:test', [ + 'canvas:test:plugins', + 'canvas:test:common', + 'canvas:test:server', + 'canvas:test:browser', + ]); + + gulp.task('canvas:test:dev', () => { + gulp.watch(getRootGlobs('common'), ['canvas:test:common']); + gulp.watch(getRootGlobs('server'), ['canvas:test:server']); + gulp.watch(getRootGlobs('public'), ['canvas:test:browser']); + gulp.watch(getRootGlobs('canvas_plugin_src'), ['canvas:test:plugins']); + }); +} diff --git a/x-pack/plugins/canvas/webpackShims/moment.js b/x-pack/plugins/canvas/webpackShims/moment.js new file mode 100644 index 0000000000000..1261aa7f7bd0f --- /dev/null +++ b/x-pack/plugins/canvas/webpackShims/moment.js @@ -0,0 +1,2 @@ +/* eslint-disable */ +module.exports = require('../../../node_modules/moment/min/moment.min.js'); diff --git a/x-pack/plugins/ml/public/factories/__tests__/state_factory.js b/x-pack/plugins/ml/public/factories/__tests__/state_factory.js index d8b177301e5c9..10cbd87a99b01 100644 --- a/x-pack/plugins/ml/public/factories/__tests__/state_factory.js +++ b/x-pack/plugins/ml/public/factories/__tests__/state_factory.js @@ -15,7 +15,10 @@ describe('ML - mlStateFactory', () => { let stateFactory; let AppState; - beforeEach(ngMock.module('kibana')); + beforeEach(ngMock.module('kibana', function (stateManagementConfigProvider) { + stateManagementConfigProvider.enable(); + })); + beforeEach(ngMock.inject(($injector) => { AppState = $injector.get('AppState'); const Private = $injector.get('Private'); diff --git a/x-pack/tasks/build.js b/x-pack/tasks/build.js index ccf0566c256a2..0ccbbf082315c 100644 --- a/x-pack/tasks/build.js +++ b/x-pack/tasks/build.js @@ -11,7 +11,7 @@ import { ToolingLog } from '@kbn/dev-utils'; import { generateNoticeFromSource } from '../../src/dev'; export default (gulp, { buildTarget }) => { - gulp.task('build', ['clean', 'report', 'prepare'], async () => { + gulp.task('build', ['clean', 'report', 'prepare:build'], async () => { await pluginHelpers.run('build', { skipArchive: true, buildDestination: buildTarget, diff --git a/x-pack/tasks/dev.js b/x-pack/tasks/dev.js index d797f7c0c4cb8..048e32ed86574 100644 --- a/x-pack/tasks/dev.js +++ b/x-pack/tasks/dev.js @@ -8,5 +8,5 @@ import pluginHelpers from '@kbn/plugin-helpers'; import getFlags from './helpers/get_flags'; export default (gulp) => { - gulp.task('dev', ['prepare'], () => pluginHelpers.run('start', { flags: getFlags() })); + gulp.task('dev', ['prepare:dev'], () => pluginHelpers.run('start', { flags: getFlags() })); }; diff --git a/x-pack/tasks/prepare.js b/x-pack/tasks/prepare.js index fad0056e8553c..f4a53a431ea64 100644 --- a/x-pack/tasks/prepare.js +++ b/x-pack/tasks/prepare.js @@ -7,6 +7,12 @@ import { ensureAllBrowsersDownloaded } from '../plugins/reporting/server/browsers'; export default gulp => { - // anything that needs to happen pre-build or pre-dev + // anything that should always happen before anything else gulp.task('prepare', () => ensureAllBrowsersDownloaded()); + + // anything that needs to happen before development + gulp.task('prepare:dev', ['prepare']); + + // anything that needs to happen before building + gulp.task('prepare:build', ['prepare', 'canvas:prepare']); }; diff --git a/x-pack/test/rbac_api_integration/apis/privileges/index.js b/x-pack/test/rbac_api_integration/apis/privileges/index.js index 12ca92f3fe33c..6aef85130a0cf 100644 --- a/x-pack/test/rbac_api_integration/apis/privileges/index.js +++ b/x-pack/test/rbac_api_integration/apis/privileges/index.js @@ -41,6 +41,9 @@ export default function ({ getService }) { 'action:saved_objects/graph-workspace/get', 'action:saved_objects/graph-workspace/bulk_get', 'action:saved_objects/graph-workspace/find', + 'action:saved_objects/canvas-workpad/get', + 'action:saved_objects/canvas-workpad/bulk_get', + 'action:saved_objects/canvas-workpad/find', 'action:saved_objects/index-pattern/get', 'action:saved_objects/index-pattern/bulk_get', 'action:saved_objects/index-pattern/find', diff --git a/x-pack/yarn.lock b/x-pack/yarn.lock index a344b6fe65340..8ca304e62e0b9 100644 --- a/x-pack/yarn.lock +++ b/x-pack/yarn.lock @@ -10,6 +10,19 @@ esutils "^2.0.2" js-tokens "^3.0.0" +"@babel/runtime@7.0.0-beta.54": + version "7.0.0-beta.54" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.0.0-beta.54.tgz#39ebb42723fe7ca4b3e1b00e967e80138d47cadf" + dependencies: + core-js "^2.5.7" + regenerator-runtime "^0.12.0" + +"@elastic/datemath@^4.0.2": + version "4.0.2" + resolved "https://registry.yarnpkg.com/@elastic/datemath/-/datemath-4.0.2.tgz#91417763fa4ec93ad1426cb69aaf2de2e9914a68" + dependencies: + moment "^2.13.0" + "@elastic/eui@4.0.1": version "4.0.1" resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-4.0.1.tgz#6543d397fb31836508fa4323564b02da11c642db" @@ -81,6 +94,12 @@ dependencies: any-observable "^0.3.0" +"@scant/router@^0.1.0": + version "0.1.0" + resolved "https://registry.yarnpkg.com/@scant/router/-/router-0.1.0.tgz#54e7e32282ee05d40ea410a4987ae6444080f989" + dependencies: + url-pattern "^1.0.3" + "@sindresorhus/is@^0.7.0": version "0.7.0" resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.7.0.tgz#9a06f4f137ee84d7df0460c1fdb1135ffa6c50fd" @@ -207,7 +226,7 @@ "@types/events" "*" "@types/node" "*" -abab@^1.0.3, abab@^1.0.4: +abab@^1.0.0, abab@^1.0.3, abab@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/abab/-/abab-1.0.4.tgz#5faad9c2c07f60dd76770f71cf025b62a63cfd4e" @@ -222,16 +241,37 @@ accept@2.x.x: boom "5.x.x" hoek "4.x.x" +accepts@1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.3.tgz#c3ca7434938648c3e0d9c1e328dd68b622c284ca" + dependencies: + mime-types "~2.1.11" + negotiator "0.6.1" + +acorn-globals@^1.0.4: + version "1.0.9" + resolved "https://registry.yarnpkg.com/acorn-globals/-/acorn-globals-1.0.9.tgz#55bb5e98691507b74579d0513413217c380c54cf" + dependencies: + acorn "^2.1.0" + acorn-globals@^4.0.0: version "4.1.0" resolved "https://registry.yarnpkg.com/acorn-globals/-/acorn-globals-4.1.0.tgz#ab716025dbe17c54d3ef81d32ece2b2d99fe2538" dependencies: acorn "^5.0.0" +acorn@^2.1.0, acorn@^2.4.0: + version "2.7.0" + resolved "http://registry.npmjs.org/acorn/-/acorn-2.7.0.tgz#ab6e7d9d886aaca8b085bc3312b79a198433f0e7" + acorn@^5.0.0, acorn@^5.1.2: version "5.3.0" resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.3.0.tgz#7446d39459c54fb49a80e6ee6478149b940ec822" +after@0.8.2: + version "0.8.2" + resolved "https://registry.yarnpkg.com/after/-/after-0.8.2.tgz#fedb394f9f0e02aa9768e702bda23b505fae7e1f" + agent-base@^4.1.0: version "4.2.1" resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.2.1.tgz#d89e5999f797875674c07d87f260fc41e83e8ca9" @@ -255,7 +295,7 @@ ajv@^4.9.1: co "^4.6.0" json-stable-stringify "^1.0.1" -ajv@^5.1.0: +ajv@^5.1.0, ajv@^5.3.0: version "5.5.2" resolved "https://registry.yarnpkg.com/ajv/-/ajv-5.5.2.tgz#73b5eeca3fab653e3d3f9422b341ad42205dc965" dependencies: @@ -496,6 +536,10 @@ array-unique@^0.3.2: version "0.3.2" resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428" +arraybuffer.slice@0.0.6: + version "0.0.6" + resolved "https://registry.yarnpkg.com/arraybuffer.slice/-/arraybuffer.slice-0.0.6.tgz#f33b2159f0532a3f3107a272c0ccfbd1ad2979ca" + arrify@^1.0.0, arrify@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d" @@ -544,6 +588,47 @@ async-limiter@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.0.tgz#78faed8c3d074ab81f22b4e985d79e8738f720f8" +async.queue@^0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/async.queue/-/async.queue-0.5.2.tgz#8d5d90812e1481066bc0904e8cc1712b17c3bd7c" + dependencies: + async.util.queue "0.5.2" + +async.util.arrayeach@0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/async.util.arrayeach/-/async.util.arrayeach-0.5.2.tgz#58c4e98028d55d69bfb05aeb3af44e0a555a829c" + +async.util.isarray@0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/async.util.isarray/-/async.util.isarray-0.5.2.tgz#e62dac8f2636f65875dcf7521c2d24d0dfb2bbdf" + +async.util.map@0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/async.util.map/-/async.util.map-0.5.2.tgz#e588ef86e0b3ab5f027d97af4d6835d055ca69d6" + +async.util.noop@0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/async.util.noop/-/async.util.noop-0.5.2.tgz#bdd62b97cb0aa3f60b586ad148468698975e58b9" + +async.util.onlyonce@0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/async.util.onlyonce/-/async.util.onlyonce-0.5.2.tgz#b8e6fc004adc923164d79e32f2813ee465c24ff2" + +async.util.queue@0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/async.util.queue/-/async.util.queue-0.5.2.tgz#57f65abe1a3cdf273d31abd28ab95425f8222ee5" + dependencies: + async.util.arrayeach "0.5.2" + async.util.isarray "0.5.2" + async.util.map "0.5.2" + async.util.noop "0.5.2" + async.util.onlyonce "0.5.2" + async.util.setimmediate "0.5.2" + +async.util.setimmediate@0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/async.util.setimmediate/-/async.util.setimmediate-0.5.2.tgz#2812ebabf2a58027758d4bc7793d1ccfaf10255f" + async@2.4.0: version "2.4.0" resolved "https://registry.yarnpkg.com/async/-/async-2.4.0.tgz#4990200f18ea5b837c2cc4f8c031a6985c385611" @@ -560,6 +645,12 @@ async@^2.1.4: dependencies: lodash "^4.14.0" +async@^2.5.0: + version "2.6.1" + resolved "https://registry.yarnpkg.com/async/-/async-2.6.1.tgz#b245a23ca71930044ec53fa46aa00a3e87c6a610" + dependencies: + lodash "^4.17.10" + asynckit@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" @@ -572,6 +663,12 @@ atob@~1.1.0: version "1.1.3" resolved "https://registry.yarnpkg.com/atob/-/atob-1.1.3.tgz#95f13629b12c3a51a5d215abdce2aa9f32f80773" +attr-accept@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/attr-accept/-/attr-accept-1.1.3.tgz#48230c79f93790ef2775fcec4f0db0f5db41ca52" + dependencies: + core-js "^2.5.0" + autolinker@~0.15.0: version "0.15.3" resolved "https://registry.yarnpkg.com/autolinker/-/autolinker-0.15.3.tgz#342417d8f2f3461b14cf09088d5edf8791dc9832" @@ -596,6 +693,10 @@ aws4@^1.2.1, aws4@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.6.0.tgz#83ef5ca860b2b32e4a0deedee8c771b9db57471e" +aws4@^1.8.0: + version "1.8.0" + resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.8.0.tgz#f0e003d9ca9e7f59c7a508945d7b2ef9a04a542f" + axios@^0.18.0: version "0.18.0" resolved "https://registry.yarnpkg.com/axios/-/axios-0.18.0.tgz#32d53e4851efdc0a11993b6cd000789d70c05102" @@ -746,6 +847,17 @@ babel-plugin-check-es2015-constants@^6.22.0: dependencies: babel-runtime "^6.22.0" +babel-plugin-inline-react-svg@^0.5.4: + version "0.5.4" + resolved "https://registry.yarnpkg.com/babel-plugin-inline-react-svg/-/babel-plugin-inline-react-svg-0.5.4.tgz#bc818f351cd9d78f5b3bfa7cc1da5f83e7b4010a" + dependencies: + babel-template "^6.26.0" + babel-traverse "^6.26.0" + babylon "^6.18.0" + lodash.isplainobject "^4.0.6" + resolve "^1.8.1" + svgo "^0.7.2" + babel-plugin-istanbul@^4.1.6: version "4.1.6" resolved "https://registry.yarnpkg.com/babel-plugin-istanbul/-/babel-plugin-istanbul-4.1.6.tgz#36c59b2192efce81c5b378321b74175add1c9a45" @@ -759,6 +871,17 @@ babel-plugin-jest-hoist@^23.2.0: version "23.2.0" resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-23.2.0.tgz#e61fae05a1ca8801aadee57a6d66b8cefaf44167" +babel-plugin-mock-imports@^0.0.5: + version "0.0.5" + resolved "https://registry.yarnpkg.com/babel-plugin-mock-imports/-/babel-plugin-mock-imports-0.0.5.tgz#caa865f017d8972fe47772e0fb57f2924e5ce3c5" + +babel-plugin-pegjs-inline-precompile@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/babel-plugin-pegjs-inline-precompile/-/babel-plugin-pegjs-inline-precompile-0.1.0.tgz#3307f2b373a844296385311a7c528c53414dc57e" + dependencies: + babylon "^6.18.0" + pegjs "^0.10.0" + babel-plugin-syntax-object-rest-spread@^6.13.0: version "6.13.0" resolved "https://registry.yarnpkg.com/babel-plugin-syntax-object-rest-spread/-/babel-plugin-syntax-object-rest-spread-6.13.0.tgz#fd6536f2bce13836ffa3a5458c4903a597bb3bf5" @@ -931,6 +1054,10 @@ babel-plugin-transform-es2015-unicode-regex@^6.24.1: babel-runtime "^6.22.0" regexpu-core "^2.0.0" +babel-plugin-transform-react-remove-prop-types@^0.4.14: + version "0.4.15" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-react-remove-prop-types/-/babel-plugin-transform-react-remove-prop-types-0.4.15.tgz#7ba830e77276a0e788cd58ea527b5f70396e12a7" + babel-plugin-transform-regenerator@^6.24.1: version "6.26.0" resolved "https://registry.yarnpkg.com/babel-plugin-transform-regenerator/-/babel-plugin-transform-regenerator-6.26.0.tgz#e0703696fbde27f0a3efcacf8b4dca2f7b3a8f2f" @@ -1036,10 +1163,18 @@ babylon@^6.18.0: version "6.18.0" resolved "https://registry.yarnpkg.com/babylon/-/babylon-6.18.0.tgz#af2f3b88fa6f5c1e4c634d1a0f8eac4f55b395e3" +backo2@1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/backo2/-/backo2-1.0.2.tgz#31ab1ac8b129363463e35b3ebb69f4dfcfba7947" + balanced-match@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" +base64-arraybuffer@0.1.5: + version "0.1.5" + resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-0.1.5.tgz#73926771923b5a19747ad666aa5cd4bf9c6e9ce8" + base64-js@0.0.8: version "0.0.8" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-0.0.8.tgz#1101e9544f4a76b1bc3b26d452ca96d7a35e7978" @@ -1048,6 +1183,14 @@ base64-js@^1.0.2, base64-js@^1.1.2: version "1.2.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.2.1.tgz#a91947da1f4a516ea38e5b4ec0ec3773675e0886" +base64-js@^1.2.1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.0.tgz#cab1e6118f051095e58b5281aea8c1cd22bfc0e3" + +base64id@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/base64id/-/base64id-1.0.0.tgz#47688cb99bb6804f0e06d3e763b1c32e57d8e6b6" + base@^0.11.1: version "0.11.2" resolved "https://registry.yarnpkg.com/base/-/base-0.11.2.tgz#7bde5ced145b6d551a90db87f83c558b4eb48a8f" @@ -1070,6 +1213,12 @@ beeper@^1.0.0: version "1.1.1" resolved "https://registry.yarnpkg.com/beeper/-/beeper-1.1.1.tgz#e6d5ea8c5dad001304a70b22638447f69cb2f809" +better-assert@~1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/better-assert/-/better-assert-1.0.2.tgz#40866b9e1b9e0b55b481894311e68faffaebc522" + dependencies: + callsite "1.0.0" + big.js@^3.1.3: version "3.2.0" resolved "https://registry.yarnpkg.com/big.js/-/big.js-3.2.0.tgz#a5fc298b81b9e0dca2e458824784b65c52ba588e" @@ -1080,6 +1229,10 @@ bl@^1.0.0: dependencies: readable-stream "^2.0.5" +blob@0.0.4: + version "0.0.4" + resolved "https://registry.yarnpkg.com/blob/-/blob-0.0.4.tgz#bcf13052ca54463f30f9fc7e95b9a47630a94921" + block-stream@*: version "0.0.9" resolved "https://registry.yarnpkg.com/block-stream/-/block-stream-0.0.9.tgz#13ebfe778a03205cfe03751481ebb4b3300c126a" @@ -1284,6 +1437,10 @@ call@3.x.x: boom "4.x.x" hoek "4.x.x" +callsite@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/callsite/-/callsite-1.0.0.tgz#280398e5d664bd74038b6f0905153e6e8af1bc20" + callsites@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/callsites/-/callsites-2.0.0.tgz#06eb84f00eea413da86affefacbffb36093b3c50" @@ -1370,6 +1527,10 @@ chance@1.0.10: version "1.0.10" resolved "https://registry.yarnpkg.com/chance/-/chance-1.0.10.tgz#03500b04ad94e778dd2891b09ec73a6ad87b1996" +change-emitter@^0.1.2: + version "0.1.6" + resolved "https://registry.yarnpkg.com/change-emitter/-/change-emitter-0.1.6.tgz#e8b2fe3d7f1ab7d69a32199aff91ea6931409515" + checksum@0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/checksum/-/checksum-0.1.1.tgz#dc6527d4c90be8560dbd1ed4cecf3297d528e9e9" @@ -1391,10 +1552,20 @@ chownr@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.0.1.tgz#e2a75042a9551908bebd25b8523d5f9769d79181" +chroma-js@^1.3.6: + version "1.3.7" + resolved "https://registry.yarnpkg.com/chroma-js/-/chroma-js-1.3.7.tgz#38db1b46c99b002b77aa5e6b6744589388f28425" + ci-info@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-1.1.2.tgz#03561259db48d0474c8bdc90f5b47b068b6bbfb4" +clap@^1.0.9: + version "1.2.3" + resolved "https://registry.yarnpkg.com/clap/-/clap-1.2.3.tgz#4f36745b32008492557f46412d66d50cb99bce51" + dependencies: + chalk "^1.1.3" + class-utils@^0.3.5: version "0.3.5" resolved "https://registry.yarnpkg.com/class-utils/-/class-utils-0.3.5.tgz#17e793103750f9627b2176ea34cfd1b565903c80" @@ -1459,6 +1630,15 @@ clone-buffer@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/clone-buffer/-/clone-buffer-1.0.0.tgz#e3e25b207ac4e701af721e2cb5a16792cac3dc58" +clone-deep@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-2.0.2.tgz#00db3a1e173656730d1188c3d6aced6d7ea97713" + dependencies: + for-own "^1.0.0" + is-plain-object "^2.0.4" + kind-of "^6.0.0" + shallow-clone "^1.0.0" + clone-response@1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/clone-response/-/clone-response-1.0.2.tgz#d1dc973920314df67fbeb94223b4ee350239e96b" @@ -1497,6 +1677,12 @@ co@^4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" +coa@~1.0.1: + version "1.0.4" + resolved "https://registry.yarnpkg.com/coa/-/coa-1.0.4.tgz#a9ef153660d6a86a8bdec0289a5c684d217432fd" + dependencies: + q "^1.1.2" + code-point-at@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" @@ -1526,12 +1712,26 @@ colors@0.5.x: version "0.5.1" resolved "https://registry.yarnpkg.com/colors/-/colors-0.5.1.tgz#7d0023eaeb154e8ee9fce75dcb923d0ed1667774" +colors@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/colors/-/colors-1.1.2.tgz#168a4701756b6a7f51a12ce0c97bfa28c084ed63" + +combined-stream@1.0.6, combined-stream@~1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.6.tgz#723e7df6e801ac5613113a7e445a9b69cb632818" + dependencies: + delayed-stream "~1.0.0" + combined-stream@^1.0.5, combined-stream@~1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.5.tgz#938370a57b4a51dea2c77c15d5c5fdf895164009" dependencies: delayed-stream "~1.0.0" +combokeys@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/combokeys/-/combokeys-3.0.0.tgz#955c59a3959af40d26846ab6fc3c682448e7572e" + commander@0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/commander/-/commander-0.6.1.tgz#fa68a14f6a945d54dbbe50d8cdb3320e9e3b1a06" @@ -1550,10 +1750,26 @@ commander@2.9.0: dependencies: graceful-readlink ">= 1.0.0" -component-emitter@^1.2.0, component-emitter@^1.2.1: +commander@~2.17.1: + version "2.17.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-2.17.1.tgz#bd77ab7de6de94205ceacc72f1716d29f20a77bf" + +component-bind@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/component-bind/-/component-bind-1.0.0.tgz#00c608ab7dcd93897c0009651b1d3a8e1e73bbd1" + +component-emitter@1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.1.2.tgz#296594f2753daa63996d2af08d15a95116c9aec3" + +component-emitter@1.2.1, component-emitter@^1.2.0, component-emitter@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6" +component-inherit@0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/component-inherit/-/component-inherit-0.0.3.tgz#645fc4adf58b72b649d5cae65135619db26ff143" + concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -1609,6 +1825,10 @@ convert-source-map@^1.4.0, convert-source-map@^1.5.0: version "1.5.1" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.5.1.tgz#b8278097b9bc229365de5c62cf5fcaed8b5599e5" +cookie@0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.3.1.tgz#e7e0a1f9ef43b4c8ba925c5c5a96e806d16873bb" + cookiejar@^2.1.0: version "2.1.1" resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.1.tgz#41ad57b1b555951ec171412a81942b1e8200d34a" @@ -1617,6 +1837,12 @@ copy-descriptor@^0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" +copy-to-clipboard@^3.0.8: + version "3.0.8" + resolved "https://registry.yarnpkg.com/copy-to-clipboard/-/copy-to-clipboard-3.0.8.tgz#f4e82f4a8830dce4666b7eb8ded0c9bcc313aba9" + dependencies: + toggle-selection "^1.0.3" + core-js@^1.0.0: version "1.2.7" resolved "https://registry.yarnpkg.com/core-js/-/core-js-1.2.7.tgz#652294c14651db28fa93bd2d5ff2983a4f08c636" @@ -1625,7 +1851,7 @@ core-js@^2.4.0, core-js@^2.5.0: version "2.5.3" resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.5.3.tgz#8acc38345824f16d8365b7c9b4259168e8ed603e" -core-js@^2.5.1: +core-js@^2.5.1, core-js@^2.5.7: version "2.5.7" resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.5.7.tgz#f972608ff0cead68b841a16a932d0b183791814e" @@ -1678,6 +1904,10 @@ cryptiles@3.x.x: dependencies: boom "5.x.x" +css-box-model@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/css-box-model/-/css-box-model-1.0.0.tgz#60142814f2b25be00c4aac65ea1a55a531b18922" + css-color-keywords@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/css-color-keywords/-/css-color-keywords-1.0.0.tgz#fea2616dc676b2962686b3af8dbdbe180b244e05" @@ -1712,11 +1942,22 @@ css@^2.2.1: source-map-resolve "^0.3.0" urix "^0.1.0" +csso@~2.3.1: + version "2.3.2" + resolved "https://registry.yarnpkg.com/csso/-/csso-2.3.2.tgz#ddd52c587033f49e94b71fc55569f252e8ff5f85" + dependencies: + clap "^1.0.9" + source-map "^0.5.3" + cssom@0.3.x, "cssom@>= 0.3.2 < 0.4.0": version "0.3.2" resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.3.2.tgz#b8036170c79f07a90ff2f16e22284027a243848b" -"cssstyle@>= 0.2.37 < 0.3.0": +"cssom@>= 0.3.0 < 0.4.0": + version "0.3.4" + resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.3.4.tgz#8cd52e8a3acfd68d3aed38ee0a640177d2f9d797" + +"cssstyle@>= 0.2.36 < 0.3.0", "cssstyle@>= 0.2.37 < 0.3.0": version "0.2.37" resolved "https://registry.yarnpkg.com/cssstyle/-/cssstyle-0.2.37.tgz#541097234cb2513c83ceed3acddc27ff27987d54" dependencies: @@ -1832,6 +2073,10 @@ dashdash@^1.12.0: dependencies: assert-plus "^1.0.0" +dashify@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/dashify/-/dashify-0.1.0.tgz#107daf9cca5e326e30a8b39ffa5048b6684922ea" + dateformat@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-2.2.0.tgz#4065e2013cf9fb916ddfd82efb506ad4c6769062" @@ -1846,6 +2091,12 @@ debug@2.2.0: dependencies: ms "0.7.1" +debug@2.3.3: + version "2.3.3" + resolved "http://registry.npmjs.org/debug/-/debug-2.3.3.tgz#40c453e67e6e13c901ddec317af8986cda9eff8c" + dependencies: + ms "0.7.2" + debug@2.6.0: version "2.6.0" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.0.tgz#bc596bcabe7617f11d9fa15361eded5608b8499b" @@ -2131,6 +2382,45 @@ end-of-stream@~0.1.5: dependencies: once "~1.3.0" +engine.io-client@~1.8.4: + version "1.8.5" + resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-1.8.5.tgz#fe7fb60cb0dcf2fa2859489329cb5968dedeb11f" + dependencies: + component-emitter "1.2.1" + component-inherit "0.0.3" + debug "2.3.3" + engine.io-parser "1.3.2" + has-cors "1.1.0" + indexof "0.0.1" + parsejson "0.0.3" + parseqs "0.0.5" + parseuri "0.0.5" + ws "~1.1.5" + xmlhttprequest-ssl "1.5.3" + yeast "0.1.2" + +engine.io-parser@1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-1.3.2.tgz#937b079f0007d0893ec56d46cb220b8cb435220a" + dependencies: + after "0.8.2" + arraybuffer.slice "0.0.6" + base64-arraybuffer "0.1.5" + blob "0.0.4" + has-binary "0.1.7" + wtf-8 "1.0.0" + +engine.io@~1.8.4: + version "1.8.5" + resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-1.8.5.tgz#4ebe5e75c6dc123dee4afdce6e5fdced21eb93f6" + dependencies: + accepts "1.3.3" + base64id "1.0.0" + cookie "0.3.1" + debug "2.3.3" + engine.io-parser "1.3.2" + ws "~1.1.5" + entities@^1.1.1, entities@~1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.1.tgz#6e5c2d0a5621b5dadaecef80b90edfb5cd7772f0" @@ -2219,6 +2509,17 @@ escape-string-regexp@1.0.5, escape-string-regexp@^1.0.2, escape-string-regexp@^1 version "1.0.5" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" +escodegen@^1.6.1: + version "1.11.0" + resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-1.11.0.tgz#b27a9389481d5bfd5bec76f7bb1eb3f8f4556589" + dependencies: + esprima "^3.1.3" + estraverse "^4.2.0" + esutils "^2.0.2" + optionator "^0.8.1" + optionalDependencies: + source-map "~0.6.1" + escodegen@^1.9.0: version "1.9.0" resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-1.9.0.tgz#9811a2f265dc1cd3894420ee3717064b632b8852" @@ -2259,6 +2560,10 @@ escodegen@~1.3.2: optionalDependencies: source-map "~0.1.33" +esprima@^2.6.0: + version "2.7.3" + resolved "https://registry.yarnpkg.com/esprima/-/esprima-2.7.3.tgz#96e3b70d5779f6ad49cd032673d1c312767ba581" + esprima@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/esprima/-/esprima-3.1.3.tgz#fdca51cee6133895e3c88d535ce49dbff62a4633" @@ -2299,6 +2604,10 @@ eventemitter3@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-3.1.0.tgz#090b4d6cdbd645ed10bf750d4b5407942d7ba163" +events@^1.0.2: + version "1.1.1" + resolved "https://registry.yarnpkg.com/events/-/events-1.1.1.tgz#9ebdb7635ad099c70dcc4c2a1f5004288e8bd924" + exec-sh@^0.2.0: version "0.2.1" resolved "https://registry.yarnpkg.com/exec-sh/-/exec-sh-0.2.1.tgz#163b98a6e89e6b65b47c2a28d215bc1f63989c38" @@ -2405,6 +2714,10 @@ extend@^3.0.0, extend@~3.0.0, extend@~3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.1.tgz#a755ea7bc1adfcc5a31ce7e762dbaadc5e636444" +extend@~3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" + external-editor@^1.1.0: version "1.1.1" resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-1.1.1.tgz#12d7b0db850f7ff7e7081baf4005700060c4600b" @@ -2497,6 +2810,18 @@ fb-watchman@^2.0.0: dependencies: bser "^2.0.0" +fbjs@^0.8.1: + version "0.8.17" + resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.17.tgz#c4d598ead6949112653d6588b01a5cdcd9f90fdd" + dependencies: + core-js "^1.0.0" + isomorphic-fetch "^2.1.1" + loose-envify "^1.0.0" + object-assign "^4.1.0" + promise "^7.1.1" + setimmediate "^1.0.5" + ua-parser-js "^0.7.18" + fbjs@^0.8.16, fbjs@^0.8.4, fbjs@^0.8.5, fbjs@^0.8.9: version "0.8.16" resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.16.tgz#5e67432f550dc41b572bf55847b8aca64e5337db" @@ -2536,6 +2861,10 @@ figures@^1.3.5: escape-string-regexp "^1.0.5" object-assign "^4.1.0" +file-saver@^1.3.8: + version "1.3.8" + resolved "https://registry.yarnpkg.com/file-saver/-/file-saver-1.3.8.tgz#e68a30c7cb044e2fb362b428469feb291c2e09d8" + filename-regex@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/filename-regex/-/filename-regex-2.0.1.tgz#c1c4b9bee3e09725ddb106b75c1e301fe2f18b26" @@ -2666,6 +2995,10 @@ fontkit@^1.0.0: unicode-properties "^1.0.0" unicode-trie "^0.3.0" +for-in@^0.1.3: + version "0.1.8" + resolved "https://registry.yarnpkg.com/for-in/-/for-in-0.1.8.tgz#d8773908e31256109952b1fdb9b3fa867d2775e1" + for-in@^1.0.1, for-in@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" @@ -2706,6 +3039,14 @@ form-data@~2.1.1: combined-stream "^1.0.5" mime-types "^2.1.12" +form-data@~2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.2.tgz#4970498be604c20c005d4f5c23aecd21d6b49099" + dependencies: + asynckit "^0.4.0" + combined-stream "1.0.6" + mime-types "^2.1.12" + formidable@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/formidable/-/formidable-1.1.1.tgz#96b8886f7c3c3508b932d6bd70c4d3a88f35f1a9" @@ -3102,11 +3443,26 @@ gulp-mocha@2.2.0: temp "^0.8.3" through "^2.3.4" +gulp-multi-process@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/gulp-multi-process/-/gulp-multi-process-1.3.1.tgz#e12aa818e4c234357ad99d5caff8df8a18f46e9e" + dependencies: + async.queue "^0.5.2" + +gulp-pegjs@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/gulp-pegjs/-/gulp-pegjs-0.1.0.tgz#830158eeae8e730171d44dcdeb1ca20c7f3714ea" + dependencies: + gulp-util "^3.0.6" + object-assign "^4.0.1" + pegjs "^0.10.0" + through2 "^2.0.1" + gulp-rename@1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/gulp-rename/-/gulp-rename-1.2.2.tgz#3ad4428763f05e2764dec1c67d868db275687817" -gulp-util@^3.0.0: +gulp-util@^3.0.0, gulp-util@^3.0.6: version "3.0.8" resolved "https://registry.yarnpkg.com/gulp-util/-/gulp-util-3.0.8.tgz#0054e1e744502e27c04c187c3ecc505dd54bbb4f" dependencies: @@ -3163,6 +3519,16 @@ gulplog@^1.0.0: dependencies: glogg "^1.0.0" +handlebars@^4.0.10: + version "4.0.12" + resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.0.12.tgz#2c15c8a96d46da5e266700518ba8cb8d919d5bc5" + dependencies: + async "^2.5.0" + optimist "^0.6.1" + source-map "^0.6.1" + optionalDependencies: + uglify-js "^3.1.4" + handlebars@^4.0.3: version "4.0.11" resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.0.11.tgz#630a35dfe0294bc281edae6ffc5d329fc7982dcc" @@ -3236,12 +3602,29 @@ har-validator@~5.0.3: ajv "^5.1.0" har-schema "^2.0.0" +har-validator@~5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.1.0.tgz#44657f5688a22cfd4b72486e81b3a3fb11742c29" + dependencies: + ajv "^5.3.0" + har-schema "^2.0.0" + has-ansi@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91" dependencies: ansi-regex "^2.0.0" +has-binary@0.1.7: + version "0.1.7" + resolved "https://registry.yarnpkg.com/has-binary/-/has-binary-0.1.7.tgz#68e61eb16210c9545a0a5cce06a873912fe1e68c" + dependencies: + isarray "0.0.1" + +has-cors@1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/has-cors/-/has-cors-1.1.0.tgz#5e474793f7ea9843d1bb99c23eef49ff126fff39" + has-flag@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-1.0.0.tgz#9d9e793165ce017a00f00418c43f942a7b1d11fa" @@ -3377,7 +3760,7 @@ hoist-non-react-statics@^2.3.0: version "2.3.1" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.3.1.tgz#343db84c6018c650778898240135a1420ee22ce0" -hoist-non-react-statics@^2.5.0: +hoist-non-react-statics@^2.3.1, hoist-non-react-statics@^2.5.0: version "2.5.5" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz#c5903cf409c0dfd908f388e619d86b9c1174cb47" @@ -3466,6 +3849,12 @@ iconv-lite@0.4.19, iconv-lite@^0.4.19, iconv-lite@~0.4.13: version "0.4.19" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.19.tgz#f7468f60135f5e5dad3399c0a81be9a1603a082b" +iconv-lite@^0.4.13: + version "0.4.24" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" + dependencies: + safer-buffer ">= 2.1.2 < 3" + ieee754@^1.1.4: version "1.1.8" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.8.tgz#be33d40ac10ef1926701f6f08a2d86fbfd1ad3e4" @@ -3497,6 +3886,10 @@ indent-string@^2.1.0: dependencies: repeating "^2.0.0" +indexof@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/indexof/-/indexof-0.0.1.tgz#82dc336d232b9062179d05ab3293a66059fd435d" + inflight@^1.0.4: version "1.0.6" resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" @@ -3516,6 +3909,12 @@ ini@^1.3.4, ini@~1.3.0: version "1.3.5" resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927" +inline-style@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/inline-style/-/inline-style-2.0.0.tgz#2fa9cf624596a8109355b925094e138bbd5ea29b" + dependencies: + dashify "^0.1.0" + inquirer@^1.2.2: version "1.2.3" resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-1.2.3.tgz#4dec6f32f37ef7bb0b2ed3f1d1a5c3f545074918" @@ -3572,7 +3971,7 @@ invariant@^2.0.0, invariant@^2.2.0, invariant@^2.2.1, invariant@^2.2.2: dependencies: loose-envify "^1.0.0" -invariant@^2.1.1, invariant@^2.2.4: +invariant@^2.1.0, invariant@^2.1.1, invariant@^2.2.4: version "2.2.4" resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" dependencies: @@ -4343,10 +4742,42 @@ js-yaml@^3.7.0: argparse "^1.0.7" esprima "^4.0.0" +js-yaml@~3.7.0: + version "3.7.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.7.0.tgz#5c967ddd837a9bfdca5f2de84253abe8a1c03b80" + dependencies: + argparse "^1.0.7" + esprima "^2.6.0" + jsbn@~0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" +jsdom@9.9.1: + version "9.9.1" + resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-9.9.1.tgz#84f3972ad394ab963233af8725211bce4d01bfd5" + dependencies: + abab "^1.0.0" + acorn "^2.4.0" + acorn-globals "^1.0.4" + array-equal "^1.0.0" + content-type-parser "^1.0.1" + cssom ">= 0.3.0 < 0.4.0" + cssstyle ">= 0.2.36 < 0.3.0" + escodegen "^1.6.1" + html-encoding-sniffer "^1.0.1" + iconv-lite "^0.4.13" + nwmatcher ">= 1.3.9 < 2.0.0" + parse5 "^1.5.1" + request "^2.55.0" + sax "^1.1.4" + symbol-tree ">= 3.1.0 < 4.0.0" + tough-cookie "^2.3.1" + webidl-conversions "^3.0.1" + whatwg-encoding "^1.0.1" + whatwg-url "^4.1.0" + xml-name-validator ">= 2.0.1 < 3.0.0" + jsdom@^11.5.1: version "11.5.1" resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-11.5.1.tgz#5df753b8d0bca20142ce21f4f6c039f99a992929" @@ -4449,6 +4880,10 @@ just-extend@^1.1.27: version "1.1.27" resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-1.1.27.tgz#ec6e79410ff914e472652abfa0e603c03d60e905" +just-reduce-object@^1.0.3: + version "1.1.0" + resolved "https://registry.yarnpkg.com/just-reduce-object/-/just-reduce-object-1.1.0.tgz#d29d172264f8511c74462de30d72d5838b6967e6" + keymirror@^0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/keymirror/-/keymirror-0.1.1.tgz#918889ea13f8d0a42e7c557250eee713adc95c35" @@ -4557,6 +4992,12 @@ linebreak@^0.3.0: brfs "^1.3.0" unicode-trie "^0.3.0" +linkify-it@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-2.0.3.tgz#d94a4648f9b1c179d64fa97291268bdb6ce9434f" + dependencies: + uc.micro "^1.0.1" + load-json-file@^1.0.0, load-json-file@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-1.1.0.tgz#956905708d58b4bab4c2261b04f59f31c99374c0" @@ -4567,7 +5008,7 @@ load-json-file@^1.0.0, load-json-file@^1.1.0: pinkie-promise "^2.0.0" strip-bom "^2.0.0" -loader-utils@^1.0.0: +loader-utils@^1.0.0, loader-utils@^1.0.1: version "1.1.0" resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.1.0.tgz#c98aef488bcceda2ffb5e2de646d6a754429f5cd" dependencies: @@ -4676,6 +5117,10 @@ lodash.assign@^4.0.3, lodash.assign@^4.0.6, lodash.assign@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/lodash.assign/-/lodash.assign-4.2.0.tgz#0d99f3ccd7a6d261d19bdaeb9245005d285808e7" +lodash.clone@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.clone/-/lodash.clone-4.5.0.tgz#195870450f5a13192478df4bc3d23d2dea1907b6" + lodash.clonedeep@^4.3.2: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" @@ -4732,10 +5177,18 @@ lodash.isobject@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/lodash.isobject/-/lodash.isobject-3.0.2.tgz#3c8fb8d5b5bf4bf90ae06e14f2a530a4ed935e1d" +lodash.isplainobject@^4.0.6: + version "4.0.6" + resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" + lodash.istypedarray@^3.0.0: version "3.0.6" resolved "https://registry.yarnpkg.com/lodash.istypedarray/-/lodash.istypedarray-3.0.6.tgz#c9a477498607501d8e8494d283b87c39281cef62" +lodash.keyby@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/lodash.keyby/-/lodash.keyby-4.6.0.tgz#7f6a1abda93fd24e22728a4d361ed8bcba5a4354" + lodash.keys@^3.0.0: version "3.1.2" resolved "https://registry.yarnpkg.com/lodash.keys/-/lodash.keys-3.1.2.tgz#4dbc0472b156be50a0b286855d1bd0b0c656098a" @@ -4744,6 +5197,10 @@ lodash.keys@^3.0.0: lodash.isarguments "^3.0.0" lodash.isarray "^3.0.0" +lodash.lowercase@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.lowercase/-/lodash.lowercase-4.3.0.tgz#46515aced4acb0b7093133333af068e4c3b14e9d" + lodash.mean@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/lodash.mean/-/lodash.mean-4.1.0.tgz#bb985349628c0b9d7fe0f5fcc0011a2ee2c0dd7a" @@ -4752,6 +5209,10 @@ lodash.mergewith@^4.6.0: version "4.6.1" resolved "https://registry.yarnpkg.com/lodash.mergewith/-/lodash.mergewith-4.6.1.tgz#639057e726c3afbdb3e7d42741caa8d6e4335927" +lodash.omitby@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/lodash.omitby/-/lodash.omitby-4.6.0.tgz#5c15ff4754ad555016b53c041311e8f079204791" + lodash.orderby@4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/lodash.orderby/-/lodash.orderby-4.6.0.tgz#e697f04ce5d78522f54d9338b32b81a3393e4eb3" @@ -4762,6 +5223,10 @@ lodash.pairs@^3.0.0: dependencies: lodash.keys "^3.0.0" +lodash.pickby@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/lodash.pickby/-/lodash.pickby-4.6.0.tgz#7dea21d8c18d7703a27c704c15d3b84a67e33aff" + lodash.restparam@^3.0.0: version "3.6.1" resolved "https://registry.yarnpkg.com/lodash.restparam/-/lodash.restparam-3.6.1.tgz#936a4e309ef330a7645ed4145986c85ae5b20805" @@ -4782,6 +5247,10 @@ lodash.sortby@^4.7.0: version "4.7.0" resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" +lodash.tail@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lodash.tail/-/lodash.tail-4.1.1.tgz#d2333a36d9e7717c8ad2f7cacafec7c32b444664" + lodash.template@^3.0.0: version "3.6.2" resolved "https://registry.yarnpkg.com/lodash.template/-/lodash.template-3.6.2.tgz#f8cdecc6169a255be9098ae8b0c53d378931d14f" @@ -4809,10 +5278,18 @@ lodash.throttle@^3.0.2: dependencies: lodash.debounce "^3.0.0" +lodash.topath@^4.5.2: + version "4.5.2" + resolved "https://registry.yarnpkg.com/lodash.topath/-/lodash.topath-4.5.2.tgz#3616351f3bba61994a0931989660bd03254fd009" + lodash.trimend@^4.5.1: version "4.5.1" resolved "https://registry.yarnpkg.com/lodash.trimend/-/lodash.trimend-4.5.1.tgz#12804437286b98cad8996b79414e11300114082f" +lodash.uniqby@^4.7.0: + version "4.7.0" + resolved "https://registry.yarnpkg.com/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz#d99c07a669e9e6d24e1362dfe266c67616af1302" + lodash.uniqueid@^3.0.0: version "3.2.0" resolved "https://registry.yarnpkg.com/lodash.uniqueid/-/lodash.uniqueid-3.2.0.tgz#59416f134103ce253d4b4aa818272be3fbbcbbdb" @@ -4835,6 +5312,10 @@ lodash@^4.0.1, lodash@^4.13.1, lodash@^4.14.0, lodash@^4.15.0, lodash@^4.17.4: version "4.17.4" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.4.tgz#78203a4d1c328ae1d86dca6460e369b57f4055ae" +lodash@^4.17.10: + version "4.17.11" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d" + lodash@^4.3.0: version "4.17.5" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.5.tgz#99a92d65c0272debe8c96b6057bc8fbfa3bed511" @@ -4899,6 +5380,10 @@ lru-cache@^4.0.1: pseudomap "^1.0.2" yallist "^2.1.2" +lz-string@^1.4.4: + version "1.4.4" + resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.4.4.tgz#c0d8eaf36059f705796e1e344811cf4c498d3a26" + make-iterator@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/make-iterator/-/make-iterator-1.0.0.tgz#57bef5dc85d23923ba23767324d8e8f8f3d9694b" @@ -4925,16 +5410,34 @@ map-visit@^1.0.0: dependencies: object-visit "^1.0.0" +markdown-it@^8.4.1: + version "8.4.2" + resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-8.4.2.tgz#386f98998dc15a37722aa7722084f4020bdd9b54" + dependencies: + argparse "^1.0.7" + entities "~1.1.1" + linkify-it "^2.0.0" + mdurl "^1.0.1" + uc.micro "^1.0.5" + material-colors@^1.2.1: version "1.2.5" resolved "https://registry.yarnpkg.com/material-colors/-/material-colors-1.2.5.tgz#5292593e6754cb1bcc2b98030e4e0d6a3afc9ea1" +mdurl@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-1.0.1.tgz#fe85b2ec75a59037f2adfec100fd6c601761152e" + mem@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/mem/-/mem-1.1.0.tgz#5edd52b485ca1d900fe64895505399a0dfa45f76" dependencies: mimic-fn "^1.0.0" +memoize-one@^4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-4.0.2.tgz#3fb8db695aa14ab9c0f1644e1585a8806adc1aee" + meow@^3.7.0: version "3.7.0" resolved "https://registry.yarnpkg.com/meow/-/meow-3.7.0.tgz#72cb668b425228290abbfa856892587308a801fb" @@ -5012,17 +5515,27 @@ mime-db@~1.30.0: version "1.30.0" resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.30.0.tgz#74c643da2dd9d6a45399963465b26d5ca7d71f01" +mime-db@~1.36.0: + version "1.36.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.36.0.tgz#5020478db3c7fe93aad7bbcc4dcf869c43363397" + mime-types@^2.1.12, mime-types@~2.1.17, mime-types@~2.1.7: version "2.1.17" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.17.tgz#09d7a393f03e995a79f8af857b70a9e0ab16557a" dependencies: mime-db "~1.30.0" +mime-types@~2.1.11, mime-types@~2.1.19: + version "2.1.20" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.20.tgz#930cb719d571e903738520f8470911548ca2cc19" + dependencies: + mime-db "~1.36.0" + mime@^1.4.1: version "1.6.0" resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" -mime@^2.0.3: +mime@^2.0.3, mime@^2.2.2: version "2.3.1" resolved "https://registry.yarnpkg.com/mime/-/mime-2.3.1.tgz#b1621c54d63b97c47d3cfe7f7215f7d64517c369" @@ -5092,6 +5605,13 @@ mixin-deep@^1.2.0: for-in "^1.0.2" is-extendable "^1.0.1" +mixin-object@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/mixin-object/-/mixin-object-2.0.1.tgz#4fb949441dab182540f1fe035ba60e1947a5e57e" + dependencies: + for-in "^0.1.3" + is-extendable "^0.1.1" + mkdirp@0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.3.0.tgz#1bbf5ab1ba827af23575143490426455f481fe1e" @@ -5102,7 +5622,7 @@ mkdirp@0.5.0: dependencies: minimist "0.0.8" -mkdirp@0.5.1, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1: +mkdirp@0.5.1, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.1: version "0.5.1" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" dependencies: @@ -5239,6 +5759,14 @@ nearley@^2.7.10: railroad-diagrams "^1.0.0" randexp "^0.4.2" +negotiator@0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.1.tgz#2b327184e8992101177b28563fb5e7102acd0ca9" + +neo-async@^2.5.0: + version "2.5.2" + resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.5.2.tgz#489105ce7bc54e709d736b195f82135048c50fcc" + ngreact@^0.5.1: version "0.5.1" resolved "https://registry.yarnpkg.com/ngreact/-/ngreact-0.5.1.tgz#2dcccc1541771796689d13e51bb8d5010af41c57" @@ -5431,6 +5959,10 @@ numeral@^2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/numeral/-/numeral-2.0.6.tgz#4ad080936d443c2561aed9f2197efffe25f4e506" +"nwmatcher@>= 1.3.9 < 2.0.0": + version "1.4.4" + resolved "https://registry.yarnpkg.com/nwmatcher/-/nwmatcher-1.4.4.tgz#2285631f34a95f0d0395cd900c96ed39b58f346e" + nwmatcher@^1.4.3: version "1.4.3" resolved "https://registry.yarnpkg.com/nwmatcher/-/nwmatcher-1.4.3.tgz#64348e3b3d80f035b40ac11563d278f8b72db89c" @@ -5439,6 +5971,14 @@ oauth-sign@~0.8.1, oauth-sign@~0.8.2: version "0.8.2" resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.8.2.tgz#46a6ab7f0aead8deae9ec0565780b7d4efeb9d43" +oauth-sign@~0.9.0: + version "0.9.0" + resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455" + +object-assign@4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.0.tgz#7a3b3d0e98063d43f4c03f2e8ae6cd51a86883a0" + object-assign@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-3.0.0.tgz#9bedd5ca0897949bca47e7ff408062d549f587f2" @@ -5447,6 +5987,10 @@ object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" +object-component@0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/object-component/-/object-component-0.0.3.tgz#f0c69aa50efc95b866c186f400a33769cb2f1291" + object-copy@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/object-copy/-/object-copy-0.1.0.tgz#7e7d858b781bd7c991a41ba975ed3812754e998c" @@ -5471,6 +6015,10 @@ object-keys@~0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-0.4.0.tgz#28a6aae7428dd2c3a92f3d95f21335dd204e0336" +object-path-immutable@^0.5.3: + version "0.5.3" + resolved "https://registry.yarnpkg.com/object-path-immutable/-/object-path-immutable-0.5.3.tgz#57a874bdfa98147907ea1b9b0c570940a0f45ae0" + object-visit@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb" @@ -5580,6 +6128,10 @@ optionator@^0.8.1: type-check "~0.3.2" wordwrap "~1.0.0" +options@>=0.0.5: + version "0.0.6" + resolved "https://registry.yarnpkg.com/options/-/options-0.0.6.tgz#ec22d312806bb53e731773e7cdaefcf1c643128f" + orchestrator@^0.3.0: version "0.3.8" resolved "https://registry.yarnpkg.com/orchestrator/-/orchestrator-0.3.8.tgz#14e7e9e2764f7315fbac184e506c7aa6df94ad7e" @@ -5698,6 +6250,10 @@ pako@^0.2.5: version "0.2.9" resolved "https://registry.yarnpkg.com/pako/-/pako-0.2.9.tgz#f3f7522f4ef782348da8161bad9ecfd51bf83a75" +papaparse@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/papaparse/-/papaparse-4.6.0.tgz#4e3b8d6bf9f7900da437912794ec292207526867" + parse-filepath@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/parse-filepath/-/parse-filepath-1.0.2.tgz#a632127f53aaf3d15876f5872f3ffac763d6c891" @@ -5725,12 +6281,34 @@ parse-passwd@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/parse-passwd/-/parse-passwd-1.0.0.tgz#6d5b934a456993b23d37f40a382d6f1666a8e5c6" +parse5@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-1.5.1.tgz#9b7f3b0de32be78dc2401b17573ccaf0f6f59d94" + parse5@^3.0.1, parse5@^3.0.2: version "3.0.3" resolved "https://registry.yarnpkg.com/parse5/-/parse5-3.0.3.tgz#042f792ffdd36851551cf4e9e066b3874ab45b5c" dependencies: "@types/node" "*" +parsejson@0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/parsejson/-/parsejson-0.0.3.tgz#ab7e3759f209ece99437973f7d0f1f64ae0e64ab" + dependencies: + better-assert "~1.0.0" + +parseqs@0.0.5: + version "0.0.5" + resolved "https://registry.yarnpkg.com/parseqs/-/parseqs-0.0.5.tgz#d5208a3738e46766e291ba2ea173684921a8b89d" + dependencies: + better-assert "~1.0.0" + +parseuri@0.0.5: + version "0.0.5" + resolved "https://registry.yarnpkg.com/parseuri/-/parseuri-0.0.5.tgz#80204a50d4dbb779bfdc6ebe2778d90e4bce320a" + dependencies: + better-assert "~1.0.0" + pascalcase@^0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14" @@ -5823,6 +6401,10 @@ peekaboo@2.x.x: version "2.0.2" resolved "https://registry.yarnpkg.com/peekaboo/-/peekaboo-2.0.2.tgz#fc42e139efd698c6ff2870a6b20c047cd9aa29ff" +pegjs@^0.10.0: + version "0.10.0" + resolved "https://registry.yarnpkg.com/pegjs/-/pegjs-0.10.0.tgz#cf8bafae6eddff4b5a7efb185269eaaf4610ddbd" + pend@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50" @@ -5905,6 +6487,10 @@ pkg-dir@^2.0.0: dependencies: find-up "^2.1.0" +platform@^1.3.0: + version "1.3.5" + resolved "https://registry.yarnpkg.com/platform/-/platform-1.3.5.tgz#fb6958c696e07e2918d2eeda0f0bc9448d733444" + plugin-error@^0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/plugin-error/-/plugin-error-0.1.2.tgz#3b9bb3335ccf00f425e07437e19276967da47ace" @@ -6019,6 +6605,14 @@ prop-types@15.5.8: dependencies: fbjs "^0.8.9" +prop-types@15.6.1, prop-types@^15.6.1: + version "15.6.1" + resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.1.tgz#36644453564255ddda391191fb3a125cbdf654ca" + dependencies: + fbjs "^0.8.16" + loose-envify "^1.3.1" + object-assign "^4.1.1" + prop-types@^15.5.0, prop-types@^15.5.10, prop-types@^15.5.4, prop-types@^15.5.8, prop-types@^15.6.0: version "15.6.0" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.0.tgz#ceaf083022fc46b4a35f69e13ef75aed0d639856" @@ -6027,11 +6621,10 @@ prop-types@^15.5.0, prop-types@^15.5.10, prop-types@^15.5.4, prop-types@^15.5.8, loose-envify "^1.3.1" object-assign "^4.1.1" -prop-types@^15.6.1: - version "15.6.1" - resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.1.tgz#36644453564255ddda391191fb3a125cbdf654ca" +prop-types@^15.5.7: + version "15.6.2" + resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.2.tgz#05d5ca77b4453e985d60fc7ff8c859094a497102" dependencies: - fbjs "^0.8.16" loose-envify "^1.3.1" object-assign "^4.1.1" @@ -6051,6 +6644,10 @@ pseudomap@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" +psl@^1.1.24: + version "1.1.29" + resolved "https://registry.yarnpkg.com/psl/-/psl-1.1.29.tgz#60f580d360170bb722a797cc704411e6da850c67" + pui-cursor@^3.0.4: version "3.0.5" resolved "https://registry.yarnpkg.com/pui-cursor/-/pui-cursor-3.0.5.tgz#e80805f27edfc4e7b8c54d2755180cd087729bb5" @@ -6118,6 +6715,10 @@ puppeteer-core@^1.7.0: rimraf "^2.6.1" ws "^5.1.1" +q@^1.1.2: + version "1.5.1" + resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7" + qs@^6.5.1, qs@~6.5.1: version "6.5.1" resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.1.tgz#349cdf6eef89ec45c12d7d5eb3fc0c870343a6d8" @@ -6130,6 +6731,10 @@ qs@~6.4.0: version "6.4.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.4.0.tgz#13e26d28ad6b0ffaa91312cd3bf708ed351e7233" +qs@~6.5.2: + version "6.5.2" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" + query-string@^5.0.1: version "5.1.1" resolved "https://registry.yarnpkg.com/query-string/-/query-string-5.1.1.tgz#a78c012b71c17e05f2e3fa2319dd330682efb3cb" @@ -6153,6 +6758,10 @@ quote-stream@~0.0.0: minimist "0.0.8" through2 "~0.4.1" +raf-schd@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/raf-schd/-/raf-schd-4.0.0.tgz#9855756c5045ff4ed4516e14a47719387c3c907b" + raf@^3.0.0, raf@^3.1.0, raf@^3.3.0, raf@^3.4.0: version "3.4.0" resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.0.tgz#a28876881b4bc2ca9117d4138163ddb80f781575" @@ -6177,6 +6786,10 @@ randomatic@^1.1.3: is-number "^3.0.0" kind-of "^4.0.0" +raw-loader@0.5.1: + version "0.5.1" + resolved "https://registry.yarnpkg.com/raw-loader/-/raw-loader-0.5.1.tgz#0c3d0beaed8a01c966d9787bf778281252a979aa" + rc@^1.1.7: version "1.2.3" resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.3.tgz#51575a900f8dd68381c710b4712c2154c3e2035b" @@ -6211,6 +6824,20 @@ react-addons-shallow-compare@^15.0.1: fbjs "^0.8.4" object-assign "^4.1.0" +react-beautiful-dnd@^8.0.7: + version "8.0.7" + resolved "https://registry.yarnpkg.com/react-beautiful-dnd/-/react-beautiful-dnd-8.0.7.tgz#2cc7ba62bffe08d3dad862fd8f48204440901b43" + dependencies: + "@babel/runtime" "7.0.0-beta.54" + css-box-model "^1.0.0" + memoize-one "^4.0.0" + prop-types "15.6.1" + raf-schd "^4.0.0" + react-motion "^0.5.2" + react-redux "^5.0.7" + redux "^4.0.0" + tiny-invariant "^0.0.3" + react-clipboard.js@^1.1.2: version "1.1.3" resolved "https://registry.yarnpkg.com/react-clipboard.js/-/react-clipboard.js-1.1.3.tgz#86feeb49364553ecd15aea91c75aa142532a60e0" @@ -6237,6 +6864,15 @@ react-datepicker@v1.5.0: react-onclickoutside "^6.7.1" react-popper "^0.9.1" +react-datetime@^2.14.0: + version "2.15.0" + resolved "https://registry.yarnpkg.com/react-datetime/-/react-datetime-2.15.0.tgz#a8f7da6c58b6b45dbeea32d4e8485db17614e12c" + dependencies: + create-react-class "^15.5.2" + object-assign "^3.0.0" + prop-types "^15.5.7" + react-onclickoutside "^6.5.0" + react-dom@^16.0.0: version "16.2.0" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.2.0.tgz#69003178601c0ca19b709b33a83369fe6124c044" @@ -6255,6 +6891,13 @@ react-dom@^16.3.0: object-assign "^4.1.1" prop-types "^15.6.0" +react-dropzone@^4.2.9: + version "4.3.0" + resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-4.3.0.tgz#facdd7db16509772633c9f5200621ac01aa6706f" + dependencies: + attr-accept "^1.1.3" + prop-types "^15.5.7" + react-input-autosize@^2.1.2, react-input-autosize@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/react-input-autosize/-/react-input-autosize-2.2.1.tgz#ec428fa15b1592994fb5f9aa15bb1eb6baf420f8" @@ -6274,6 +6917,10 @@ react-is@^16.3.1: version "16.4.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.4.1.tgz#d624c4650d2c65dbd52c72622bbf389435d9776e" +react-lib-adler32@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/react-lib-adler32/-/react-lib-adler32-1.0.1.tgz#01f7a0e24fe715580aadb8a827c39a850e1ccc4e" + react-lifecycles-compat@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" @@ -6302,7 +6949,7 @@ react-motion@^0.5.2: prop-types "^15.5.8" raf "^3.1.0" -react-onclickoutside@^6.7.1: +react-onclickoutside@^6.5.0, react-onclickoutside@^6.7.1: version "6.7.1" resolved "https://registry.yarnpkg.com/react-onclickoutside/-/react-onclickoutside-6.7.1.tgz#6a5b5b8b4eae6b776259712c89c8a2b36b17be93" @@ -6382,6 +7029,17 @@ react-select@^1.2.1: prop-types "^15.5.8" react-input-autosize "^2.1.2" +react-shortcuts@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/react-shortcuts/-/react-shortcuts-2.0.0.tgz#871b033a071a8537422b1529d691c38432823bae" + dependencies: + combokeys "^3.0.0" + events "^1.0.2" + invariant "^2.1.0" + just-reduce-object "^1.0.3" + platform "^1.3.0" + prop-types "^15.5.8" + react-sticky@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/react-sticky/-/react-sticky-6.0.1.tgz#356988bdcc6fc8cd2d89746d2302edce67d86687" @@ -6563,6 +7221,15 @@ rechoir@^0.6.2: dependencies: resolve "^1.1.6" +recompose@^0.26.0: + version "0.26.0" + resolved "https://registry.yarnpkg.com/recompose/-/recompose-0.26.0.tgz#9babff039cb72ba5bd17366d55d7232fbdfb2d30" + dependencies: + change-emitter "^0.1.2" + fbjs "^0.8.1" + hoist-non-react-statics "^2.3.1" + symbol-observable "^1.0.4" + redent@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/redent/-/redent-1.0.0.tgz#cf916ab1fd5f1f16dfb20822dd6ec7f730c2afde" @@ -6574,6 +7241,10 @@ reduce-reducers@^0.1.0: version "0.1.2" resolved "https://registry.yarnpkg.com/reduce-reducers/-/reduce-reducers-0.1.2.tgz#fa1b4718bc5292a71ddd1e5d839c9bea9770f14b" +reduce-reducers@^0.1.2: + version "0.1.5" + resolved "https://registry.yarnpkg.com/reduce-reducers/-/reduce-reducers-0.1.5.tgz#ff77ca8068ff41007319b8b4b91533c7e0e54576" + redux-actions@2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/redux-actions/-/redux-actions-2.2.1.tgz#d64186b25649a13c05478547d7cd7537b892410d" @@ -6591,7 +7262,11 @@ redux-thunk@2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.3.0.tgz#51c2c19a185ed5187aaa9a2d08b666d0d6467622" -redux@4.0.0: +redux-thunks@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/redux-thunks/-/redux-thunks-1.0.0.tgz#56e03b86d281a2664c884ab05c543d9ab1673658" + +redux@4.0.0, redux@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.0.tgz#aa698a92b729315d22b34a0553d7e6533555cc03" dependencies: @@ -6606,6 +7281,10 @@ regenerator-runtime@^0.11.0: version "0.11.1" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9" +regenerator-runtime@^0.12.0: + version "0.12.1" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.12.1.tgz#fa1a71544764c036f8c49b13a08b2594c9f8a0de" + regenerator-transform@^0.10.0: version "0.10.1" resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.10.1.tgz#1e4996837231da8b7f3cf4114d71b5691a0680dd" @@ -6733,6 +7412,31 @@ request@2.81.0, "request@>=2.9.0 <2.82.0": tunnel-agent "^0.6.0" uuid "^3.0.0" +request@^2.55.0: + version "2.88.0" + resolved "https://registry.yarnpkg.com/request/-/request-2.88.0.tgz#9c2fca4f7d35b592efe57c7f0a55e81052124fef" + dependencies: + aws-sign2 "~0.7.0" + aws4 "^1.8.0" + caseless "~0.12.0" + combined-stream "~1.0.6" + extend "~3.0.2" + forever-agent "~0.6.1" + form-data "~2.3.2" + har-validator "~5.1.0" + http-signature "~1.2.0" + is-typedarray "~1.0.0" + isstream "~0.1.2" + json-stringify-safe "~5.0.1" + mime-types "~2.1.19" + oauth-sign "~0.9.0" + performance-now "^2.1.0" + qs "~6.5.2" + safe-buffer "^5.1.2" + tough-cookie "~2.4.3" + tunnel-agent "^0.6.0" + uuid "^3.3.2" + request@^2.83.0: version "2.83.0" resolved "https://registry.yarnpkg.com/request/-/request-2.83.0.tgz#ca0b65da02ed62935887808e6f510381034e3356" @@ -6873,6 +7577,12 @@ resolve@^1.1.5, resolve@^1.1.6, resolve@^1.1.7: dependencies: path-parse "^1.0.5" +resolve@^1.8.1: + version "1.8.1" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.8.1.tgz#82f1ec19a423ac1fbd080b0bab06ba36e84a7a26" + dependencies: + path-parse "^1.0.5" + responselike@1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/responselike/-/responselike-1.0.2.tgz#918720ef3b631c5642be068f15ade5a46f4ba1e7" @@ -6959,6 +7669,14 @@ safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@~5.1.0, version "5.1.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.1.tgz#893312af69b2123def71f57889001671eeb2c853" +safe-buffer@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" + +"safer-buffer@>= 2.1.2 < 3": + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + samsam@1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/samsam/-/samsam-1.3.0.tgz#8d1d9350e25622da30de3e44ba692b5221ab7c50" @@ -6986,6 +7704,17 @@ sass-graph@^2.2.4: scss-tokenizer "^0.2.3" yargs "^7.0.0" +sass-loader@^7.1.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/sass-loader/-/sass-loader-7.1.0.tgz#16fd5138cb8b424bf8a759528a1972d72aad069d" + dependencies: + clone-deep "^2.0.1" + loader-utils "^1.0.1" + lodash.tail "^4.1.1" + neo-async "^2.5.0" + pify "^3.0.0" + semver "^5.5.0" + sax@0.5.3: version "0.5.3" resolved "https://registry.yarnpkg.com/sax/-/sax-0.5.3.tgz#3773714a0d9157caaa7302971efa5c6dcda552d6" @@ -6994,7 +7723,7 @@ sax@0.5.x: version "0.5.8" resolved "https://registry.yarnpkg.com/sax/-/sax-0.5.8.tgz#d472db228eb331c2506b0e8c15524adb939d12c1" -sax@>=0.6.0, sax@^1.2.1: +sax@>=0.6.0, sax@^1.1.4, sax@^1.2.1, sax@~1.2.1: version "1.2.4" resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" @@ -7005,6 +7734,10 @@ schema-utils@^0.4.0: ajv "^6.1.0" ajv-keywords "^3.1.0" +scriptjs@^2.5.8: + version "2.5.8" + resolved "https://registry.yarnpkg.com/scriptjs/-/scriptjs-2.5.8.tgz#d0c43955c2e6bad33b6e4edf7b53b8965aa7ca5f" + scroll-into-view@^1.3.0: version "1.9.1" resolved "https://registry.yarnpkg.com/scroll-into-view/-/scroll-into-view-1.9.1.tgz#90c3b338422f9fddaebad90e6954790940dc9c1e" @@ -7080,6 +7813,14 @@ setimmediate@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" +shallow-clone@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/shallow-clone/-/shallow-clone-1.0.0.tgz#4480cd06e882ef68b2ad88a3ea54832e2c48b571" + dependencies: + is-extendable "^0.1.1" + kind-of "^5.0.0" + mixin-object "^2.0.1" + shallow-copy@~0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/shallow-copy/-/shallow-copy-0.0.1.tgz#415f42702d73d810330292cc5ee86eae1a11a170" @@ -7182,6 +7923,50 @@ sntp@2.x.x: dependencies: hoek "4.x.x" +socket.io-adapter@0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-0.5.0.tgz#cb6d4bb8bec81e1078b99677f9ced0046066bb8b" + dependencies: + debug "2.3.3" + socket.io-parser "2.3.1" + +socket.io-client@1.7.4, socket.io-client@^1.7.3: + version "1.7.4" + resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-1.7.4.tgz#ec9f820356ed99ef6d357f0756d648717bdd4281" + dependencies: + backo2 "1.0.2" + component-bind "1.0.0" + component-emitter "1.2.1" + debug "2.3.3" + engine.io-client "~1.8.4" + has-binary "0.1.7" + indexof "0.0.1" + object-component "0.0.3" + parseuri "0.0.5" + socket.io-parser "2.3.1" + to-array "0.1.4" + +socket.io-parser@2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-2.3.1.tgz#dd532025103ce429697326befd64005fcfe5b4a0" + dependencies: + component-emitter "1.1.2" + debug "2.2.0" + isarray "0.0.1" + json3 "3.3.2" + +socket.io@^1.7.3: + version "1.7.4" + resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-1.7.4.tgz#2f7ecedc3391bf2d5c73e291fe233e6e34d4dd00" + dependencies: + debug "2.3.3" + engine.io "~1.8.4" + has-binary "0.1.7" + object-assign "4.1.0" + socket.io-adapter "0.5.0" + socket.io-client "1.7.4" + socket.io-parser "2.3.1" + sort-keys@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-2.0.0.tgz#658535584861ec97d730d6cf41822e1f56684128" @@ -7228,7 +8013,7 @@ source-map-url@~0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.3.0.tgz#7ecaf13b57bcd09da8a40c5d269db33799d4aaf9" -"source-map@>= 0.1.2", source-map@^0.6.0: +"source-map@>= 0.1.2", source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" @@ -7283,6 +8068,10 @@ sprintf-js@~1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" +squel@^5.12.2: + version "5.12.2" + resolved "https://registry.yarnpkg.com/squel/-/squel-5.12.2.tgz#8c7b54fd5462d95fe2432663c8762b65d29efe4c" + sshpk@^1.7.0: version "1.13.1" resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.13.1.tgz#512df6da6287144316dc4c18fe1cf1d940739be3" @@ -7359,6 +8148,10 @@ stream-shift@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.0.tgz#d5c752825e5367e786f78e18e445ea223a155952" +stream-stream@^1.2.6: + version "1.2.6" + resolved "https://registry.yarnpkg.com/stream-stream/-/stream-stream-1.2.6.tgz#a9ae071c64c11b8584f52973f7715e37e5144c43" + strict-uri-encode@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713" @@ -7442,6 +8235,12 @@ strip-json-comments@~2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" +style-it@^1.6.12: + version "1.6.13" + resolved "https://registry.yarnpkg.com/style-it/-/style-it-1.6.13.tgz#b57f01e3fd15a6c39b8386793f604471b1b9c90d" + dependencies: + react-lib-adler32 "^1.0.0" + styled-components@3.3.3: version "3.3.3" resolved "https://registry.yarnpkg.com/styled-components/-/styled-components-3.3.3.tgz#09e702055ab11f7a8eab8229b1c0d0b855095686" @@ -7542,11 +8341,23 @@ supports-color@^5.3.0: dependencies: has-flag "^3.0.0" -symbol-observable@^1.2.0: +svgo@^0.7.2: + version "0.7.2" + resolved "https://registry.yarnpkg.com/svgo/-/svgo-0.7.2.tgz#9f5772413952135c6fefbf40afe6a4faa88b4bb5" + dependencies: + coa "~1.0.1" + colors "~1.1.2" + csso "~2.3.1" + js-yaml "~3.7.0" + mkdirp "~0.5.1" + sax "~1.2.1" + whet.extend "~0.9.9" + +symbol-observable@^1.0.4, symbol-observable@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" -symbol-tree@^3.2.1: +"symbol-tree@>= 3.1.0 < 4.0.0", symbol-tree@^3.2.1: version "3.2.2" resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.2.tgz#ae27db38f660a7ae2e1c3b7d1bc290819b8519e6" @@ -7712,6 +8523,10 @@ tiny-inflate@^1.0.0, tiny-inflate@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/tiny-inflate/-/tiny-inflate-1.0.2.tgz#93d9decffc8805bd57eae4310f0b745e9b6fb3a7" +tiny-invariant@^0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-0.0.3.tgz#4c7283c950e290889e9e94f64d3586ec9156cf44" + tinycolor2@1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.3.0.tgz#3f38e6424de4566122d550eb1acc80cad37a7184" @@ -7720,6 +8535,10 @@ tinycolor2@^1.4.1: version "1.4.1" resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.4.1.tgz#f4fad333447bc0b07d4dc8e9209d8f39a8ac77e8" +tinymath@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/tinymath/-/tinymath-0.5.0.tgz#4c8b788a40b5929c4aff36ecc7c128004202496a" + tmp@0.0.31: version "0.0.31" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.31.tgz#8f38ab9438e17315e5dbd8b3657e8bfb277ae4a7" @@ -7749,6 +8568,10 @@ to-absolute-glob@^2.0.0: is-absolute "^1.0.0" is-negated-glob "^1.0.0" +to-array@0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/to-array/-/to-array-0.1.4.tgz#17e6c11f73dd4f3d74cda7a4ff3238e9ad9bf890" + to-fast-properties@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-1.0.3.tgz#b83571fa4d8c25b82e231b06e3a3055de4ca1a47" @@ -7784,6 +8607,10 @@ to-through@^2.0.0: dependencies: through2 "^2.0.3" +toggle-selection@^1.0.3: + version "1.0.6" + resolved "https://registry.yarnpkg.com/toggle-selection/-/toggle-selection-1.0.6.tgz#6e45b1263f2017fa0acc7d89d78b15b8bf77da32" + topo@1.x.x: version "1.1.0" resolved "https://registry.yarnpkg.com/topo/-/topo-1.1.0.tgz#e9d751615d1bb87dc865db182fa1ca0a5ef536d5" @@ -7802,12 +8629,23 @@ tough-cookie@>=2.3.3, tough-cookie@^2.3.3, tough-cookie@~2.3.0, tough-cookie@~2. dependencies: punycode "^1.4.1" +tough-cookie@^2.3.1, tough-cookie@~2.4.3: + version "2.4.3" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781" + dependencies: + psl "^1.1.24" + punycode "^1.4.1" + tr46@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/tr46/-/tr46-1.0.1.tgz#a8b13fd6bfd2489519674ccde55ba3693b706d09" dependencies: punycode "^2.1.0" +tr46@~0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" + tree-kill@^1.1.0, tree-kill@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.0.tgz#5846786237b4239014f05db156b643212d4c6f36" @@ -7868,10 +8706,18 @@ typescript@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.0.3.tgz#4853b3e275ecdaa27f78fda46dc273a7eb7fc1c8" +ua-parser-js@^0.7.18: + version "0.7.18" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.18.tgz#a7bfd92f56edfb117083b69e31d2aa8882d4b1ed" + ua-parser-js@^0.7.9: version "0.7.17" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.17.tgz#e9ec5f9498b9ec910e7ae3ac626a805c4d09ecac" +uc.micro@^1.0.1, uc.micro@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.5.tgz#0c65f15f815aa08b560a61ce8b4db7ffc3f45376" + uglify-js@^2.6: version "2.8.29" resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-2.8.29.tgz#29c5733148057bb4e1f75df35b7a9cb72e6a59dd" @@ -7881,6 +8727,13 @@ uglify-js@^2.6: optionalDependencies: uglify-to-browserify "~1.0.0" +uglify-js@^3.1.4: + version "3.4.9" + resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.4.9.tgz#af02f180c1207d76432e473ed24a28f4a782bae3" + dependencies: + commander "~2.17.1" + source-map "~0.6.1" + uglify-to-browserify@~1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz#6e0924d6bda6b5afe349e39a6d632850a0f882b7" @@ -7893,6 +8746,10 @@ uid-number@^0.0.6: version "0.0.6" resolved "https://registry.yarnpkg.com/uid-number/-/uid-number-0.0.6.tgz#0ea10e8035e8eb5b8e4449f06da1c730663baa81" +ultron@1.0.x: + version "1.0.2" + resolved "https://registry.yarnpkg.com/ultron/-/ultron-1.0.2.tgz#ace116ab557cd197386a4e88f4685378c8b2e4fa" + unbzip2-stream@1.0.9: version "1.0.9" resolved "https://registry.yarnpkg.com/unbzip2-stream/-/unbzip2-stream-1.0.9.tgz#9d107697a8d539d7bfdb9378a1cd832836bb7f8f" @@ -7977,6 +8834,10 @@ url-parse-lax@^3.0.0: dependencies: prepend-http "^2.0.0" +url-pattern@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/url-pattern/-/url-pattern-1.0.3.tgz#0409292471b24f23c50d65a47931793d2b5acfc1" + url-to-options@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/url-to-options/-/url-to-options-1.0.1.tgz#1505a03a289a48cbd7a434efbaeec5055f5633a9" @@ -8012,6 +8873,10 @@ uuid@^3.0.0, uuid@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.1.0.tgz#3dd3d3e790abc24d7b0d3a034ffababe28ebbc04" +uuid@^3.3.2: + version "3.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131" + v8flags@^2.0.2: version "2.1.1" resolved "https://registry.yarnpkg.com/v8flags/-/v8flags-2.1.1.tgz#aab1a1fa30d45f88dd321148875ac02c0b55e5b4" @@ -8171,6 +9036,10 @@ watch@~0.18.0: exec-sh "^0.2.0" minimist "^1.2.0" +webidl-conversions@^3.0.0, webidl-conversions@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" + webidl-conversions@^4.0.1, webidl-conversions@^4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad" @@ -8185,6 +9054,13 @@ whatwg-fetch@>=0.10.0: version "2.0.3" resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-2.0.3.tgz#9c84ec2dcf68187ff00bc64e1274b442176e1c84" +whatwg-url@^4.1.0: + version "4.8.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-4.8.0.tgz#d2981aa9148c1e00a41c5a6131166ab4683bbcc0" + dependencies: + tr46 "~0.0.3" + webidl-conversions "^3.0.0" + whatwg-url@^6.3.0: version "6.4.0" resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-6.4.0.tgz#08fdf2b9e872783a7a1f6216260a1d66cc722e08" @@ -8193,6 +9069,10 @@ whatwg-url@^6.3.0: tr46 "^1.0.0" webidl-conversions "^4.0.1" +whet.extend@~0.9.9: + version "0.9.9" + resolved "https://registry.yarnpkg.com/whet.extend/-/whet.extend-0.9.9.tgz#f877d5bf648c97e5aa542fadc16d6a259b9c11a1" + which-module@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/which-module/-/which-module-1.0.0.tgz#bba63ca861948994ff307736089e3b96026c2a4f" @@ -8285,6 +9165,17 @@ ws@^5.1.1: dependencies: async-limiter "~1.0.0" +ws@~1.1.5: + version "1.1.5" + resolved "https://registry.yarnpkg.com/ws/-/ws-1.1.5.tgz#cbd9e6e75e09fc5d2c90015f21f0c40875e0dd51" + dependencies: + options ">=0.0.5" + ultron "1.0.x" + +wtf-8@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/wtf-8/-/wtf-8-1.0.0.tgz#392d8ba2d0f1c34d1ee2d630f15d0efb68e1048a" + xml-crypto@^0.10.1: version "0.10.1" resolved "https://registry.yarnpkg.com/xml-crypto/-/xml-crypto-0.10.1.tgz#f832f74ccf56f24afcae1163a1fcab44d96774a8" @@ -8292,7 +9183,7 @@ xml-crypto@^0.10.1: xmldom "=0.1.19" xpath.js ">=0.0.3" -xml-name-validator@^2.0.1: +"xml-name-validator@>= 2.0.1 < 3.0.0", xml-name-validator@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-2.0.1.tgz#4d8b8f1eccd3419aa362061becef515e1e559635" @@ -8321,6 +9212,10 @@ xmldom@=0.1.19: version "0.1.19" resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.1.19.tgz#631fc07776efd84118bf25171b37ed4d075a0abc" +xmlhttprequest-ssl@1.5.3: + version "1.5.3" + resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.3.tgz#185a888c04eca46c3e4070d99f7b49de3528992d" + xpath.js@>=0.0.3: version "1.1.0" resolved "https://registry.yarnpkg.com/xpath.js/-/xpath.js-1.1.0.tgz#3816a44ed4bb352091083d002a383dd5104a5ff1" @@ -8447,6 +9342,10 @@ yazl@^2.1.0: dependencies: buffer-crc32 "~0.2.3" +yeast@0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/yeast/-/yeast-0.1.2.tgz#008e06d8094320c372dbc2f8ed76a0ca6c8ac419" + zlib@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/zlib/-/zlib-1.0.5.tgz#6e7c972fc371c645a6afb03ab14769def114fcc0" diff --git a/yarn.lock b/yarn.lock index 48161b19a0f22..e0b9b89f14dfd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -43,6 +43,13 @@ version "7.0.0-beta.52" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.0.0-beta.52.tgz#4e935b62cd9bf872bd37bcf1f63d82fe7b0237a2" +"@babel/runtime@7.0.0-beta.54": + version "7.0.0-beta.54" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.0.0-beta.54.tgz#39ebb42723fe7ca4b3e1b00e967e80138d47cadf" + dependencies: + core-js "^2.5.7" + regenerator-runtime "^0.12.0" + "@babel/template@7.0.0-beta.31": version "7.0.0-beta.31" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.0.0-beta.31.tgz#577bb29389f6c497c3e7d014617e7d6713f68bda" @@ -73,6 +80,12 @@ lodash "^4.2.0" to-fast-properties "^2.0.0" +"@elastic/datemath@^4.0.2": + version "4.0.2" + resolved "https://registry.yarnpkg.com/@elastic/datemath/-/datemath-4.0.2.tgz#91417763fa4ec93ad1426cb69aaf2de2e9914a68" + dependencies: + moment "^2.13.0" + "@elastic/eslint-config-kibana@link:packages/eslint-config-kibana": version "0.0.0" uid "" @@ -187,6 +200,12 @@ dependencies: any-observable "^0.3.0" +"@scant/router@^0.1.0": + version "0.1.0" + resolved "https://registry.yarnpkg.com/@scant/router/-/router-0.1.0.tgz#54e7e32282ee05d40ea410a4987ae6444080f989" + dependencies: + url-pattern "^1.0.3" + "@sindresorhus/is@^0.7.0": version "0.7.0" resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.7.0.tgz#9a06f4f137ee84d7df0460c1fdb1135ffa6c50fd" @@ -1084,6 +1103,12 @@ async@^2.1.2, async@^2.1.4, async@^2.3.0, async@^2.4.1: dependencies: lodash "^4.14.0" +async@^2.5.0: + version "2.6.1" + resolved "https://registry.yarnpkg.com/async/-/async-2.6.1.tgz#b245a23ca71930044ec53fa46aa00a3e87c6a610" + dependencies: + lodash "^4.17.10" + async@~0.2.9: version "0.2.10" resolved "https://registry.yarnpkg.com/async/-/async-0.2.10.tgz#b6bbe0b0674b9d719708ca38de8c237cb526c3d1" @@ -1104,6 +1129,12 @@ atob@~1.1.0: version "1.1.3" resolved "https://registry.yarnpkg.com/atob/-/atob-1.1.3.tgz#95f13629b12c3a51a5d215abdce2aa9f32f80773" +attr-accept@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/attr-accept/-/attr-accept-1.1.3.tgz#48230c79f93790ef2775fcec4f0db0f5db41ca52" + dependencies: + core-js "^2.5.0" + autobind-decorator@^1.3.4: version "1.4.3" resolved "https://registry.yarnpkg.com/autobind-decorator/-/autobind-decorator-1.4.3.tgz#4c96ffa77b10622ede24f110f5dbbf56691417d1" @@ -1903,6 +1934,10 @@ base64-js@^1.0.2, base64-js@^1.1.2: version "1.2.3" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.2.3.tgz#fb13668233d9614cf5fb4bce95a9ba4096cdf801" +base64-js@^1.2.1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.0.tgz#cab1e6118f051095e58b5281aea8c1cd22bfc0e3" + base64id@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/base64id/-/base64id-1.0.0.tgz#47688cb99bb6804f0e06d3e763b1c32e57d8e6b6" @@ -2590,6 +2625,10 @@ chance@1.0.10: version "1.0.10" resolved "https://registry.yarnpkg.com/chance/-/chance-1.0.10.tgz#03500b04ad94e778dd2891b09ec73a6ad87b1996" +change-emitter@^0.1.2: + version "0.1.6" + resolved "https://registry.yarnpkg.com/change-emitter/-/change-emitter-0.1.6.tgz#e8b2fe3d7f1ab7d69a32199aff91ea6931409515" + character-entities-legacy@^1.0.0: version "1.1.1" resolved "https://registry.yarnpkg.com/character-entities-legacy/-/character-entities-legacy-1.1.1.tgz#f40779df1a101872bb510a3d295e1fccf147202f" @@ -2700,6 +2739,10 @@ chownr@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.0.1.tgz#e2a75042a9551908bebd25b8523d5f9769d79181" +chroma-js@^1.3.6: + version "1.3.7" + resolved "https://registry.yarnpkg.com/chroma-js/-/chroma-js-1.3.7.tgz#38db1b46c99b002b77aa5e6b6744589388f28425" + chromedriver@2.41.0: version "2.41.0" resolved "https://registry.yarnpkg.com/chromedriver/-/chromedriver-2.41.0.tgz#2709d3544bc0c288b4738a6925a64c02a98a921f" @@ -2970,6 +3013,10 @@ combined-stream@1.0.6, combined-stream@^1.0.5, combined-stream@~1.0.5: dependencies: delayed-stream "~1.0.0" +combokeys@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/combokeys/-/combokeys-3.0.0.tgz#955c59a3959af40d26846ab6fc3c682448e7572e" + commander@2, commander@^2.12.1, commander@^2.9.0: version "2.15.1" resolved "https://registry.yarnpkg.com/commander/-/commander-2.15.1.tgz#df46e867d0fc2aec66a34662b406a9ccafff5b0f" @@ -2990,6 +3037,10 @@ commander@~2.13.0: version "2.13.0" resolved "https://registry.yarnpkg.com/commander/-/commander-2.13.0.tgz#6964bca67685df7c1f1430c584f07d7597885b9c" +commander@~2.17.1: + version "2.17.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-2.17.1.tgz#bd77ab7de6de94205ceacc72f1716d29f20a77bf" + commondir@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" @@ -3182,6 +3233,12 @@ copy-descriptor@^0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" +copy-to-clipboard@^3.0.8: + version "3.0.8" + resolved "https://registry.yarnpkg.com/copy-to-clipboard/-/copy-to-clipboard-3.0.8.tgz#f4e82f4a8830dce4666b7eb8ded0c9bcc313aba9" + dependencies: + toggle-selection "^1.0.3" + core-js@^1.0.0: version "1.2.7" resolved "https://registry.yarnpkg.com/core-js/-/core-js-1.2.7.tgz#652294c14651db28fa93bd2d5ff2983a4f08c636" @@ -3190,6 +3247,10 @@ core-js@^2.2.0, core-js@^2.4.0, core-js@^2.5.0, core-js@^2.5.1: version "2.5.3" resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.5.3.tgz#8acc38345824f16d8365b7c9b4259168e8ed603e" +core-js@^2.5.7: + version "2.5.7" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.5.7.tgz#f972608ff0cead68b841a16a932d0b183791814e" + core-util-is@1.0.2, core-util-is@~1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" @@ -3346,6 +3407,10 @@ cson@~3.0.2: requirefresh "^2.0.0" safefs "^4.0.0" +css-box-model@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/css-box-model/-/css-box-model-1.0.0.tgz#60142814f2b25be00c4aac65ea1a55a531b18922" + css-color-keywords@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/css-color-keywords/-/css-color-keywords-1.0.0.tgz#fea2616dc676b2962686b3af8dbdbe180b244e05" @@ -3671,6 +3736,10 @@ dashdash@^1.12.0: dependencies: assert-plus "^1.0.0" +dashify@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/dashify/-/dashify-0.1.0.tgz#107daf9cca5e326e30a8b39ffa5048b6684922ea" + date-fns@^1.27.2: version "1.29.0" resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-1.29.0.tgz#12e609cdcb935127311d04d33334e2960a2a54e6" @@ -4245,6 +4314,23 @@ engine.io-client@1.8.3: xmlhttprequest-ssl "1.5.3" yeast "0.1.2" +engine.io-client@~1.8.4: + version "1.8.5" + resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-1.8.5.tgz#fe7fb60cb0dcf2fa2859489329cb5968dedeb11f" + dependencies: + component-emitter "1.2.1" + component-inherit "0.0.3" + debug "2.3.3" + engine.io-parser "1.3.2" + has-cors "1.1.0" + indexof "0.0.1" + parsejson "0.0.3" + parseqs "0.0.5" + parseuri "0.0.5" + ws "~1.1.5" + xmlhttprequest-ssl "1.5.3" + yeast "0.1.2" + engine.io-parser@1.3.2: version "1.3.2" resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-1.3.2.tgz#937b079f0007d0893ec56d46cb220b8cb435220a" @@ -4267,6 +4353,17 @@ engine.io@1.8.3: engine.io-parser "1.3.2" ws "1.1.2" +engine.io@~1.8.4: + version "1.8.5" + resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-1.8.5.tgz#4ebe5e75c6dc123dee4afdce6e5fdced21eb93f6" + dependencies: + accepts "1.3.3" + base64id "1.0.0" + cookie "0.3.1" + debug "2.3.3" + engine.io-parser "1.3.2" + ws "~1.1.5" + enhanced-resolve@^3.0.0, enhanced-resolve@^3.4.0: version "3.4.1" resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-3.4.1.tgz#0421e339fd71419b3da13d129b3979040230476e" @@ -4730,7 +4827,7 @@ eventemitter3@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-3.1.0.tgz#090b4d6cdbd645ed10bf750d4b5407942d7ba163" -events@^1.0.0: +events@^1.0.0, events@^1.0.2: version "1.1.1" resolved "https://registry.yarnpkg.com/events/-/events-1.1.1.tgz#9ebdb7635ad099c70dcc4c2a1f5004288e8bd924" @@ -5007,6 +5104,18 @@ fb-watchman@^2.0.0: dependencies: bser "^2.0.0" +fbjs@^0.8.1: + version "0.8.17" + resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.17.tgz#c4d598ead6949112653d6588b01a5cdcd9f90fdd" + dependencies: + core-js "^1.0.0" + isomorphic-fetch "^2.1.1" + loose-envify "^1.0.0" + object-assign "^4.1.0" + promise "^7.1.1" + setimmediate "^1.0.5" + ua-parser-js "^0.7.18" + fbjs@^0.8.16, fbjs@^0.8.4, fbjs@^0.8.5, fbjs@^0.8.9: version "0.8.16" resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.16.tgz#5e67432f550dc41b572bf55847b8aca64e5337db" @@ -5066,6 +5175,10 @@ file-loader@1.1.4: loader-utils "^1.0.2" schema-utils "^0.3.0" +file-saver@^1.3.8: + version "1.3.8" + resolved "https://registry.yarnpkg.com/file-saver/-/file-saver-1.3.8.tgz#e68a30c7cb044e2fb362b428469feb291c2e09d8" + file-type@^3.1.0, file-type@^3.8.0: version "3.9.0" resolved "https://registry.yarnpkg.com/file-type/-/file-type-3.9.0.tgz#257a078384d1db8087bc449d107d52a52672b9e9" @@ -5946,6 +6059,16 @@ handlebars@^4.0.1, handlebars@^4.0.3: optionalDependencies: uglify-js "^2.6" +handlebars@^4.0.10: + version "4.0.12" + resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.0.12.tgz#2c15c8a96d46da5e266700518ba8cb8d919d5bc5" + dependencies: + async "^2.5.0" + optimist "^0.6.1" + source-map "^0.6.1" + optionalDependencies: + uglify-js "^3.1.4" + hapi-auth-cookie@6.1.1: version "6.1.1" resolved "https://registry.yarnpkg.com/hapi-auth-cookie/-/hapi-auth-cookie-6.1.1.tgz#927db39e434916d81ab870d4181d70d53e745572" @@ -6232,6 +6355,10 @@ hoist-non-react-statics@^2.3.0, hoist-non-react-statics@^2.5.0: version "2.5.0" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.5.0.tgz#d2ca2dfc19c5a91c5a6615ce8e564ef0347e2a40" +hoist-non-react-statics@^2.3.1: + version "2.5.5" + resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz#c5903cf409c0dfd908f388e619d86b9c1174cb47" + home-or-tmp@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/home-or-tmp/-/home-or-tmp-2.0.0.tgz#e36c3f2d2cae7d746a857e38d18d5f32a7882db8" @@ -6495,6 +6622,12 @@ ini@^1.3.4, ini@~1.3.0: version "1.3.5" resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927" +inline-style@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/inline-style/-/inline-style-2.0.0.tgz#2fa9cf624596a8109355b925094e138bbd5ea29b" + dependencies: + dashify "^0.1.0" + inquirer@^0.11.1: version "0.11.4" resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-0.11.4.tgz#81e3374e8361beaff2d97016206d359d0b32fa4d" @@ -6588,7 +6721,7 @@ into-stream@^3.1.0: from2 "^2.1.1" p-is-promise "^1.1.0" -invariant@^2.0.0, invariant@^2.1.1, invariant@^2.2.0, invariant@^2.2.1, invariant@^2.2.2, invariant@^2.2.4: +invariant@^2.0.0, invariant@^2.1.0, invariant@^2.1.1, invariant@^2.2.0, invariant@^2.2.1, invariant@^2.2.2, invariant@^2.2.4: version "2.2.4" resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" dependencies: @@ -7848,6 +7981,10 @@ just-extend@^1.1.27: version "1.1.27" resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-1.1.27.tgz#ec6e79410ff914e472652abfa0e603c03d60e905" +just-reduce-object@^1.0.3: + version "1.1.0" + resolved "https://registry.yarnpkg.com/just-reduce-object/-/just-reduce-object-1.1.0.tgz#d29d172264f8511c74462de30d72d5838b6967e6" + karma-chrome-launcher@2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/karma-chrome-launcher/-/karma-chrome-launcher-2.1.1.tgz#216879c68ac04d8d5140e99619ba04b59afd46cf" @@ -8331,6 +8468,10 @@ lodash.camelcase@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6" +lodash.clone@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.clone/-/lodash.clone-4.5.0.tgz#195870450f5a13192478df4bc3d23d2dea1907b6" + lodash.clonedeep@^4.3.2: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" @@ -8420,6 +8561,10 @@ lodash.kebabcase@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz#8489b1cb0d29ff88195cceca448ff6d6cc295c36" +lodash.keyby@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/lodash.keyby/-/lodash.keyby-4.6.0.tgz#7f6a1abda93fd24e22728a4d361ed8bcba5a4354" + lodash.keys@^3.0.0: version "3.1.2" resolved "https://registry.yarnpkg.com/lodash.keys/-/lodash.keys-3.1.2.tgz#4dbc0472b156be50a0b286855d1bd0b0c656098a" @@ -8428,6 +8573,10 @@ lodash.keys@^3.0.0: lodash.isarguments "^3.0.0" lodash.isarray "^3.0.0" +lodash.lowercase@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.lowercase/-/lodash.lowercase-4.3.0.tgz#46515aced4acb0b7093133333af068e4c3b14e9d" + lodash.map@^4.4.0: version "4.6.0" resolved "https://registry.yarnpkg.com/lodash.map/-/lodash.map-4.6.0.tgz#771ec7839e3473d9c4cde28b19394c3562f4f6d3" @@ -8448,6 +8597,10 @@ lodash.mergewith@^4.6.0: version "4.6.1" resolved "https://registry.yarnpkg.com/lodash.mergewith/-/lodash.mergewith-4.6.1.tgz#639057e726c3afbdb3e7d42741caa8d6e4335927" +lodash.omitby@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/lodash.omitby/-/lodash.omitby-4.6.0.tgz#5c15ff4754ad555016b53c041311e8f079204791" + lodash.orderby@4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/lodash.orderby/-/lodash.orderby-4.6.0.tgz#e697f04ce5d78522f54d9338b32b81a3393e4eb3" @@ -8462,6 +8615,10 @@ lodash.pick@^4.2.1: version "4.4.0" resolved "https://registry.yarnpkg.com/lodash.pick/-/lodash.pick-4.4.0.tgz#52f05610fff9ded422611441ed1fc123a03001b3" +lodash.pickby@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/lodash.pickby/-/lodash.pickby-4.6.0.tgz#7dea21d8c18d7703a27c704c15d3b84a67e33aff" + lodash.reduce@^4.4.0: version "4.6.0" resolved "https://registry.yarnpkg.com/lodash.reduce/-/lodash.reduce-4.6.0.tgz#f1ab6b839299ad48f784abbf476596f03b914d3b" @@ -8504,6 +8661,10 @@ lodash.throttle@^3.0.2: dependencies: lodash.debounce "^3.0.0" +lodash.topath@^4.5.2: + version "4.5.2" + resolved "https://registry.yarnpkg.com/lodash.topath/-/lodash.topath-4.5.2.tgz#3616351f3bba61994a0931989660bd03254fd009" + lodash.trimend@^4.5.1: version "4.5.1" resolved "https://registry.yarnpkg.com/lodash.trimend/-/lodash.trimend-4.5.1.tgz#12804437286b98cad8996b79414e11300114082f" @@ -8512,6 +8673,10 @@ lodash.uniq@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" +lodash.uniqby@^4.7.0: + version "4.7.0" + resolved "https://registry.yarnpkg.com/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz#d99c07a669e9e6d24e1362dfe266c67616af1302" + lodash.uniqueid@^3.0.0: version "3.2.0" resolved "https://registry.yarnpkg.com/lodash.uniqueid/-/lodash.uniqueid-3.2.0.tgz#59416f134103ce253d4b4aa818272be3fbbcbbdb" @@ -8635,6 +8800,10 @@ lru-cache@^4.1.1: pseudomap "^1.0.2" yallist "^2.1.2" +lz-string@^1.4.4: + version "1.4.4" + resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.4.4.tgz#c0d8eaf36059f705796e1e344811cf4c498d3a26" + macaddress@^0.2.8: version "0.2.9" resolved "https://registry.yarnpkg.com/macaddress/-/macaddress-0.2.9.tgz#3579b8b9acd5b96b4553abf0f394185a86813cb3" @@ -8745,6 +8914,10 @@ mem@^1.1.0: dependencies: mimic-fn "^1.0.0" +memoize-one@^4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-4.0.2.tgz#3fb8db695aa14ab9c0f1644e1585a8806adc1aee" + memory-fs@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/memory-fs/-/memory-fs-0.2.0.tgz#f2bb25368bc121e391c2520de92969caee0a0290" @@ -8860,7 +9033,7 @@ mime@^1.2.11, mime@^1.3.4, mime@^1.4.1: version "1.6.0" resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" -mime@^2.0.3: +mime@^2.0.3, mime@^2.2.2: version "2.3.1" resolved "https://registry.yarnpkg.com/mime/-/mime-2.3.1.tgz#b1621c54d63b97c47d3cfe7f7215f7d64517c369" @@ -9472,6 +9645,10 @@ object-keys@^1.0.11, object-keys@^1.0.6, object-keys@^1.0.8: version "1.0.11" resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.0.11.tgz#c54601778ad560f1142ce0e01bcca8b56d13426d" +object-path-immutable@^0.5.3: + version "0.5.3" + resolved "https://registry.yarnpkg.com/object-path-immutable/-/object-path-immutable-0.5.3.tgz#57a874bdfa98147907ea1b9b0c570940a0f45ae0" + object-visit@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb" @@ -9733,6 +9910,10 @@ pako@~1.0.5: version "1.0.6" resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.6.tgz#0101211baa70c4bca4a0f63f2206e97b7dfaf258" +papaparse@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/papaparse/-/papaparse-4.6.0.tgz#4e3b8d6bf9f7900da437912794ec292207526867" + parallel-transform@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/parallel-transform/-/parallel-transform-1.1.0.tgz#d410f065b05da23081fcd10f28854c29bda33b06" @@ -10054,6 +10235,10 @@ pkg-up@^2.0.0: dependencies: find-up "^2.1.0" +platform@^1.3.0: + version "1.3.5" + resolved "https://registry.yarnpkg.com/platform/-/platform-1.3.5.tgz#fb6958c696e07e2918d2eeda0f0bc9448d733444" + plugin-error@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/plugin-error/-/plugin-error-1.0.1.tgz#77016bd8919d0ac377fdcdd0322328953ca5781c" @@ -10474,7 +10659,7 @@ prop-types@15.5.8: dependencies: fbjs "^0.8.9" -prop-types@15.x, prop-types@^15.5.0, prop-types@^15.5.10, prop-types@^15.5.4, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1: +prop-types@15.6.1, prop-types@15.x, prop-types@^15.5.0, prop-types@^15.5.10, prop-types@^15.5.4, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1: version "15.6.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.1.tgz#36644453564255ddda391191fb3a125cbdf654ca" dependencies: @@ -10482,6 +10667,13 @@ prop-types@15.x, prop-types@^15.5.0, prop-types@^15.5.10, prop-types@^15.5.4, pr loose-envify "^1.3.1" object-assign "^4.1.1" +prop-types@^15.5.7: + version "15.6.2" + resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.2.tgz#05d5ca77b4453e985d60fc7ff8c859094a497102" + dependencies: + loose-envify "^1.3.1" + object-assign "^4.1.1" + propagate@0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/propagate/-/propagate-0.4.0.tgz#f3fcca0a6fe06736a7ba572966069617c130b481" @@ -10755,6 +10947,10 @@ quote-stream@^1.0.1, quote-stream@~1.0.2: minimist "^1.1.3" through2 "^2.0.0" +raf-schd@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/raf-schd/-/raf-schd-4.0.0.tgz#9855756c5045ff4ed4516e14a47719387c3c907b" + raf@^3.0.0, raf@^3.1.0, raf@^3.3.0, raf@^3.4.0: version "3.4.0" resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.0.tgz#a28876881b4bc2ca9117d4138163ddb80f781575" @@ -10853,6 +11049,20 @@ react-anything-sortable@^1.7.4: create-react-class "^15.5.2" prop-types "^15.5.8" +react-beautiful-dnd@^8.0.7: + version "8.0.7" + resolved "https://registry.yarnpkg.com/react-beautiful-dnd/-/react-beautiful-dnd-8.0.7.tgz#2cc7ba62bffe08d3dad862fd8f48204440901b43" + dependencies: + "@babel/runtime" "7.0.0-beta.54" + css-box-model "^1.0.0" + memoize-one "^4.0.0" + prop-types "15.6.1" + raf-schd "^4.0.0" + react-motion "^0.5.2" + react-redux "^5.0.7" + redux "^4.0.0" + tiny-invariant "^0.0.3" + react-clipboard.js@^1.1.2: version "1.1.3" resolved "https://registry.yarnpkg.com/react-clipboard.js/-/react-clipboard.js-1.1.3.tgz#86feeb49364553ecd15aea91c75aa142532a60e0" @@ -10879,6 +11089,15 @@ react-datepicker@v1.5.0: react-onclickoutside "^6.7.1" react-popper "^0.9.1" +react-datetime@^2.14.0: + version "2.15.0" + resolved "https://registry.yarnpkg.com/react-datetime/-/react-datetime-2.15.0.tgz#a8f7da6c58b6b45dbeea32d4e8485db17614e12c" + dependencies: + create-react-class "^15.5.2" + object-assign "^3.0.0" + prop-types "^15.5.7" + react-onclickoutside "^6.5.0" + react-dom@^16.0.0: version "16.2.0" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.2.0.tgz#69003178601c0ca19b709b33a83369fe6124c044" @@ -10904,6 +11123,13 @@ react-draggable@3.x, "react-draggable@^2.2.6 || ^3.0.3": classnames "^2.2.5" prop-types "^15.6.0" +react-dropzone@^4.2.9: + version "4.3.0" + resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-4.3.0.tgz#facdd7db16509772633c9f5200621ac01aa6706f" + dependencies: + attr-accept "^1.1.3" + prop-types "^15.5.7" + react-grid-layout@^0.16.2: version "0.16.6" resolved "https://registry.yarnpkg.com/react-grid-layout/-/react-grid-layout-0.16.6.tgz#9b2407a2b946c2260ebaf66f13b556e1da4efeb2" @@ -10944,6 +11170,10 @@ react-is@^16.4.0: version "16.4.0" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.4.0.tgz#cc9fdc855ac34d2e7d9d2eb7059bbc240d35ffcf" +react-lib-adler32@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/react-lib-adler32/-/react-lib-adler32-1.0.1.tgz#01f7a0e24fe715580aadb8a827c39a850e1ccc4e" + react-markdown-renderer@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/react-markdown-renderer/-/react-markdown-renderer-1.4.0.tgz#f3b95bd9fc7f7bf8ab3f0150aa696b41740e7d01" @@ -10969,7 +11199,7 @@ react-motion@^0.5.2: prop-types "^15.5.8" raf "^3.1.0" -react-onclickoutside@^6.7.1: +react-onclickoutside@^6.5.0, react-onclickoutside@^6.7.1: version "6.7.1" resolved "https://registry.yarnpkg.com/react-onclickoutside/-/react-onclickoutside-6.7.1.tgz#6a5b5b8b4eae6b776259712c89c8a2b36b17be93" @@ -11056,6 +11286,17 @@ react-select@^1.2.1: prop-types "^15.5.8" react-input-autosize "^2.1.2" +react-shortcuts@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/react-shortcuts/-/react-shortcuts-2.0.0.tgz#871b033a071a8537422b1529d691c38432823bae" + dependencies: + combokeys "^3.0.0" + events "^1.0.2" + invariant "^2.1.0" + just-reduce-object "^1.0.3" + platform "^1.3.0" + prop-types "^15.5.8" + react-sizeme@^2.3.6: version "2.3.6" resolved "https://registry.yarnpkg.com/react-sizeme/-/react-sizeme-2.3.6.tgz#d60ea2634acc3fd827a3c7738d41eea0992fa678" @@ -11312,6 +11553,15 @@ realpath-native@^1.0.0: dependencies: util.promisify "^1.0.0" +recompose@^0.26.0: + version "0.26.0" + resolved "https://registry.yarnpkg.com/recompose/-/recompose-0.26.0.tgz#9babff039cb72ba5bd17366d55d7232fbdfb2d30" + dependencies: + change-emitter "^0.1.2" + fbjs "^0.8.1" + hoist-non-react-statics "^2.3.1" + symbol-observable "^1.0.4" + redent@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/redent/-/redent-1.0.0.tgz#cf916ab1fd5f1f16dfb20822dd6ec7f730c2afde" @@ -11344,6 +11594,10 @@ reduce-reducers@^0.1.0: version "0.1.2" resolved "https://registry.yarnpkg.com/reduce-reducers/-/reduce-reducers-0.1.2.tgz#fa1b4718bc5292a71ddd1e5d839c9bea9770f14b" +reduce-reducers@^0.1.2: + version "0.1.5" + resolved "https://registry.yarnpkg.com/reduce-reducers/-/reduce-reducers-0.1.5.tgz#ff77ca8068ff41007319b8b4b91533c7e0e54576" + redux-actions@2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/redux-actions/-/redux-actions-2.2.1.tgz#d64186b25649a13c05478547d7cd7537b892410d" @@ -11357,6 +11611,10 @@ redux-thunk@2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.3.0.tgz#51c2c19a185ed5187aaa9a2d08b666d0d6467622" +redux-thunks@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/redux-thunks/-/redux-thunks-1.0.0.tgz#56e03b86d281a2664c884ab05c543d9ab1673658" + redux@4.0.0, redux@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.0.tgz#aa698a92b729315d22b34a0553d7e6533555cc03" @@ -11380,6 +11638,10 @@ regenerator-runtime@^0.11.0: version "0.11.1" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9" +regenerator-runtime@^0.12.0: + version "0.12.1" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.12.1.tgz#fa1a71544764c036f8c49b13a08b2594c9f8a0de" + regenerator-transform@^0.10.0: version "0.10.1" resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.10.1.tgz#1e4996837231da8b7f3cf4114d71b5691a0680dd" @@ -11954,6 +12216,10 @@ script-loader@0.7.2: dependencies: raw-loader "~0.5.1" +scriptjs@^2.5.8: + version "2.5.8" + resolved "https://registry.yarnpkg.com/scriptjs/-/scriptjs-2.5.8.tgz#d0c43955c2e6bad33b6e4edf7b53b8965aa7ca5f" + scroll-into-view@^1.3.0: version "1.9.1" resolved "https://registry.yarnpkg.com/scroll-into-view/-/scroll-into-view-1.9.1.tgz#90c3b338422f9fddaebad90e6954790940dc9c1e" @@ -12190,6 +12456,22 @@ socket.io-client@1.7.3: socket.io-parser "2.3.1" to-array "0.1.4" +socket.io-client@1.7.4, socket.io-client@^1.7.3: + version "1.7.4" + resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-1.7.4.tgz#ec9f820356ed99ef6d357f0756d648717bdd4281" + dependencies: + backo2 "1.0.2" + component-bind "1.0.0" + component-emitter "1.2.1" + debug "2.3.3" + engine.io-client "~1.8.4" + has-binary "0.1.7" + indexof "0.0.1" + object-component "0.0.3" + parseuri "0.0.5" + socket.io-parser "2.3.1" + to-array "0.1.4" + socket.io-parser@2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-2.3.1.tgz#dd532025103ce429697326befd64005fcfe5b4a0" @@ -12211,6 +12493,18 @@ socket.io@1.7.3: socket.io-client "1.7.3" socket.io-parser "2.3.1" +socket.io@^1.7.3: + version "1.7.4" + resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-1.7.4.tgz#2f7ecedc3391bf2d5c73e291fe233e6e34d4dd00" + dependencies: + debug "2.3.3" + engine.io "~1.8.4" + has-binary "0.1.7" + object-assign "4.1.0" + socket.io-adapter "0.5.0" + socket.io-client "1.7.4" + socket.io-parser "2.3.1" + sort-keys@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-1.1.2.tgz#441b6d4d346798f1b4e49e8920adfba0e543f9ad" @@ -12528,6 +12822,10 @@ stream-shift@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.0.tgz#d5c752825e5367e786f78e18e445ea223a155952" +stream-stream@^1.2.6: + version "1.2.6" + resolved "https://registry.yarnpkg.com/stream-stream/-/stream-stream-1.2.6.tgz#a9ae071c64c11b8584f52973f7715e37e5144c43" + stream-to-buffer@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/stream-to-buffer/-/stream-to-buffer-0.1.0.tgz#26799d903ab2025c9bd550ac47171b00f8dd80a9" @@ -12654,6 +12952,12 @@ strip-outer@^1.0.0: dependencies: escape-string-regexp "^1.0.2" +style-it@^1.6.12: + version "1.6.13" + resolved "https://registry.yarnpkg.com/style-it/-/style-it-1.6.13.tgz#b57f01e3fd15a6c39b8386793f604471b1b9c90d" + dependencies: + react-lib-adler32 "^1.0.0" + style-loader@0.19.0: version "0.19.0" resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-0.19.0.tgz#7258e788f0fee6a42d710eaf7d6c2412a4c50759" @@ -12787,7 +13091,7 @@ svgo@^0.7.0: sax "~1.2.1" whet.extend "~0.9.9" -symbol-observable@^1.1.0, symbol-observable@^1.2.0: +symbol-observable@^1.0.4, symbol-observable@^1.1.0, symbol-observable@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" @@ -12982,6 +13286,10 @@ tiny-inflate@^1.0.0, tiny-inflate@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/tiny-inflate/-/tiny-inflate-1.0.2.tgz#93d9decffc8805bd57eae4310f0b745e9b6fb3a7" +tiny-invariant@^0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-0.0.3.tgz#4c7283c950e290889e9e94f64d3586ec9156cf44" + tiny-lr@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/tiny-lr/-/tiny-lr-0.2.1.tgz#b3fdba802e5d56a33c2f6f10794b32e477ac729d" @@ -13017,6 +13325,10 @@ tinymath@0.2.1: dependencies: lodash.get "^4.4.2" +tinymath@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/tinymath/-/tinymath-0.5.0.tgz#4c8b788a40b5929c4aff36ecc7c128004202496a" + tmp@0.0.23: version "0.0.23" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.23.tgz#de874aa5e974a85f0a32cdfdbd74663cb3bd9c74" @@ -13088,6 +13400,10 @@ to-through@^2.0.0: dependencies: through2 "^2.0.3" +toggle-selection@^1.0.3: + version "1.0.6" + resolved "https://registry.yarnpkg.com/toggle-selection/-/toggle-selection-1.0.6.tgz#6e45b1263f2017fa0acc7d89d78b15b8bf77da32" + token-stream@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/token-stream/-/token-stream-0.0.1.tgz#ceeefc717a76c4316f126d0b9dbaa55d7e7df01a" @@ -13325,6 +13641,10 @@ typescript@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.0.3.tgz#4853b3e275ecdaa27f78fda46dc273a7eb7fc1c8" +ua-parser-js@^0.7.18: + version "0.7.18" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.18.tgz#a7bfd92f56edfb117083b69e31d2aa8882d4b1ed" + ua-parser-js@^0.7.9: version "0.7.17" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.17.tgz#e9ec5f9498b9ec910e7ae3ac626a805c4d09ecac" @@ -13349,6 +13669,13 @@ uglify-js@^2.6, uglify-js@^2.6.1, uglify-js@^2.8.29: optionalDependencies: uglify-to-browserify "~1.0.0" +uglify-js@^3.1.4: + version "3.4.9" + resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.4.9.tgz#af02f180c1207d76432e473ed24a28f4a782bae3" + dependencies: + commander "~2.17.1" + source-map "~0.6.1" + uglify-to-browserify@~1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz#6e0924d6bda6b5afe349e39a6d632850a0f882b7" @@ -13618,6 +13945,10 @@ url-parse-lax@^3.0.0: dependencies: prepend-http "^2.0.0" +url-pattern@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/url-pattern/-/url-pattern-1.0.3.tgz#0409292471b24f23c50d65a47931793d2b5acfc1" + url-regex@^3.0.0: version "3.2.0" resolved "https://registry.yarnpkg.com/url-regex/-/url-regex-3.2.0.tgz#dbad1e0c9e29e105dd0b1f09f6862f7fdb482724" @@ -14389,6 +14720,13 @@ ws@^5.1.1: dependencies: async-limiter "~1.0.0" +ws@~1.1.5: + version "1.1.5" + resolved "https://registry.yarnpkg.com/ws/-/ws-1.1.5.tgz#cbd9e6e75e09fc5d2c90015f21f0c40875e0dd51" + dependencies: + options ">=0.0.5" + ultron "1.0.x" + wtf-8@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/wtf-8/-/wtf-8-1.0.0.tgz#392d8ba2d0f1c34d1ee2d630f15d0efb68e1048a"