diff --git a/.circleci/config.yml b/.circleci/config.yml index b7d7033e9e..f6b47c80e8 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -90,21 +90,6 @@ jobs: - run: name: Execute Cypress tests command: npm run cypress run-ci - build-tarball: - docker: - - image: circleci/node:8 - steps: - - checkout - - run: sudo apt install python-pip - - run: sudo pip install -r requirements_bundles.txt - - run: npm install - - run: .circleci/update_version - - run: npm run bundle - - run: npm run build - - run: rm -rf ./node_modules/ - - run: .circleci/pack - - store_artifacts: - path: /tmp/artifacts/ build-docker-image: docker: - image: circleci/node:8 @@ -130,7 +115,8 @@ workflows: - frontend-e2e-tests: requires: - frontend-lint - - build-tarball: + - hold: + type: approval requires: - backend-unit-tests - frontend-unit-tests @@ -139,15 +125,8 @@ workflows: branches: only: - master + - preview-image - /release\/.*/ - build-docker-image: requires: - - backend-unit-tests - - frontend-unit-tests - - frontend-e2e-tests - filters: - branches: - only: - - master - - preview-image - - /release\/.*/ + - hold diff --git a/CHANGELOG.md b/CHANGELOG.md index cbefdec544..391b2d38cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,128 @@ # Change Log +## v8.0.1 - 2019-11-17 + +* Fix the DB migration so that the correct key is used for encrypting Data Source credentials. Otherwise direct upgrades from versions older than v7 will not work properly. +* Bump pymssql to get around pymssql/pymssql#520. +* Specify version of pandas to allow clean install against vanilla Python 2.7.17. + +## v8.0.0 - 2019-10-27 + +There were no changes in this release since v8.0.0-beta.2. This is just to mark a stable release. + +## v8.0.0-beta.2 - 2019-09-16 + +This is an update to the previous beta release, which includes: + +* Add options for users to share anonymous usage information with us (see [docs](https://redash.io/help/open-source/admin-guide/usage-data) for details). +* Visualizations: + - Allow the user to decide how to handle null values in charts. +* Upgrade Sentry-SDK to latest version. +* Make horizontal table scroll visible in dashboard widgets without scrolling. +* Data Sources: + * Add support for Azure Data Explorer (Kusto). + * MySQL: fix connections without SSL configuration failing. + * Amazon Redshift: option to set query group for adhoc/scheduled queries. + * Hive: make error message more friendly. + * Qubole: add support to run Quantum queries. +* Display data source icon in query editor. +* Fix: allow users with view only acces to use the queries in Query Results +* Dashboard: when updating parameters refersh only widgets that use those parameters. + +This release had contributions from 12 people: @arikfr, @cclauss, @gabrieldutra, @justinclift, @kravets-levko, @ranbena, @rauchy, @sandeepV2, @shinsuke-nara, @spacentropy, @sphenlee, @swfz. + + +## v8.0.0-beta - 2019-08-18 + +After months of being heads down with hard work, it's finally time to wrap up the V8 release 🤩 This release includes many long awaited improvements to parameters, UX improvements, further React migration and other changes, fixes and improvements. + +While this version is already running on the hosted platform to make sure it's stable, we're excited to put this in the hands of our Open Source users. + +Starting from this release we will no longer build a tarball distribution of the codebase and recommend everyone to switch over to using our Docker images. We're planning on dropping Python 2 support towards its EOL this year and switching over to the Docker image will make this transition much simpler. + +This release was made possible by contributions from over 40 people: @aidarbek, @AntonZarutsky, @ariarijp, @arikfr, @combineads, @deecay, @fmy, @gabrieldutra, @guwenqing, @guyco33, @ialeinikov, @Jakdaw, @jezdez, @justinclift, @k-tomoyasu, @katty0324, @koooge, @kravets-levko, @ktmud, @KumanoTanaka, @kyoshidajp, @nason, @oldPadavan, @openjck, @osule, @otsaloma, @ranbena, @rauchy, @rueian, @sekiyama58, @shinsuke-nara, @taminif, @The-Alchemist, @vv-p, @washort, @wudi-ayuan, @ygrishaev, @yoavbls, @yoshiken, @yusukegoto and the support of over 500 organizations who subscribed to our hosted version and by that sponsor the team's work. + +### Parameters + +- Parameter UI improvements: + - Support for multi-select in dropdown (and query dropdown) parameters. + - Support for dynamic values in date and date-range parameters. + - Search dropdown parameter values. + - New UX for applying parameter changes in queries and dashboards. +- Allow using Safe Parameters in visualization embeds and public dashboards. Safe Parameters are any parameter type except for the a text parameter (dropdowns are safe). + +### Data Sources + +- New Data Sources: Couchbase, Phoenix and Dgraph. +- New JSON data source (and deprecated old URL data source). +- Snowflake: update connector to latest version. +- PostgreSQL: show only accessible tables in schema. +- BigQuery: + - Correctly handle NaN values. + - Treat repeated fields as rrays. + - [BigQuery] Fix: in some queries there is no mode field +- DynamoDB: + - Support for Unicode in queries. + - Safe loading of schema. +- Rockset: better handling of query errors. +- Google Sheets: + - Support for Team Drive. + - Friendlier error message in case of an API error and more reliable test connection. +- MySQL: + - Support for calling Stored Procedures and better handling of query cancellation. + - Switch to using `mysqlclient` (a maintained fork of `Python-MySQL`). +- MongoDB: Support serializing Decimal128 values. +- Presto: support for passwords in connection settings. +- Amazon Athena: allow to specify custom work group. +- Query Results: querying a column with a dictionary or array fails +- Clickhouse: make sure we don't show password in error messages. +- Enable Cassandra support by default. + +### Visualizations + +- Charts: + - Fix: legend overlapping chart on small screens. + - Fix: Pie chart not rendering when series doesn't exist in options. + - Pie Chart: add option to set direction of slices. +- WordCloud: rewritten to support new options (provide frequency in query, limits), scale when resizing, handle long words and more. +- Pivot Table: support hiding totals. +- Counters: apply formatting to target value. +- Maps: + - Ability to customize marker icon and color. + - Customization options for Choropleth maps. +- New Visualization: Details View. + +### **UX** + +- Replace blank screen with a loading indicator when the application is doing its first load. +- Multiple improvements to dashboards editing: auto-save, grid markings and better refresh indicator. +- Admin can now edit user's groups from the user page. +- Add keyboard shortcut (Ctrl/Cmd+Shift+F) to trigger query formatting. + +### API + +- Query Result API response minimized to only required fields when called with a non user API key. +- Prefer API key over cookies in authentication. +- User can now regenerate Query API Key. + +### Other Changes + +- Sends CSP headers to prevent various kinds of security attacks via the browser. Might break unusual usages and embeds of Redash. +- New Failed Scheduled Queries email report (can be enabled from organization settings screen). +- Deprecated HipChat Alert Destination. +- Add options to hide different parts of a Visualization embed UI (parameters, title, link to query). +- Support multi-byte search for query names and descriptions (needs to be enabled in Organization settings screen). +- CSV query results download: correctly serialize booleans and date values. +- Dashboard filters now collect values from all widgets with the same filter. +- Support for custom message and description in alert notifications (currently disabled behind a feature flag until we improve the alert UX). + +### Bug Fixes + +- Fix: adding widget to dashboard from a query page is broken. +- Fix: default time format option was wrong. +- Fix: when too many errors of a scheduled queries occur it causes an OverflowError. +- Fix: when forking a query maintain the same visualizations order. + ## v7.0.0 - 2019-03-17 We're trying a new format for the CHANGELOG in this release. Focusing on the bigger changes, but for whoever interested, you can see all the changes [here](https://github.com/getredash/redash/compare/v6.0.0...master). diff --git a/README.md b/README.md index 221fab98f6..e7643a57c0 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Documentation](https://img.shields.io/badge/docs-redash.io/help-brightgreen.svg)](https://redash.io/help/) [![Datree](https://s3.amazonaws.com/catalog.static.datree.io/datree-badge-20px.svg)](https://datree.io/?src=badge) -![Build Status](https://circleci.com/gh/getredash/redash.png?circle-token=8a695aa5ec2cbfa89b48c275aea298318016f040) +[![Build Status](https://circleci.com/gh/getredash/redash.png?style=shield&circle-token=8a695aa5ec2cbfa89b48c275aea298318016f040)](https://circleci.com/gh/getredash/redash/tree/master) **_Redash_** is our take on freeing the data within our company in a way that will better fit our culture and usage patterns. diff --git a/bin/flake8_tests.sh b/bin/flake8_tests.sh index 3d6be0d2a9..3c27f7fee2 100755 --- a/bin/flake8_tests.sh +++ b/bin/flake8_tests.sh @@ -1,7 +1,9 @@ #!/bin/sh +set -o errexit # fail the build if any task fails + flake8 --version ; pip --version # stop the build if there are Python syntax errors or undefined names -flake8 . --count --select=E901,E999,F821,F822,F823 --show-source --statistics +flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics diff --git a/client/app/assets/images/db-logos/azure_kusto.png b/client/app/assets/images/db-logos/azure_kusto.png new file mode 100644 index 0000000000..9f24192c3e Binary files /dev/null and b/client/app/assets/images/db-logos/azure_kusto.png differ diff --git a/client/app/assets/less/ant.less b/client/app/assets/less/ant.less index 5c35144b6c..d1509a48b5 100644 --- a/client/app/assets/less/ant.less +++ b/client/app/assets/less/ant.less @@ -77,12 +77,6 @@ } } -// Fix for Ant dropdowns when they are used in Boootstrap modals -// ANGULAR_REMOVE_ME Remove when all dialogs will be migrated to React (also search and remove usages) -.ant-dropdown-in-bootstrap-modal { - z-index: 1050; -} - // Button overrides .@{btn-prefix-cls} { transition-duration: 150ms; @@ -156,6 +150,10 @@ border-color: transparent; color: @pagination-color; line-height: @pagination-item-size - 2px; + + .@{pagination-prefix-cls}.mini & { + line-height: @pagination-item-size-sm - 2px; + } } &:focus .@{pagination-prefix-cls}-item-link, diff --git a/client/app/assets/less/inc/alert.less b/client/app/assets/less/inc/alert.less index a61b2dde81..b7f0badab9 100755 --- a/client/app/assets/less/inc/alert.less +++ b/client/app/assets/less/inc/alert.less @@ -1,6 +1,5 @@ .alert { - padding-left: 30px; - padding-right: 30px; + padding: 15px; span { cursor: pointer; diff --git a/client/app/assets/less/inc/base.less b/client/app/assets/less/inc/base.less index b189802eb5..d5f05424ef 100755 --- a/client/app/assets/less/inc/base.less +++ b/client/app/assets/less/inc/base.less @@ -19,11 +19,15 @@ html, body { } body { - padding-top: @header-height; + padding-top: 0; + background: #F6F8F9; + font-family: @redash-font; position: relative; + &.headless { - padding-top: 0; - .nav.app-header { + padding-top: 10px; + + .nav.app-header, .navbar { display: none; } } @@ -47,20 +51,20 @@ strong { position: relative; padding-top: 30px; padding-bottom: 30px; - + @media (min-width: (@screen-sm-min + 1)) { padding-right: 15px; padding-left: 15px; } - + @media (min-width: (@screen-lg-min + 80px)) { margin-left: @sidebar-left-width; } - + @media (min-width: @screen-sm-min) and (max-width: (@screen-md-max + 80px)) { margin-left: @sidebar-left-mid-width; } - + @media (max-width: (@screen-sm-min)) { margin-left: 0; } @@ -72,10 +76,34 @@ strong { } } +// Fixed width layout for specific pages +@media (min-width: 768px) { + settings-screen, home-page, page-dashboard-list, page-queries-list, page-alerts-list, alert-page, queries-search-results-page, .fixed-container { + .container { + width: 750px; + } + } +} + +@media (min-width: 992px) { + settings-screen, home-page, page-dashboard-list, page-queries-list, page-alerts-list, alert-page, queries-search-results-page, .fixed-container { + .container { + width: 970px; + } + } +} + +@media (min-width: 1200px) { + settings-screen, home-page, page-dashboard-list, page-queries-list, page-alerts-list, alert-page, queries-search-results-page, .fixed-container { + .container { + width: 1170px; + } + } +} + .scrollbox { overflow: auto; position: relative; - } .clickable { @@ -95,3 +123,150 @@ strong { resize: both !important; transition: height 0s, width 0s !important; } + +// Ace Editor +.ace_editor { + border: 1px solid fade(@redash-gray, 15%) !important; +} + +.ace-tm { + .ace_gutter { + background: #fff !important; + } + + .ace_gutter-active-line { + background-color: fade(@redash-gray, 20%) !important; + } + + .ace_marker-layer .ace_active-line { + background: fade(@redash-gray, 9%) !important; + } +} + +.bg-ace { + background-color: fade(@redash-gray, 12%) !important; +} + +// resizeable +.rg-top span, .rg-bottom span { + height: 3px; + border-color: #b1c1ce; // TODO: variable +} + +.rg-bottom { + bottom: 15px; + + span { + margin: 1.5px 0 0 -10px; + } +} + +// Plotly +text.slicetext { + text-shadow: 1px 1px 5px #333; +} + +// markdown +.markdown strong { + font-weight: bold; +} + +.markdown img { + max-width: 100%; +} + +.dropdown-menu > li > a:hover, .dropdown-menu > li > a:focus { + background-color: fade(@redash-gray, 15%); + color: #111; +} + +.profile__image--navbar { + border-radius: 100%; + margin-right: 3px; + margin-top: -2px; +} + +.profile__image--settings { + border-radius: 100%; +} + +.profile__image_thumb { + border-radius: 100%; + margin-right: 3px; + margin-top: -2px; + width: 20px; + height: 20px; +} + + +// Error state +.error-state { + display: flex; + flex-direction: column; + justify-content: flex-start; + text-align: center; + margin-top: 25vh; + padding: 35px; + font-size: 14px; + line-height: 21px; + + .error-state__icon { + .zmdi { + font-size: 64px; + color: @redash-gray; + } + } + + @media (max-width: 767px) { + margin-top: 10vh; + } +} + +// page +.page-header--new .btn-favourite, .page-header--new .btn-archive { + font-size: 19px; + } + + .page-title { + display: flex; + align-items: center; + + h3 { + margin-right: 5px !important; + } + + .label { + margin-top: 3px; + display: inline-block; + } + + favorites-control { + margin-right: 5px; + } + + @media (max-width: 767px) { + display: block; + + favorites-control { + float: left; + } + + h3 { + width: 100%; + margin-bottom: 5px !important; + display: block !important; + } + } + } + + .page-header-wrapper, .page-header--new { + h3 { + margin: 0.2em 0; + line-height: 1.3; + font-weight: 500; + } + } + + .select-option-divider { + margin: 10px 0 !important; + } \ No newline at end of file diff --git a/client/app/assets/less/inc/bootstrap-overrides.less b/client/app/assets/less/inc/bootstrap-overrides.less index 1702d631cb..643a7e67df 100755 --- a/client/app/assets/less/inc/bootstrap-overrides.less +++ b/client/app/assets/less/inc/bootstrap-overrides.less @@ -31,7 +31,7 @@ .collapsing, .collapse.in { - padding: 5px 10px; + padding: 0; transition: all 0.35s ease; } diff --git a/client/app/assets/less/inc/button.less b/client/app/assets/less/inc/button.less index 324dd7bae8..d6661f9775 100755 --- a/client/app/assets/less/inc/button.less +++ b/client/app/assets/less/inc/button.less @@ -122,3 +122,21 @@ top: 1px; position: relative; } + + +.btn-default { + background-color: fade(@redash-gray, 15%); +} + +.btn-transparent { + background-color: transparent !important; +} + +.btn-default:hover, .btn-default:focus, .btn-default.focus, .btn-default:active, .btn-default.active, .open > .dropdown-toggle.btn-default { + background-color: fade(@redash-gray, 25%); +} + +.btn-default:active:hover, .btn-default.active:hover, .open > .dropdown-toggle.btn-default:hover, .btn-default:active:focus, .btn-default.active:focus, .open > .dropdown-toggle.btn-default:focus, .btn-default:active.focus, .btn-default.active.focus, .open > .dropdown-toggle.btn-default.focus { + color: #333; + background-color: fade(@redash-gray, 45%); +} \ No newline at end of file diff --git a/client/app/assets/less/inc/form.less b/client/app/assets/less/inc/form.less index 61602aa549..6508ebafe8 100755 --- a/client/app/assets/less/inc/form.less +++ b/client/app/assets/less/inc/form.less @@ -55,14 +55,17 @@ textarea.v-resizable { .transition-duration(300ms); resize: none; box-shadow: 0 0 0 40px rgba(0, 0, 0, 0) !important; - border-radius: 0; + border-radius: @redash-input-radius; &:focus { - box-shadow: 0 0 1px -2px rgba(121,194,255,0.5) !important; + box-shadow: none !important; + border-color: @blue; + } + &:hover { + border-color: @blue; } } - /* -------------------------------------------------------- Custom Checkbox + Radio -----------------------------------------------------------*/ diff --git a/client/app/assets/less/inc/generics.less b/client/app/assets/less/inc/generics.less index 0ad99bd54e..0555e90643 100755 --- a/client/app/assets/less/inc/generics.less +++ b/client/app/assets/less/inc/generics.less @@ -153,4 +153,10 @@ /* -------------------------------------------------------- Border Radius -----------------------------------------------------------*/ -.brd-2 { border-radius: 2px; } \ No newline at end of file +.brd-2 { border-radius: 2px; } + + +/* -------------------------------------------------------- + Alignment +-----------------------------------------------------------*/ +.va-top { vertical-align: top; } \ No newline at end of file diff --git a/client/app/assets/less/inc/label.less b/client/app/assets/less/inc/label.less index e3f8831b49..dee027841c 100755 --- a/client/app/assets/less/inc/label.less +++ b/client/app/assets/less/inc/label.less @@ -1,14 +1,37 @@ .label { + border-radius: 2px; + padding: 3px 6px 4px; + font-weight: 500; + font-size: 11px; +} + +.badge { border-radius: 1px; - padding: 4px 5px 3px; } -h1, h2, h3, h4, h5, h6 { - .label { - border-radius: 2px; - } +.label-default { + background: fade(@redash-gray, 85%); } -.badge { - border-radius: 1px; +.label-tag-unpublished { + background: fade(@redash-gray, 85%); +} + +.label-tag-archived { + .label-warning(); +} + +.label-tag { + background: fade(@redash-gray, 10%); + color: fade(@redash-gray, 75%); +} + +.label-tag-unpublished, +.label-tag-archived, +.label-tag { + margin-right: 3px; + display: inline; + margin-top: 2px; + max-width: 24ch; + .text-overflow(); } \ No newline at end of file diff --git a/client/app/assets/less/inc/list-group.less b/client/app/assets/less/inc/list-group.less index 85635c7ee3..ce32187ed6 100755 --- a/client/app/assets/less/inc/list-group.less +++ b/client/app/assets/less/inc/list-group.less @@ -31,6 +31,17 @@ tags-list { line-height: 1.3; } +.tags-list { + .badge-light { + background: fade(@redash-gray, 10%); + color: fade(@redash-gray, 75%); + } + + a:hover { + cursor: pointer; + } +} + .max-character { .text-overflow(); } @@ -45,6 +56,11 @@ tags-list { line-height: 100%; margin-top: 2px; } + + &.active, &.active:hover, &.active:focus { + background-color: #fff; + box-shadow: inset 3px 0px 0px @brand-primary; + } } .list-group-item-heading { @@ -76,3 +92,18 @@ tags-list { height: 38px; border-radius: 2px; } + +.ui-select-choices-row.disabled > span { + background-color: inherit !important; +} + +.list-group-item.inactive, +.ui-select-choices-row.disabled { + background-color: #eee !important; + border-color: transparent; + opacity: 0.5; + box-shadow: none; + color: #333; + pointer-events: none; + cursor: not-allowed; +} \ No newline at end of file diff --git a/client/app/assets/less/inc/navbar.less b/client/app/assets/less/inc/navbar.less index c65b309b66..3b912e8dea 100755 --- a/client/app/assets/less/inc/navbar.less +++ b/client/app/assets/less/inc/navbar.less @@ -30,3 +30,266 @@ a.navbar-brand img { left: -9px; bottom: -11px; } + +.caret--nav { + border-top: none; +} + +.caret--nav:after { + content: ""; + position: absolute; + right: 5px; + top: 9px; + width: 13px; + height: 13px; + display: block; + background-image: url("data:image/svg+xml,%3C%3Fxml version='1.0' encoding='UTF-8'%3F%3E%3Csvg width='11px' height='6px' viewBox='0 0 11 6' version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'%3E%3C!-- Generator: Sketch 42 %2836781%29 - http://www.bohemiancoding.com/sketch --%3E%3Ctitle%3EShape%3C/title%3E%3Cdesc%3ECreated with Sketch.%3C/desc%3E%3Cdefs%3E%3C/defs%3E%3Cg id='Page-1' stroke='none' stroke-width='1' fill='none' fill-rule='evenodd'%3E%3Cpath d='M5.296,4.288 L9.382,0.2 C9.66086822,-0.0716916976 10.1065187,-0.068122925 10.381,0.208 C10.661,0.488 10.661,0.932 10.388,1.206 L5.792,5.803 C5.6602899,5.93388911 5.48167943,6.00662966 5.296,6.005 C5.10997499,6.00689786 4.93095449,5.93413702 4.799,5.803 L0.204,1.207 C0.072163111,1.07394937 -0.00121750401,0.893846387 9.62313189e-05,0.706545264 C0.00140996665,0.519244142 0.0773097323,0.340188219 0.211,0.209 C0.485365732,-0.0664648737 0.930253538,-0.0700311086 1.209,0.201 L5.296,4.288 L5.296,4.288 Z' id='Shape' fill='%23000000'%3E%3C/path%3E%3C/g%3E%3C/svg%3E"); + background-size: 100% 100%; + transition: transform .2s cubic-bezier(.75,0,.25,1); +} + +.navbar .caret--nav:after { + top: 19px; +} + +.dropdown--profile .caret--nav:after { + right: 8px; +} + +.btn--create { + padding-right: 20px; + + .caret--nav:after { + top: 10px; + right: 10px; + background-image: url("data:image/svg+xml,%3C%3Fxml version='1.0' encoding='UTF-8'%3F%3E%3Csvg width='11px' height='6px' viewBox='0 0 11 6' version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'%3E%3C!-- Generator: Sketch 42 %2836781%29 - http://www.bohemiancoding.com/sketch --%3E%3Ctitle%3EShape%3C/title%3E%3Cdesc%3ECreated with Sketch.%3C/desc%3E%3Cdefs%3E%3C/defs%3E%3Cg id='Page-1' stroke='none' stroke-width='1' fill='none' fill-rule='evenodd'%3E%3Cpath d='M5.29592111,4.28945339 L9.38192111,0.201453387 C9.66078932,-0.0702383105 10.1064398,-0.0666695379 10.3809211,0.209453387 C10.6609211,0.489453387 10.6609211,0.933453387 10.3879211,1.20745339 L5.79192111,5.80445339 C5.66021101,5.9353425 5.48160054,6.00808305 5.29592111,6.00645339 C5.1098961,6.00835125 4.9308756,5.9355904 4.79892111,5.80445339 L0.203921109,1.20845339 C0.0720842204,1.07540275 -0.00129639464,0.895299774 1.73406884e-05,0.707998651 C0.00133107602,0.520697529 0.0772308417,0.341641606 0.210921109,0.210453387 C0.485286842,-0.0650114866 0.930174648,-0.0685777215 1.20892111,0.202453387 L5.29592111,4.28945339 L5.29592111,4.28945339 Z' id='Shape' fill='%23FCFCFC'%3E%3C/path%3E%3C/g%3E%3C/svg%3E"); + } +} + +.dropdown.open .caret--nav:after { + transform: rotate(180deg); +} + +.navbar { + box-shadow: fade(@redash-gray, 15%) 0px 4px 9px -3px; + + .navbar-collapse { + padding-left: 0; + } + + a.dropdown--profile { + padding-top: 10px; + padding-bottom: 10px; + line-height: 2.35; + } + + .navbar-inverse { + background-color: @redash-gray; + border: none; + } +} + +.navbar-btn { + margin-top: 10px; + margin-bottom: 9px; +} + +.navbar-brand { + position: absolute; + left: 50%; + margin-left: -25px !important; // center + display: block; + zoom: 0.9; +} + +.menu-search { + margin-top: 2px; +} + +.dropdown-menu--profile { + li { + width: 200px; + } +} + +.navbar .collapse.in { + background: #fff; + position: relative; + z-index: 999; + padding: 0 10px 0 10px; +} +.navbar { + min-height: initial; + height: 50px; + border: 1px solid #fff; + border-top: none; + border-radius: 0; + background: #fff; + margin-bottom: 10px; + + .btn-group.open .dropdown-toggle { + -webkit-box-shadow: none; + box-shadow: none; + } + + .btn-group .btn:active { + box-shadow: none; + } +} + +.navbar-link-ANGULAR_REMOVE_ME { + line-height: 18px; + padding: 10px 15px; + display: block; + + @media (min-width: 768px) { + padding-top: 16px; + padding-bottom: 16px; + } +} + +.navbar-link-ANGULAR_REMOVE_ME, +.navbar-default .navbar-nav > li > a { + color: #000; + font-weight: 500; + + &:active, &:hover, &:focus { + color: #000; + } +} + +.navbar-default .btn__new button { + font-weight: 500; +} + +.btn__new { + margin-left: 15px; +} + +.navbar-default .navbar-nav > li > a:hover { + //background-color: fade(@redash-gray, 10%); + //text-decoration: underline; + //border-radius: 0; +} + +.navbar-default .navbar-nav > .open > a, .navbar-default .navbar-nav > .open > a:hover, .navbar-default .navbar-nav > .open > a:focus { + background-color: fade(@redash-gray, 15%); + color: #111; +} + + +// Responsive fixes +@media (max-width: 767px) { + .navbar-brand { + left: 2%; + margin-left: 0 !important; + } + + //Fix navbar collapse + .navbar .collapse.in { + border: none; + + .dropdown-menu--profile { + li { + width: auto; + } + } + + .dropdown--profile { + .caret--nav:after { + right: initial !important; + } + } + + .dropdown--profile__username { + display: inline-block; + } + + .nav__main li a { + padding: 10px 15px; + display: block; + text-align: left; + float: none !important; + } + + .navbar-form { + margin-bottom: 0; + margin-top: 0; + } + + .navbar-right { + margin-bottom: 0; + } + } +} + +@media (min-width: 768px) { + @media (max-width: 880px) { + .navbar-link-ANGULAR_REMOVE_ME, + .navbar-default .navbar-nav > li > a, + .navbar-form { + padding-left: 10px !important; + padding-right: 10px !important; + } + + a.navbar-brand { + margin-left: -15px !important; + } + } + + @media (max-width: 810px) { + .menu-search { + width: 175px; + } + + a.navbar-brand { + margin-left: 13px !important; + } + } +} + +@media (max-width: 1084px) { + .dropdown--profile__username { + display: none; + } +} + + + + +// Cross-browser fixes + +// Firefox +@-moz-document url-prefix() { + .caret--nav::after { + height: 7px; + } + + .navbar .caret--nav::after { + top: 22px; + } + + .navbar .btn--create .caret--nav::after { + top: 12px; + } +} + +// IE10+ +@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) { + .caret--nav::after { + height: 7px; + } + + .navbar .caret--nav::after { + top: 22px; + } + + .navbar .btn--create .caret--nav::after { + top: 12px; + } +} + + +.navbar li a .btn-favourite .fa, .navbar li a .btn-archive .fa { + font-size: 100%; +} \ No newline at end of file diff --git a/client/app/assets/less/inc/pagination.less b/client/app/assets/less/inc/pagination.less deleted file mode 100755 index 05511e5d1b..0000000000 --- a/client/app/assets/less/inc/pagination.less +++ /dev/null @@ -1,54 +0,0 @@ -.pagination { - border-radius: 0; - - & > li { - margin: 0 2px; - display: inline-block; - vertical-align: top; - - & > a, - & > span { - border-radius: 50% !important; - padding: 0; - width: 40px; - height: 40px; - line-height: 38px; - text-align: center; - font-size: 14px; - z-index: 1; - position: relative; - - & > .zmdi { - font-size: 22px; - line-height: 39px; - } - } - - &.disabled { - .opacity(0.5); - } - } -} - - -/* -------------------------------------------------------- - Listview Pagination ------------------------------------------------------------*/ -.lv-pagination { - width: 100%; - text-align: center; - padding: 40px 0; - border-top: 1px solid #F0F0F0; - margin-top: 0; - margin-bottom: 0; -} - - -/* -------------------------------------------------------- - Pager ------------------------------------------------------------*/ -.pager li > a, .pager li > span { - padding: 5px 10px 6px; - color: @pagination-color; -} - diff --git a/client/app/assets/less/inc/popover.less b/client/app/assets/less/inc/popover.less index ed0c6da63e..5fcad7089b 100755 --- a/client/app/assets/less/inc/popover.less +++ b/client/app/assets/less/inc/popover.less @@ -1,5 +1,5 @@ .popover { - box-shadow: 0 2px 30px rgba(0, 0, 0, 0.2); + box-shadow: fade(@redash-gray, 25%) 0px 0px 15px 0px; } .popover-title { diff --git a/client/app/assets/less/inc/tab.less b/client/app/assets/less/inc/tab.less index 6ef14fceda..e31179f9b4 100755 --- a/client/app/assets/less/inc/tab.less +++ b/client/app/assets/less/inc/tab.less @@ -132,9 +132,15 @@ } .tab-nav { + margin-bottom: 0px; + > li.rd-tab-btn { float: right; padding-right: 10px; padding-top: 10px; } + + > li > a { + text-transform: capitalize; + } } diff --git a/client/app/assets/less/inc/table.less b/client/app/assets/less/inc/table.less index 18e2343ab4..7a43a6f9e6 100755 --- a/client/app/assets/less/inc/table.less +++ b/client/app/assets/less/inc/table.less @@ -1,26 +1,26 @@ .table { margin-bottom: 0; - + th.sortable-column { - cursor: pointer; + cursor: pointer; } - + &:not(.table-striped) > thead > tr > th { background-color: #FAFAFA; } - + [class*="bg-"] { & > tr > th { color: #fff; border-bottom: 0; background: transparent !important; } - + & + tbody > tr:first-child > td { border-top: 0; } } - + & > thead > tr > th { vertical-align: middle; font-weight: 500; @@ -29,24 +29,24 @@ text-transform: uppercase; padding: 15px 10px; } - + & > thead > tr, & > tbody > tr, & > tfoot > tr { - + & > th, & > td { - + &:first-child { padding-left: 30px; } - + &:last-child { padding-right: 30px; } - + } } - + tbody > tr:last-child > td { padding-bottom: 20px; } @@ -54,21 +54,21 @@ .table-bordered { border: 0; - + & > tbody > tr { & > td, & > th { border-bottom: 0; border-left: 0; - + &:last-child { border-right: 0; } } } - + & > thead > tr > th { border-left: 0; - + &:last-child { border-right: 0; } @@ -86,14 +86,64 @@ } .tile .table { - + & > thead:not([class*="bg-"]) > tr > th { border-top: 1px solid @table-border-color; - + } } .table-hover > tbody > tr:hover { - background-color: #f4f4f4; + background-color: #f4f4f4; +} + +.table-data { + tbody > tr > td { + padding-top: 5px !important; + } + + .btn-favourite, .btn-archive { + font-size: 15px; + } +} + +.table-main-title { + font-weight: 500; + line-height: 1.7 !important; +} + +.btn-favourite { + color: #d4d4d4; + transition: all .25s ease-in-out; + + &:hover, &:focus { + color: @yellow-darker; + cursor: pointer; + } + + .fa-star { + color: @yellow-darker; + } +} + +.btn-archive { + color: #d4d4d4; + transition: all .25s ease-in-out; + + &:hover, &:focus { + color: @gray-light; + } + + .fa-archive { + color: @gray-light; + } +} + +.table > thead > tr > th { + text-transform: none; } +.table-data .label-tag { + display: inline-block; + max-width: 135px; + } \ No newline at end of file diff --git a/client/app/assets/less/inc/tile.less b/client/app/assets/less/inc/tile.less index 932a9cce5e..d0a3617cee 100755 --- a/client/app/assets/less/inc/tile.less +++ b/client/app/assets/less/inc/tile.less @@ -2,7 +2,8 @@ background-color: #fff; margin-bottom: @grid-gutter-width; position: relative; - box-shadow: @tile-shadow; + border-radius: 3px; + box-shadow: fade(@redash-gray, 15%) 0px 4px 9px -3px; &[class*="bg-"] { color: #fff; @@ -12,6 +13,10 @@ margin-bottom: @grid-gutter-width/2; } } +.tiled { + border-radius: 3px; + box-shadow: fade(@redash-gray, 15%) 0px 4px 9px -3px; +} .t-header { .th-title { @@ -74,6 +79,15 @@ } } +.t-header:not(.th-alt) { + padding: 15px; + + ul { + margin-bottom: 0; + line-height: 2.2; + } + } + .tb-padding { padding: 20px 23px 30px; } diff --git a/client/app/assets/less/inc/variables.less b/client/app/assets/less/inc/variables.less index 3bd9a746dd..0b8468b30f 100755 --- a/client/app/assets/less/inc/variables.less +++ b/client/app/assets/less/inc/variables.less @@ -23,6 +23,8 @@ @logo-height: @header-height; @boxed-width: 1170px; @body-bg: #edecec; +@spacing: 15px; +@redash-radius: 3px; /* -------------------------------------------------------- @@ -39,6 +41,7 @@ -----------------------------------------------------------*/ @font-icon: 'Material-Design-Iconic-Font'; @font-family-sans-serif: 'Roboto', sans-serif; +@redash-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; @font-size-base: 13px; @@ -59,6 +62,7 @@ @input-border: #e8e8e8; @input-border-radius: 0; @input-border-radius-large: 0px; +@redash-input-radius: 2px; @input-height-large: 40px; @input-height-base: 35px; @input-height-small: 30px; @@ -94,6 +98,11 @@ @gray-light: #828282; @ace: #f8f8f8; +@redash-gray: rgba(102, 136, 153, 1); +@redash-orange: rgba(255, 120, 100, 1); +@redash-black: rgba(0, 0, 0, 1); +@redash-yellow: rgba(252, 252, 161, 0.75); + /** Form States **/ @state-success-text: @green; @state-info-text: @blue; @@ -192,7 +201,6 @@ @pagination-hover-color: #333; @pagination-hover-bg: #d7d7d7; @pagination-hover-border: @pagination-border; -@pager-border-radius: 5px; /* -------------------------------------------------------- diff --git a/client/app/assets/less/inc/visualizations/counter-render.less b/client/app/assets/less/inc/visualizations/counter-render.less deleted file mode 100755 index c57c297a98..0000000000 --- a/client/app/assets/less/inc/visualizations/counter-render.less +++ /dev/null @@ -1,45 +0,0 @@ -counter-renderer { - display: block; - text-align: center; - padding: 15px 10px; - overflow: hidden; - - counter { - margin: 0; - padding: 0; - font-size: 80px; - line-height: normal; - overflow: hidden; - display: flex; - align-items: center; - justify-content: center; - - value, - counter-target { - font-size: 1em; - display: block; - } - - counter-name { - font-size: 0.5em; - display: block; - } - - &.positive value { - color: #5cb85c; - } - - &.negative value { - color: #d9534f; - } - } - - counter-target { - color: #ccc; - } - - counter-name { - font-size: 0.5em; - display: block; - } -} diff --git a/client/app/assets/less/inc/visualizations/pivot-table.less b/client/app/assets/less/inc/visualizations/pivot-table.less index 7400914f47..6e41ffa0a1 100644 --- a/client/app/assets/less/inc/visualizations/pivot-table.less +++ b/client/app/assets/less/inc/visualizations/pivot-table.less @@ -1,3 +1,4 @@ -.pivot-table-renderer > table, grid-renderer > div, visualization-renderer > div { +.pivot-table-renderer > table, +visualization-renderer > .visualization-renderer-wrapper { overflow: auto; } diff --git a/client/app/assets/less/main.less b/client/app/assets/less/main.less index 4b7ef25beb..0f764f8144 100644 --- a/client/app/assets/less/main.less +++ b/client/app/assets/less/main.less @@ -32,7 +32,6 @@ @import 'inc/progress-bar'; @import 'inc/widgets'; @import 'inc/table'; -@import 'inc/pagination'; @import 'inc/alert'; @import 'inc/media'; @import 'inc/modal'; @@ -54,11 +53,9 @@ @import 'inc/schema-browser'; @import 'inc/toast'; @import 'inc/visualizations/box'; -@import 'inc/visualizations/counter-render'; @import 'inc/visualizations/sankey'; @import 'inc/visualizations/pivot-table'; @import 'inc/visualizations/map'; -@import 'inc/visualizations/chart'; @import 'inc/visualizations/sunburst'; @import 'inc/visualizations/cohort'; @import 'inc/visualizations/misc'; @@ -71,11 +68,11 @@ @import 'inc/vendor-overrides/ui-select'; /** REDASH STYLING **/ -@import 'redash/redash-newstyle'; @import 'redash/redash-table'; @import 'redash/query'; @import 'redash/tags-control'; @import 'redash/css-logo'; +@import 'redash/loading-indicator'; diff --git a/client/app/assets/less/redash/loading-indicator.less b/client/app/assets/less/redash/loading-indicator.less new file mode 100644 index 0000000000..ded814aecf --- /dev/null +++ b/client/app/assets/less/redash/loading-indicator.less @@ -0,0 +1,51 @@ +.loading-indicator { + position: fixed; + top: 50%; + left: 50%; + margin: -50px 0 0 -50px; // center + width: 100px; + height: 100px; + transition-duration: 150ms; + transition-timing-function: linear; + transition-property: opacity, transform; + + #css-logo { + animation: hover 2s infinite; + } + + #shadow { + width: 33px; + height: 12px; + border-radius: 50%; + background-color: black; + opacity: 0.25; + display: block; + position: absolute; + left: 34px; + top: 115px; + animation: shadow 2s infinite; + } + + @keyframes hover { + 50% { + transform: translateY(-5px); + } + } + @keyframes shadow { + 50% { + transform: scaleX(0.9); + opacity: 0.2; + } + } +} + +// hide indicator when app-view has content +app-view:not(:empty) ~ .loading-indicator { + opacity: 0; + transform: scale(0.9); + pointer-events: none; + + * { + animation: none !important; + } +} \ No newline at end of file diff --git a/client/app/assets/less/redash/query.less b/client/app/assets/less/redash/query.less index 5ef3b93699..621630ebd3 100644 --- a/client/app/assets/less/redash/query.less +++ b/client/app/assets/less/redash/query.less @@ -92,7 +92,7 @@ edit-in-place p.editable:hover { } .filter-container { - margin-bottom: 10px; + margin-bottom: 5px; } .ace_editor.ace_autocomplete .ace_completion-highlight { @@ -208,18 +208,18 @@ edit-in-place p.editable:hover { } } -.visualization-renderer { - .pagination, - .ant-pagination { - margin-top: 10px; - } -} - .embed__vis { display: flex; flex-flow: column; } +.embed-heading { + h3 { + line-height: 1.75; + margin: 0; + } +} + .widget-wrapper { .body-container { .filters-wrapper { @@ -343,7 +343,8 @@ a.label-tag { border-bottom: 1px solid #efefef; } - .pivot-table-renderer > table, grid-renderer > div, visualization-renderer > div { + .pivot-table-renderer > table, + visualization-renderer > .visualization-renderer-wrapper { overflow: visible; } @@ -676,8 +677,17 @@ nav .rg-bottom { .filter-container { padding-right: 0; } +} - .btn-edit-visualisation { +// Responsive fixes +@media (max-width: 767px) { + .query-page-wrapper { + h3 { + font-size: 18px; + } + favorites-control { + margin-top: -3px; + } } } diff --git a/client/app/assets/less/redash/redash-newstyle.less b/client/app/assets/less/redash/redash-newstyle.less deleted file mode 100644 index f82457a9d4..0000000000 --- a/client/app/assets/less/redash/redash-newstyle.less +++ /dev/null @@ -1,1098 +0,0 @@ -@import (reference, less) '~bootstrap/less/labels.less'; - -// Variables -@redash-gray: rgba(102, 136, 153, 1); -@redash-orange: rgba(255, 120, 100, 1); -@redash-black: rgba(0, 0, 0, 1); -@redash-yellow: rgba(252, 252, 161, 0.75); -@redash-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; - -@spacing: 15px; - -//Default spacing (between tiles) -@redash-space: 10px; - -@redash-radius: 3px; -@redash-input-radius: 2px; - -// General -body { - padding-top: 0; - background: #F6F8F9; - font-family: @redash-font; - - &.headless { - padding-top: 10px; - - .navbar { - display: none !important; - } - } -} - -.word-wrap-break { - word-wrap: break-word; -} - -.clearboth { - clear: both; -} - -.callout { - padding: 20px; - border: 1px solid #eee; - border-left-width: 5px; - border-radius: 3px; -} - -.callout-warning { - border-left-color: #aa6708; -} - -.callout-info { - border-left-color: #1b809e; -} - -// Fixed width layout for specific pages -@media (min-width: 768px) { - settings-screen, home-page, page-dashboard-list, page-queries-list, page-alerts-list, alert-page, queries-search-results-page, .fixed-container { - .container { - width: 750px; - } - } -} - -@media (min-width: 992px) { - settings-screen, home-page, page-dashboard-list, page-queries-list, page-alerts-list, alert-page, queries-search-results-page, .fixed-container { - .container { - width: 970px; - } - } -} - -@media (min-width: 1200px) { - settings-screen, home-page, page-dashboard-list, page-queries-list, page-alerts-list, alert-page, queries-search-results-page, .fixed-container { - .container { - width: 1170px; - } - } -} - -.creation-container { - h5 { - color: #a7a7a7; - } - - h3 { - margin: 0px; - margin-bottom: 15px; - } -} - -.add-widget-container { - background: #fff; - border-radius: @redash-radius; - padding: 15px; - position: fixed; - left: 15px; - bottom: 20px; - width: calc(~'100% - 30px'); - z-index: 99; - box-shadow: fade(@redash-gray, 50%) 0px 7px 29px -3px; - display: flex; - justify-content: space-between; - - h2 { - margin: 0; - font-size: 14px; - line-height: 2.1; - font-weight: 400; - - .zmdi { - margin: 0; - margin-right: 5px; - font-size: 24px; - position: absolute; - bottom: 18px; - } - - span { - padding-left: 30px; - } - } - - .btn { - align-self: center; - } -} - -body { - .ace-tm .ace_gutter { - background: #fff; - } - - .ace_editor { - border: 1px solid fade(@redash-gray, 15%); - } - - .ace-tm .ace_gutter-active-line { - background-color: fade(@redash-gray, 20%); - } - - .ace-tm .ace_marker-layer .ace_active-line { - background: fade(@redash-gray, 9%); - } -} - -.list-group-item.active, .list-group-item.active:hover, .list-group-item.active:focus { - background-color: #fff; - box-shadow: inset 3px 0px 0px @brand-primary; -} - -.table-data { - tbody > tr > td { - padding-top: 5px !important; - } - - .btn-favourite, .btn-archive { - font-size: 15px; - } -} - -.table-main-title { - font-weight: 500; - line-height: 1.7 !important; - a { - //font-size: 15px; - } -} - -.btn-favourite, .btn-archive { - color: #d4d4d4; - transition: all .25s ease-in-out; - - &:hover, &:focus { - color: @yellow-darker; - cursor: pointer; - } - - .fa-star { - color: @yellow-darker; - } -} - -.btn-archive { - color: #d4d4d4; - transition: all .25s ease-in-out; - - &:hover, &:focus { - color: @gray-light; - } - - .fa-archive { - color: @gray-light; - } -} - -.page-header--new .btn-favourite, .page-header--new .btn-archive { - font-size: 19px; -} - -.page-title { - display: flex; - align-items: center; - - h3 { - margin-right: 5px !important; - } - - .label { - margin-top: 3px; - display: inline-block; - } - - favorites-control { - margin-right: 5px; - } - - @media (max-width: 767px) { - display: block; - - favorites-control { - float: left; - } - - h3 { - width: 100%; - margin-bottom: 5px !important; - display: block !important; - } - } -} - -.navbar li a .btn-favourite .fa, .navbar li a .btn-archive .fa { - font-size: 100%; -} - -.float-right { - float: right; -} - -.visual-card-list { - margin: -5px 0 0 -5px; // compensate for .visual-card spacing -} - -.visual-card { - background: #FFFFFF; - border: 1px solid fade(@redash-gray, 15%); - border-radius: 3px; - margin: 5px; - width: 212px; - padding: 15px 5px; - cursor: pointer; - box-shadow: none; - transition: transform 0.12s ease-out; - transition-duration: 0.3s; - transition-property: box-shadow; - - display: flex; - align-items: center; - - &:hover { - box-shadow: rgba(102, 136, 153, 0.15) 0px 4px 9px -3px; - } - - img { - width: 64px !important; - height: 64px !important; - margin-right: 5px; - } - - h3 { - font-size: 13px; - color: #323232; - margin: 0 !important; - text-overflow: ellipsis; - overflow: hidden; - } -} - -@media (max-width: 1200px) { - .visual-card { - width: 217px; - } -} - -@media (max-width: 755px) { - .visual-card { - width: 47%; - } -} - -@media (max-width: 515px) { - .visual-card { - width: 47%; - - img { - width: 48px; - height: 48px; - } - } -} - -@media (max-width: 408px) { - .visual-card { - width: 100%; - padding: 5px; - - img { - width: 48px; - height: 48px; - } - } -} - - -.t-header:not(.th-alt) { - padding: 15px; - - ul { - margin-bottom: 0; - line-height: 2.2; - } -} - -.page-header-wrapper, .page-header--new { - h3 { - margin: 0.2em 0; - line-height: 1.3; - font-weight: 500; - } -} - -.alert { - padding: 15px; -} - -.dynamic-table__pagination { - margin-top: 10px; -} - -.rg-top span, .rg-bottom span { - height: 3px; - border-color: #b1c1ce; // TODO: variable -} - -.rg-bottom { - bottom: 15px; - - span { - margin: 1.5px 0 0 -10px; - } -} - -.popover { - box-shadow: fade(@redash-gray, 25%) 0px 0px 15px 0px; -} - -.tile__bottom-control a { - color: fade(@redash-black, 65%); - - &:hover { - color: fade(@redash-black, 95%); - } -} - -.pagination { - .disabled a { - background-color: fade(@redash-gray, 14%); - } - - li { - a { - background-color: fade(@redash-gray, 15%); - - &:hover { - background-color: fade(@redash-gray, 25%); - } - } - } -} - -.btn-default { - background-color: fade(@redash-gray, 15%); -} - -.btn-transparent { - background-color: transparent !important; -} - -.btn-default:hover, .btn-default:focus, .btn-default.focus, .btn-default:active, .btn-default.active, .open > .dropdown-toggle.btn-default { - background-color: fade(@redash-gray, 25%); -} - -.btn-default:active:hover, .btn-default.active:hover, .open > .dropdown-toggle.btn-default:hover, .btn-default:active:focus, .btn-default.active:focus, .open > .dropdown-toggle.btn-default:focus, .btn-default:active.focus, .btn-default.active.focus, .open > .dropdown-toggle.btn-default.focus { - color: #333; - background-color: fade(@redash-gray, 45%); -} - -.label { - border-radius: 2px; - padding: 3px 6px 4px; - font-weight: 500; - font-size: 11px; -} - -.label-default { - background: fade(@redash-gray, 85%); -} - -.label-tag-unpublished { - background: fade(@redash-gray, 85%); -} - -.label-tag-archived { - .label-warning(); -} - -.label-tag { - background: fade(@redash-gray, 10%); - color: fade(@redash-gray, 75%); -} - -.label-tag-unpublished, -.label-tag-archived, -.label-tag { - margin-right: 3px; - display: inline; - margin-top: 2px; - max-width: 24ch; - .text-overflow(); -} - -.tab-nav > li > a { - text-transform: capitalize; -} - -.table > thead > tr > th { - text-transform: none; -} - -.dashboard-header { - position: -webkit-sticky; // required for Safari - position: sticky; - background: #f6f7f9; - z-index: 99; - width: 100%; - top: 0; -} - -.dashboard__control { - margin: 8px 0; -} - -.editing-mode { - a.query-link { - pointer-events: none; - cursor: move; - } - - .th-title { - cursor: move; - } - - .refresh-indicator { - transition-duration: 0s; - - rd-timer { - display: none; - } - - .refresh-indicator-mini(); - } -} - -.dashboard-header { - position: -webkit-sticky; // required for Safari - position: sticky; - background: #f6f7f9; - z-index: 99; - width: 100%; - top: 0; -} - -.widget-wrapper { - .parameter-container { - margin: 0 15px; - } -} - -.bg-ace { - background-color: fade(@redash-gray, 12%) !important; -} - -.refresh-indicator { - font-size: 18px; - color: #86a1af; - transition: all 100ms linear; - transition-delay: 150ms; // waits for widget-menu to fade out before moving back over it - transform: translateX(22px); - position: absolute; - right: 29px; - top: 8px; - display: flex; - flex-direction: row-reverse; - - .refresh-icon { - position: relative; - - &:before { - content: ""; - position: absolute; - top: 0px; - right: 0; - width: 24px; - height: 24px; - background-color: #e8ecf0; - border-radius: 50%; - transition: opacity 100ms linear; - transition-delay: 150ms; - } - - i { - height: 24px; - width: 24px; - display: flex; - justify-content: center; - align-items: center; - } - } - - rd-timer { - font-size: 13px; - display: inline-block; - font-variant-numeric: tabular-nums; - opacity: 0; - transform: translateX(-6px); - transition: all 100ms linear; - transition-delay: 150ms; - color: #bbbbbb; - background-color: rgba(255,255,255,.9); - padding-left: 2px; - padding-right: 1px; - margin-right: -4px; - margin-top: 2px; - } - - .widget-visualization[data-refreshing="false"] & { - display: none; - } -} - -.refresh-indicator-mini() { - font-size: 13px; - transition-delay: 0s; - color: #bbbbbb; - transform: translateY(-4px); - - .refresh-icon:before { - transition-delay: 0s; - opacity: 0; - } - - rd-timer { - transition-delay: 0s; - opacity: 1; - transform: translateX(0); - } -} - -.refresh-button { - margin-left: -6px; -} - -.tiled { - border-radius: 3px; - box-shadow: fade(@redash-gray, 15%) 0px 4px 9px -3px; -} - -.tile { - border-radius: 3px; - box-shadow: fade(@redash-gray, 15%) 0px 4px 9px -3px; - - .widget-menu-regular, .btn__refresh { - opacity: 0 !important; - transition: opacity 0.35s ease-in-out; - } - - .t-header { - .th-title { - padding-right: 23px; // no overlap on RefreshIndicator - - a { - color: fade(@redash-black, 80%); - font-size: 15px; - font-weight: 500; - } - } - - .query--description { - font-size: 14px; - line-height: 1.5; - font-style: italic; - - p { - margin-bottom: 0; - } - } - } - - .t-header.widget { - padding: 15px; - } - - &:hover { - .widget-menu-regular, .btn__refresh { - opacity: 1 !important; - transition: opacity 0.35s ease-in-out; - } - - .refresh-indicator { - .refresh-indicator-mini(); - } - } - - .tile__bottom-control { - padding: 10px 15px; - line-height: 2; - } -} - -.embed-heading { - h3 { - line-height: 1.75; - margin: 0; - } -} - -.filter-container { - margin-bottom: 5px; -} - -// Navigation -.caret--nav { - border-top: none; -} - -.caret--nav:after { - content: ""; - position: absolute; - right: 5px; - top: 9px; - width: 13px; - height: 13px; - display: block; - background-image: url("data:image/svg+xml,%3C%3Fxml version='1.0' encoding='UTF-8'%3F%3E%3Csvg width='11px' height='6px' viewBox='0 0 11 6' version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'%3E%3C!-- Generator: Sketch 42 %2836781%29 - http://www.bohemiancoding.com/sketch --%3E%3Ctitle%3EShape%3C/title%3E%3Cdesc%3ECreated with Sketch.%3C/desc%3E%3Cdefs%3E%3C/defs%3E%3Cg id='Page-1' stroke='none' stroke-width='1' fill='none' fill-rule='evenodd'%3E%3Cpath d='M5.296,4.288 L9.382,0.2 C9.66086822,-0.0716916976 10.1065187,-0.068122925 10.381,0.208 C10.661,0.488 10.661,0.932 10.388,1.206 L5.792,5.803 C5.6602899,5.93388911 5.48167943,6.00662966 5.296,6.005 C5.10997499,6.00689786 4.93095449,5.93413702 4.799,5.803 L0.204,1.207 C0.072163111,1.07394937 -0.00121750401,0.893846387 9.62313189e-05,0.706545264 C0.00140996665,0.519244142 0.0773097323,0.340188219 0.211,0.209 C0.485365732,-0.0664648737 0.930253538,-0.0700311086 1.209,0.201 L5.296,4.288 L5.296,4.288 Z' id='Shape' fill='%23000000'%3E%3C/path%3E%3C/g%3E%3C/svg%3E"); - background-size: 100% 100%; - transition: transform .2s cubic-bezier(.75,0,.25,1); -} - -.navbar .caret--nav:after { - top: 19px; -} - -.dropdown--profile .caret--nav:after { - right: 8px; -} - -.btn--create { - padding-right: 20px; - - .caret--nav:after { - top: 10px; - right: 10px; - background-image: url("data:image/svg+xml,%3C%3Fxml version='1.0' encoding='UTF-8'%3F%3E%3Csvg width='11px' height='6px' viewBox='0 0 11 6' version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'%3E%3C!-- Generator: Sketch 42 %2836781%29 - http://www.bohemiancoding.com/sketch --%3E%3Ctitle%3EShape%3C/title%3E%3Cdesc%3ECreated with Sketch.%3C/desc%3E%3Cdefs%3E%3C/defs%3E%3Cg id='Page-1' stroke='none' stroke-width='1' fill='none' fill-rule='evenodd'%3E%3Cpath d='M5.29592111,4.28945339 L9.38192111,0.201453387 C9.66078932,-0.0702383105 10.1064398,-0.0666695379 10.3809211,0.209453387 C10.6609211,0.489453387 10.6609211,0.933453387 10.3879211,1.20745339 L5.79192111,5.80445339 C5.66021101,5.9353425 5.48160054,6.00808305 5.29592111,6.00645339 C5.1098961,6.00835125 4.9308756,5.9355904 4.79892111,5.80445339 L0.203921109,1.20845339 C0.0720842204,1.07540275 -0.00129639464,0.895299774 1.73406884e-05,0.707998651 C0.00133107602,0.520697529 0.0772308417,0.341641606 0.210921109,0.210453387 C0.485286842,-0.0650114866 0.930174648,-0.0685777215 1.20892111,0.202453387 L5.29592111,4.28945339 L5.29592111,4.28945339 Z' id='Shape' fill='%23FCFCFC'%3E%3C/path%3E%3C/g%3E%3C/svg%3E"); - } -} - -.dropdown.open .caret--nav:after { - transform: rotate(180deg); -} - -.collapsing, .collapse.in { - padding: 0; -} - -.navbar { - min-height: initial; - height: 50px; - border: 1px solid #fff; - border-top: none; - border-radius: 0; - background: #fff; - margin-bottom: 10px; - - .btn-group.open .dropdown-toggle { - -webkit-box-shadow: none; - box-shadow: none; - } - - .btn-group .btn:active { - box-shadow: none; - } -} - -.navbar-link-ANGULAR_REMOVE_ME { - line-height: 18px; - padding: 10px 15px; - display: block; - - @media (min-width: 768px) { - padding-top: 16px; - padding-bottom: 16px; - } -} - -.navbar-link-ANGULAR_REMOVE_ME, -.navbar-default .navbar-nav > li > a { - color: #000; - font-weight: 500; - - &:active, &:hover, &:focus { - color: #000; - } -} - -.navbar-default .btn__new button { - font-weight: 500; -} - -.navbar-default .navbar-nav > li > a:hover { - //background-color: fade(@redash-gray, 10%); - //text-decoration: underline; - //border-radius: 0; -} - -.navbar-default .navbar-nav > .open > a, .navbar-default .navbar-nav > .open > a:hover, .navbar-default .navbar-nav > .open > a:focus { - background-color: fade(@redash-gray, 15%); - color: #111; -} - -.dropdown-menu > li > a:hover, .dropdown-menu > li > a:focus { - background-color: fade(@redash-gray, 15%); - color: #111; -} - -.tab-nav { - margin-bottom: 0px; -} - -.profile__image--navbar { - border-radius: 100%; - margin-right: 3px; - margin-top: -2px; -} - -.profile__image--settings { - border-radius: 100%; -} - -.profile__image_thumb { - border-radius: 100%; - margin-right: 3px; - margin-top: -2px; - width: 20px; - height: 20px; -} - -.user_list__user--invitation-pending { - color: fade(@alert-danger-bg, 75%); - font-weight: 500; -} - -.btn__new { - margin-left: 15px; -} - -.navbar-btn { - margin-top: 10px; - margin-bottom: 9px; -} - -.navbar-brand { - position: absolute; - left: 50%; - margin-left: -25px !important; // center - display: block; - zoom: 0.9; -} - -.va-top { - vertical-align: top; -} - -.navbar { - box-shadow: fade(@redash-gray, 15%) 0px 4px 9px -3px; - - .navbar-collapse { - padding-left: 0; - } - - a.dropdown--profile { - padding-top: 10px; - padding-bottom: 10px; - line-height: 2.35; - } - - .navbar-inverse { - background-color: @redash-gray; - border: none; - } -} - -.menu-search { - margin-top: 2px; -} - -.tags-list { - .badge-light { - background: fade(@redash-gray, 10%); - color: fade(@redash-gray, 75%); - } - - a:hover { - cursor: pointer; - } -} - -.dropdown-menu--profile { - li { - width: 200px; - } -} - -.navbar .collapse.in { - background: #fff; - position: relative; - z-index: 999; - padding: 0 10px 0 10px; -} - -// Pagination -.pagination > li > a, .pagination > li > span { - border-radius: 3px !important; - width: 33px; - height: 33px; - line-height: 31px; -} - -// Error state -.error-state { - display: flex; - flex-direction: column; - justify-content: flex-start; - text-align: center; - margin-top: 25vh; - padding: 35px; - font-size: 14px; - line-height: 21px; - - .error-state__icon { - .zmdi { - font-size: 64px; - color: @redash-gray; - } - } - - @media (max-width: 767px) { - margin-top: 10vh; - } -} - -// Forms -.form-control { - border-radius: @redash-input-radius; - - &:focus { - box-shadow: none !important; - border-color: @blue; - } - - &:hover { - border-color: @blue; - } -} - -// Plotly -text.slicetext { - text-shadow: 1px 1px 5px #333; -} - - -// Responsive fixes -@media (max-width: 767px) { - .text-center-xs { - text-align: center !important; - } - - .query-page-wrapper { - h3 { - font-size: 18px; - } - - favorites-control { - margin-top: -3px; - } - } - - .navbar-brand { - left: 2%; - margin-left: 0 !important; - } - - //Fix navbar collapse - .navbar .collapse.in { - border: none; - - .dropdown-menu--profile { - li { - width: auto; - } - } - - .dropdown--profile { - .caret--nav:after { - right: initial !important; - } - } - - .dropdown--profile__username { - display: inline-block; - } - - .nav__main li a { - padding: 10px 15px; - display: block; - text-align: left; - float: none !important; - } - - .navbar-form { - margin-bottom: 0; - margin-top: 0; - } - - .navbar-right { - margin-bottom: 0; - } - } -} - -@media (min-width: 768px) { - @media (max-width: 880px) { - .navbar-link-ANGULAR_REMOVE_ME, - .navbar-default .navbar-nav > li > a, - .navbar-form { - padding-left: 10px !important; - padding-right: 10px !important; - } - - a.navbar-brand { - margin-left: -15px !important; - } - } - - @media (max-width: 810px) { - .menu-search { - width: 175px; - } - - a.navbar-brand { - margin-left: 13px !important; - } - } -} - -@media (max-width: 1084px) { - .dropdown--profile__username { - display: none; - } -} - - - -// Cross-browser fixes - -// Firefox -@-moz-document url-prefix() { - .caret--nav::after { - height: 7px; - } - - .navbar .caret--nav::after { - top: 22px; - } - - .navbar .btn--create .caret--nav::after { - top: 12px; - } -} - -// IE10+ -@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) { - .caret--nav::after { - height: 7px; - } - - .navbar .caret--nav::after { - top: 22px; - } - - .navbar .btn--create .caret--nav::after { - top: 12px; - } -} - -.ui-select-choices-row.disabled > span { - background-color: inherit !important; -} - -.list-group-item.inactive, -.ui-select-choices-row.disabled { - background-color: #eee !important; - border-color: transparent; - opacity: 0.5; - box-shadow: none; - color: #333; - pointer-events: none; - cursor: not-allowed; -} - -.select-option-divider { - margin: 10px 0 !important; -} - -.table-data .label-tag { - display: inline-block; - max-width: 135px; -} - -.markdown strong { - font-weight: bold; -} - -.markdown img { - max-width: 100%; -} - -.loading-indicator { - position: fixed; - top: 50%; - left: 50%; - margin: -50px 0 0 -50px; // center - width: 100px; - height: 100px; - transition-duration: 150ms; - transition-timing-function: linear; - transition-property: opacity, transform; - - #css-logo { - animation: hover 2s infinite; - } - - #shadow { - width: 33px; - height: 12px; - border-radius: 50%; - background-color: black; - opacity: 0.25; - display: block; - position: absolute; - left: 34px; - top: 115px; - animation: shadow 2s infinite; - } - - @keyframes hover { - 50% { - transform: translateY(-5px); - } - } - @keyframes shadow { - 50% { - transform: scaleX(0.9); - opacity: 0.2; - } - } -} - -// hide indicator when app-view has content -app-view:not(:empty) ~ .loading-indicator { - opacity: 0; - transform: scale(0.9); - pointer-events: none; - - * { - animation: none !important; - } -} \ No newline at end of file diff --git a/client/app/assets/less/server.less b/client/app/assets/less/server.less index 83c63db677..d275273f0e 100644 --- a/client/app/assets/less/server.less +++ b/client/app/assets/less/server.less @@ -19,8 +19,6 @@ @import 'inc/ie-warning'; @import 'inc/flex'; -@import 'redash/redash-newstyle'; - html, body { height: 100%; margin: 0; diff --git a/client/app/components/BeaconConsent.jsx b/client/app/components/BeaconConsent.jsx new file mode 100644 index 0000000000..e551eb6a0f --- /dev/null +++ b/client/app/components/BeaconConsent.jsx @@ -0,0 +1,83 @@ +import React, { useState } from 'react'; +import { react2angular } from 'react2angular'; +import Card from 'antd/lib/card'; +import Button from 'antd/lib/button'; +import Typography from 'antd/lib/typography'; +import { clientConfig } from '@/services/auth'; +import { HelpTrigger } from '@/components/HelpTrigger'; +import DynamicComponent from '@/components/DynamicComponent'; +import OrgSettings from '@/services/organizationSettings'; + +const Text = Typography.Text; + +export function BeaconConsent() { + const [hide, setHide] = useState(false); + + if (!clientConfig.showBeaconConsentMessage || hide) { + return null; + } + + const hideConsentCard = () => { + clientConfig.showBeaconConsentMessage = false; + setHide(true); + }; + + const confirmConsent = (confirm) => { + let message = '🙏 Thank you.'; + + if (!confirm) { + message = 'Settings Saved.'; + } + + OrgSettings.save({ beacon_consent: confirm }, message) + // .then(() => { + // // const settings = get(response, 'settings'); + // // this.setState({ settings, formValues: { ...settings } }); + // }) + .finally(hideConsentCard); + }; + + return ( + +
+ + Would you be ok with sharing anonymous usage data with the Redash team?{' '} + + + )} + bordered={false} + > + Help Redash improve by automatically sending anonymous usage data: +
+
    +
  • Number of users, queries, dashboards, alerts, widgets and visualizations.
  • +
  • Types of data sources, alert destinations and visualizations.
  • +
+
+ All data is aggregated and will never include any sensitive or private data. +
+ + +
+
+ + You can change this setting anytime from the Organization Settings page. + +
+
+
+
+ ); +} + +export default function init(ngModule) { + ngModule.component('beaconConsent', react2angular(BeaconConsent)); +} + +init.init = true; diff --git a/client/app/components/ColorBox.jsx b/client/app/components/ColorBox.jsx index 73b3f3681e..dd83c3f49a 100644 --- a/client/app/components/ColorBox.jsx +++ b/client/app/components/ColorBox.jsx @@ -1,23 +1,12 @@ -import React from 'react'; -import PropTypes from 'prop-types'; +// ANGULAR_REMOVE_ME import { react2angular } from 'react2angular'; -import './color-box.less'; - -export function ColorBox({ color }) { - return ; -} +import ColorPicker from '@/components/ColorPicker'; -ColorBox.propTypes = { - color: PropTypes.string, -}; - -ColorBox.defaultProps = { - color: 'transparent', -}; +import './color-box.less'; export default function init(ngModule) { - ngModule.component('colorBox', react2angular(ColorBox)); + ngModule.component('colorBox', react2angular(ColorPicker.Swatch)); } init.init = true; diff --git a/client/app/components/ColorPicker/Input.jsx b/client/app/components/ColorPicker/Input.jsx new file mode 100644 index 0000000000..89af2ec5af --- /dev/null +++ b/client/app/components/ColorPicker/Input.jsx @@ -0,0 +1,93 @@ +import { isNil, isArray, chunk, map, filter, toPairs } from 'lodash'; +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import tinycolor from 'tinycolor2'; +import TextInput from 'antd/lib/input'; +import Typography from 'antd/lib/typography'; +import Swatch from './Swatch'; + +import './input.less'; + +function preparePresets(presetColors, presetColumns) { + presetColors = isArray(presetColors) ? map(presetColors, v => [null, v]) : toPairs(presetColors); + presetColors = map(presetColors, ([title, value]) => { + if (isNil(value)) { + return [title, null]; + } + value = tinycolor(value); + if (value.isValid()) { + return [title, '#' + value.toHex().toUpperCase()]; + } + return null; + }); + return chunk(filter(presetColors), presetColumns); +} + +function validateColor(value, callback, prefix = '#') { + if (isNil(value)) { + callback(null); + } + value = tinycolor(value); + if (value.isValid()) { + callback(prefix + value.toHex().toUpperCase()); + } +} + +export default function Input({ color, presetColors, presetColumns, onChange, onPressEnter }) { + const [inputValue, setInputValue] = useState(''); + const [isInputFocused, setIsInputFocused] = useState(false); + + const presets = preparePresets(presetColors, presetColumns); + + function handleInputChange(value) { + setInputValue(value); + validateColor(value, onChange); + } + + useEffect(() => { + if (!isInputFocused) { + validateColor(color, setInputValue, ''); + } + }, [color, isInputFocused]); + + return ( + + {map(presets, (group, index) => ( +
+ {map(group, ([title, value]) => ( + validateColor(value, onChange)} /> + ))} +
+ ))} +
+ #} + value={inputValue} + onChange={e => handleInputChange(e.target.value)} + onFocus={() => setIsInputFocused(true)} + onBlur={() => setIsInputFocused(false)} + onPressEnter={onPressEnter} + /> +
+
+ ); +} + +Input.propTypes = { + color: PropTypes.string, + presetColors: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.string), // array of colors (no tooltips) + PropTypes.objectOf(PropTypes.string), // color name => color value + ]), + presetColumns: PropTypes.number, + onChange: PropTypes.func, + onPressEnter: PropTypes.func, +}; + +Input.defaultProps = { + color: '#FFFFFF', + presetColors: null, + presetColumns: 8, + onChange: () => {}, + onPressEnter: () => {}, +}; diff --git a/client/app/components/ColorPicker/Swatch.jsx b/client/app/components/ColorPicker/Swatch.jsx new file mode 100644 index 0000000000..f0b510b612 --- /dev/null +++ b/client/app/components/ColorPicker/Swatch.jsx @@ -0,0 +1,37 @@ +import { isString } from 'lodash'; +import React from 'react'; +import PropTypes from 'prop-types'; +import Tooltip from 'antd/lib/tooltip'; + +import './swatch.less'; + +export default function Swatch({ className, color, title, size, ...props }) { + const result = ( + + ); + + if (isString(title) && (title !== '')) { + return ( + {result} + ); + } + return result; +} + +Swatch.propTypes = { + className: PropTypes.string, + title: PropTypes.string, + color: PropTypes.string, + size: PropTypes.number, +}; + +Swatch.defaultProps = { + className: '', + title: null, + color: 'transparent', + size: 12, +}; diff --git a/client/app/components/ColorPicker/index.jsx b/client/app/components/ColorPicker/index.jsx new file mode 100644 index 0000000000..946c3a5c33 --- /dev/null +++ b/client/app/components/ColorPicker/index.jsx @@ -0,0 +1,128 @@ +import { toString } from 'lodash'; +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import tinycolor from 'tinycolor2'; +import Popover from 'antd/lib/popover'; +import Card from 'antd/lib/card'; +import Tooltip from 'antd/lib/tooltip'; +import Icon from 'antd/lib/icon'; + +import ColorInput from './Input'; +import Swatch from './Swatch'; + +import './index.less'; + +function validateColor(value, fallback = null) { + value = tinycolor(value); + return value.isValid() ? '#' + value.toHex().toUpperCase() : fallback; +} + +export default function ColorPicker({ + color, placement, presetColors, presetColumns, triggerSize, interactive, children, onChange, +}) { + const [visible, setVisible] = useState(false); + const [currentColor, setCurrentColor] = useState(''); + + function handleApply() { + setVisible(false); + if (!interactive) { + onChange(currentColor); + } + } + + function handleCancel() { + setVisible(false); + } + + const actions = []; + if (!interactive) { + actions.push(( + + + + )); + actions.push(( + + + + )); + } + + function handleInputChange(newColor) { + setCurrentColor(newColor); + if (interactive) { + onChange(newColor); + } + } + + useEffect(() => { + if (visible) { + setCurrentColor(validateColor(color)); + } + }, [color, visible]); + + return ( + + + + )} + trigger="click" + placement={placement} + visible={visible} + onVisibleChange={setVisible} + > + {children || ()} + + ); +} + +ColorPicker.propTypes = { + color: PropTypes.string, + placement: PropTypes.oneOf([ + 'top', 'left', 'right', 'bottom', + 'topLeft', 'topRight', 'bottomLeft', 'bottomRight', + 'leftTop', 'leftBottom', 'rightTop', 'rightBottom', + ]), + presetColors: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.string), // array of colors (no tooltips) + PropTypes.objectOf(PropTypes.string), // color name => color value + ]), + presetColumns: PropTypes.number, + triggerSize: PropTypes.number, + interactive: PropTypes.bool, + children: PropTypes.node, + onChange: PropTypes.func, +}; + +ColorPicker.defaultProps = { + color: '#FFFFFF', + placement: 'top', + presetColors: null, + presetColumns: 8, + triggerSize: 30, + interactive: false, + children: null, + onChange: () => {}, +}; + +ColorPicker.Input = ColorInput; +ColorPicker.Swatch = Swatch; diff --git a/client/app/components/ColorPicker/index.less b/client/app/components/ColorPicker/index.less new file mode 100644 index 0000000000..00bf5768e2 --- /dev/null +++ b/client/app/components/ColorPicker/index.less @@ -0,0 +1,40 @@ +.color-picker { + &.color-picker-with-actions { + &.ant-popover-placement-top, + &.ant-popover-placement-topLeft, + &.ant-popover-placement-topRight, + &.ant-popover-placement-leftBottom, + &.ant-popover-placement-rightBottom { + > .ant-popover-content > .ant-popover-arrow { + border-color: #fafafa; // same as card actions + } + } + } + + &.ant-popover-placement-bottom, + &.ant-popover-placement-bottomLeft, + &.ant-popover-placement-bottomRight, + &.ant-popover-placement-leftTop, + &.ant-popover-placement-rightTop { + > .ant-popover-content > .ant-popover-arrow { + border-color: var(--color-picker-selected-color); + } + } + + .ant-popover-inner-content { + padding: 0; + } + + .ant-card-head { + text-align: center; + border-bottom-color: rgba(0, 0, 0, 0.1); + } + + .ant-card-body { + padding: 10px; + } +} + +.color-picker-trigger { + cursor: pointer; +} diff --git a/client/app/components/ColorPicker/input.less b/client/app/components/ColorPicker/input.less new file mode 100644 index 0000000000..56f9d7ec58 --- /dev/null +++ b/client/app/components/ColorPicker/input.less @@ -0,0 +1,19 @@ +.color-picker-input-swatches { + margin: 0 0 10px 0; + text-align: left; + white-space: nowrap; + + .color-swatch { + cursor: pointer; + margin: 0 10px 0 0; + + &:last-child { + margin-right: 0; + } + } +} + +.color-picker-input { + text-align: left; + white-space: nowrap; +} diff --git a/client/app/components/ColorPicker/swatch.less b/client/app/components/ColorPicker/swatch.less new file mode 100644 index 0000000000..4dea312c44 --- /dev/null +++ b/client/app/components/ColorPicker/swatch.less @@ -0,0 +1,30 @@ +.color-swatch { + display: inline-block; + box-sizing: border-box; + vertical-align: middle; + border-radius: 2px; + overflow: hidden; + width: 12px; + + @cell-size: 12px; + @cell-color: rgba(0, 0, 0, 0.1); + + background-color: transparent; + background-image: + linear-gradient(45deg, @cell-color 25%, transparent 25%), + linear-gradient(-45deg, @cell-color 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, @cell-color 75%), + linear-gradient(-45deg, transparent 75%, @cell-color 75%); + background-size: @cell-size @cell-size; + background-position: 0 0, 0 @cell-size/2, @cell-size/2 -@cell-size/2, -@cell-size/2 0px; + + &:before { + content: ""; + display: block; + padding-top: ~"calc(100% - 2px)"; + background-color: inherit; + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 2px; + overflow: hidden; + } +} diff --git a/client/app/components/EditParameterSettingsDialog.jsx b/client/app/components/EditParameterSettingsDialog.jsx index 7db1ee3003..fbae2eb71d 100644 --- a/client/app/components/EditParameterSettingsDialog.jsx +++ b/client/app/components/EditParameterSettingsDialog.jsx @@ -153,6 +153,7 @@ function EditParameterSettingsDialog(props) { setParam({ ...param, title: e.target.value })} + data-test="ParameterTitleInput" /> @@ -176,7 +177,7 @@ function EditParameterSettingsDialog(props) { {param.type === 'enum' && ( - + @@ -28,11 +27,6 @@ function ParameterApplyButton({ paramCount, onClick, isApplying }) { ParameterApplyButton.propTypes = { onClick: PropTypes.func.isRequired, paramCount: PropTypes.number.isRequired, - isApplying: PropTypes.bool.isRequired, }; -export default function init(ngModule) { - ngModule.component('parameterApplyButton', react2angular(ParameterApplyButton)); -} - -init.init = true; +export default ParameterApplyButton; diff --git a/client/app/components/ParameterMappingInput.jsx b/client/app/components/ParameterMappingInput.jsx index fe8e82b901..6eb1d41ff2 100644 --- a/client/app/components/ParameterMappingInput.jsx +++ b/client/app/components/ParameterMappingInput.jsx @@ -14,7 +14,7 @@ import Input from 'antd/lib/input'; import Radio from 'antd/lib/radio'; import Form from 'antd/lib/form'; import Tooltip from 'antd/lib/tooltip'; -import { ParameterValueInput } from '@/components/ParameterValueInput'; +import ParameterValueInput from '@/components/ParameterValueInput'; import { ParameterMappingType } from '@/services/widget'; import { Parameter } from '@/services/query'; import { HelpTrigger } from '@/components/HelpTrigger'; diff --git a/client/app/components/ParameterValueInput.jsx b/client/app/components/ParameterValueInput.jsx index 1cc7ec3ce1..fa5b411091 100644 --- a/client/app/components/ParameterValueInput.jsx +++ b/client/app/components/ParameterValueInput.jsx @@ -1,6 +1,5 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { react2angular } from 'react2angular'; import Select from 'antd/lib/select'; import Input from 'antd/lib/input'; import InputNumber from 'antd/lib/input-number'; @@ -19,7 +18,7 @@ const multipleValuesProps = { maxTagPlaceholder: num => `+${num.length} more`, }; -export class ParameterValueInput extends React.Component { +class ParameterValueInput extends React.Component { static propTypes = { type: PropTypes.string, value: PropTypes.any, // eslint-disable-line react/forbid-prop-types @@ -108,7 +107,6 @@ export class ParameterValueInput extends React.Component { value={value} onChange={this.onSelect} dropdownMatchSelectWidth={false} - dropdownClassName="ant-dropdown-in-bootstrap-modal" showSearch showArrow style={{ minWidth: 60 }} @@ -142,7 +140,7 @@ export class ParameterValueInput extends React.Component { const { className } = this.props; const { value } = this.state; - const normalize = val => !isNaN(val) && val || 0; + const normalize = val => (isNaN(val) ? undefined : val); return ( - `, - bindings: { - param: '<', - }, - controller($scope) { - this.setValue = (value, isDirty) => { - if (isDirty) { - this.param.setPendingValue(value); - } else { - this.param.clearPendingValue(); - } - $scope.$apply(); - }; - }, - }); - ngModule.component('parameterValueInputImpl', react2angular(ParameterValueInput)); -} - -init.init = true; +export default ParameterValueInput; diff --git a/client/app/components/ParameterValueInput.less b/client/app/components/ParameterValueInput.less index fb99a9542f..9921c74a94 100644 --- a/client/app/components/ParameterValueInput.less +++ b/client/app/components/ParameterValueInput.less @@ -5,9 +5,15 @@ .parameter-input { display: inline-block; position: relative; + width: 100%; - .@{ant-prefix}-input[type="text"] { - width: 195px; + .@{ant-prefix}-input, + .@{ant-prefix}-input-number { + min-width: 100% !important; + } + + .@{ant-prefix}-select { + width: 100%; } &[data-dirty] { @@ -18,65 +24,3 @@ } } } - -.parameter-container { - position: relative; - - .parameter-apply-button { - display: none; // default for mobile - - // "floating" on desktop - @media (min-width: 768px) { - position: absolute; - bottom: -42px; - left: -15px; - border-radius: 2px; - z-index: 1; - transition: opacity 150ms ease-out; - box-shadow: 0 4px 9px -3px rgba(102, 136, 153, 0.15); - background-color: #ffffff; - padding: 4px; - padding-left: 16px; - opacity: 0; - display: block; - pointer-events: none; // so tooltip doesn't remain after button hides - } - - &[data-show="true"] { - opacity: 1; - display: block; - pointer-events: auto; - } - - button { - padding: 0 8px 0 6px; - color: #2096f3; - border-color: #50acf6; - - // smaller on desktop - @media (min-width: 768px) { - font-size: 12px; - height: 27px; - } - - &:hover, &:focus, &:active { - background-color: #eef7fe; - } - - i { - margin-right: 3px; - } - } - - .ant-badge-count { - min-width: 15px; - height: 15px; - padding: 0 5px; - font-size: 10px; - line-height: 15px; - background: #f77b74; - border-radius: 7px; - box-shadow: 0px 0px 0 1px white, -1px 1px 0 1px #5d6f7d85; - } - } -} diff --git a/client/app/components/Parameters.jsx b/client/app/components/Parameters.jsx new file mode 100644 index 0000000000..285b6a6237 --- /dev/null +++ b/client/app/components/Parameters.jsx @@ -0,0 +1,208 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { size, filter, forEach, extend } from 'lodash'; +import { react2angular } from 'react2angular'; +import { sortableContainer, sortableElement, sortableHandle } from 'react-sortable-hoc'; +import { $location } from '@/services/ng'; +import { Parameter } from '@/services/query'; +import ParameterApplyButton from '@/components/ParameterApplyButton'; +import ParameterValueInput from '@/components/ParameterValueInput'; +import EditParameterSettingsDialog from './EditParameterSettingsDialog'; +import { toHuman } from '@/filters'; + +import './Parameters.less'; + +const DragHandle = sortableHandle(({ parameterName }) => ( +
+)); + +const SortableItem = sortableElement(({ className, parameterName, disabled, children }) => ( +
+ {!disabled && } + {children} +
+)); +const SortableContainer = sortableContainer(({ children }) => children); + +function updateUrl(parameters) { + const params = extend({}, $location.search()); + parameters.forEach((param) => { + extend(params, param.toUrlParams()); + }); + Object.keys(params).forEach(key => params[key] == null && delete params[key]); + $location.search(params); +} + +export class Parameters extends React.Component { + static propTypes = { + parameters: PropTypes.arrayOf(PropTypes.instanceOf(Parameter)), + editable: PropTypes.bool, + disableUrlUpdate: PropTypes.bool, + onValuesChange: PropTypes.func, + onPendingValuesChange: PropTypes.func, + onParametersEdit: PropTypes.func, + }; + + static defaultProps = { + parameters: [], + editable: false, + disableUrlUpdate: false, + onValuesChange: () => {}, + onPendingValuesChange: () => {}, + onParametersEdit: () => {}, + } + + constructor(props) { + super(props); + const { parameters } = props; + this.state = { parameters, dragging: false }; + if (!props.disableUrlUpdate) { + updateUrl(parameters); + } + } + + componentDidUpdate = (prevProps) => { + const { parameters, disableUrlUpdate } = this.props; + if (prevProps.parameters !== parameters) { + this.setState({ parameters }); + if (!disableUrlUpdate) { + updateUrl(parameters); + } + } + }; + + handleKeyDown = (e) => { + // Cmd/Ctrl/Alt + Enter + if (e.keyCode === 13 && (e.ctrlKey || e.metaKey || e.altKey)) { + e.stopPropagation(); + this.applyChanges(); + } + }; + + setPendingValue = (param, value, isDirty) => { + const { onPendingValuesChange } = this.props; + this.setState(({ parameters }) => { + if (isDirty) { + param.setPendingValue(value); + } else { + param.clearPendingValue(); + } + onPendingValuesChange(); + return { parameters }; + }); + }; + + moveParameter = ({ oldIndex, newIndex }) => { + const { onParametersEdit } = this.props; + if (oldIndex !== newIndex) { + this.setState(({ parameters }) => { + parameters.splice(newIndex, 0, parameters.splice(oldIndex, 1)[0]); + onParametersEdit(); + return { parameters }; + }); + } + this.setState({ dragging: false }); + }; + + onBeforeSortStart = () => { + this.setState({ dragging: true }); + }; + + applyChanges = () => { + const { onValuesChange, disableUrlUpdate } = this.props; + this.setState(({ parameters }) => { + const parametersWithPendingValues = parameters.filter(p => p.hasPendingValue); + forEach(parameters, p => p.applyPendingValue()); + onValuesChange(parametersWithPendingValues); + if (!disableUrlUpdate) { + updateUrl(parameters); + } + return { parameters }; + }); + }; + + showParameterSettings = (parameter, index) => { + const { onParametersEdit } = this.props; + EditParameterSettingsDialog + .showModal({ parameter }) + .result.then((updated) => { + this.setState(({ parameters }) => { + const updatedParameter = extend(parameter, updated); + parameters[index] = new Parameter(updatedParameter, updatedParameter.parentQueryId); + onParametersEdit(); + return { parameters }; + }); + }); + }; + + renderParameter(param, index) { + const { editable } = this.props; + return ( +
+
+ + {editable && ( + + )} +
+ this.setPendingValue(param, value, isDirty)} + /> +
+ ); + } + + render() { + const { parameters, dragging } = this.state; + const { editable } = this.props; + const dirtyParamCount = size(filter(parameters, 'hasPendingValue')); + return ( + +
+ {parameters.map((param, index) => ( + + {this.renderParameter(param, index)} + + ))} + + +
+
+ ); + } +} + +export default function init(ngModule) { + ngModule.component('parameters', react2angular(Parameters)); +} + +init.init = true; diff --git a/client/app/components/Parameters.less b/client/app/components/Parameters.less new file mode 100644 index 0000000000..304c1a8f86 --- /dev/null +++ b/client/app/components/Parameters.less @@ -0,0 +1,124 @@ +@import '../assets/less/ant'; + +.drag-handle { + background: linear-gradient(90deg, transparent 0px, white 1px, white 2px) + center, + linear-gradient(transparent 0px, white 1px, white 2px) center, #111111; + background-size: 2px 2px; + display: inline-block; + width: 6px; + height: 36px; + vertical-align: bottom; + margin-right: 5px; + cursor: move; +} + +.parameter-block { + display: inline-block; + background: white; + padding: 0 12px 6px 0; + vertical-align: top; + + .parameter-container[data-draggable] & { + margin: 4px 0 0 4px; + padding: 3px 6px 6px; + } + + &.parameter-dragged { + box-shadow: 0 4px 9px -3px rgba(102, 136, 153, 0.15); + width: auto !important; + } +} + +.parameter-heading { + display: flex; + align-items: center; + padding-bottom: 4px; + + label { + margin-bottom: 1px; + overflow: hidden; + text-overflow: ellipsis; + min-width: 100%; + max-width: 195px; + white-space: nowrap; + + .parameter-block[data-editable] & { + min-width: calc(100% - 27px); // make room for settings button + max-width: 195px - 27px; + } + } +} + +.parameter-container { + position: relative; + + &[data-draggable] { + padding: 0 4px 4px 0; + transition: background-color 200ms ease-out; + transition-delay: 300ms; // short pause before returning to original bgcolor + } + + &[data-dragging] { + transition-delay: 0s; + background-color: #f6f8f9; + } + + .parameter-apply-button { + display: none; // default for mobile + + // "floating" on desktop + @media (min-width: 768px) { + position: absolute; + bottom: -36px; + left: -15px; + border-radius: 2px; + z-index: 1; + transition: opacity 150ms ease-out; + box-shadow: 0 4px 9px -3px rgba(102, 136, 153, 0.15); + background-color: #ffffff; + padding: 4px; + padding-left: 16px; + opacity: 0; + display: block; + pointer-events: none; // so tooltip doesn't remain after button hides + } + + &[data-show="true"] { + opacity: 1; + display: block; + pointer-events: auto; + } + + button { + padding: 0 8px 0 6px; + color: #2096f3; + border-color: #50acf6; + + // smaller on desktop + @media (min-width: 768px) { + font-size: 12px; + height: 27px; + } + + &:hover, &:focus, &:active { + background-color: #eef7fe; + } + + i { + margin-right: 3px; + } + } + + .ant-badge-count { + min-width: 15px; + height: 15px; + padding: 0 5px; + font-size: 10px; + line-height: 15px; + background: #f77b74; + border-radius: 7px; + box-shadow: 0px 0px 0 1px white, -1px 1px 0 1px #5d6f7d85; + } + } +} diff --git a/client/app/components/QueryBasedParameterInput.jsx b/client/app/components/QueryBasedParameterInput.jsx index 7160e12be8..d55f5aa2b6 100644 --- a/client/app/components/QueryBasedParameterInput.jsx +++ b/client/app/components/QueryBasedParameterInput.jsx @@ -81,7 +81,6 @@ export class QueryBasedParameterInput extends React.Component { value={isArray(value) ? value : toString(value)} onChange={onSelect} dropdownMatchSelectWidth={false} - dropdownClassName="ant-dropdown-in-bootstrap-modal" optionFilterProp="children" showSearch showArrow diff --git a/client/app/components/cards-list/CardsList.jsx b/client/app/components/cards-list/CardsList.jsx index 21df7c3e22..94ce09e229 100644 --- a/client/app/components/cards-list/CardsList.jsx +++ b/client/app/components/cards-list/CardsList.jsx @@ -4,6 +4,8 @@ import PropTypes from 'prop-types'; import React from 'react'; import EmptyState from '@/components/items-list/components/EmptyState'; +import './CardsList.less'; + const { Search } = Input; export default class CardsList extends React.Component { diff --git a/client/app/components/cards-list/CardsList.less b/client/app/components/cards-list/CardsList.less new file mode 100644 index 0000000000..ef9fbb1abb --- /dev/null +++ b/client/app/components/cards-list/CardsList.less @@ -0,0 +1,76 @@ + +@import '../../assets/less/inc/variables'; + +.visual-card-list { + margin: -5px 0 0 -5px; // compensate for .visual-card spacing +} + +.visual-card { + background: #FFFFFF; + border: 1px solid fade(@redash-gray, 15%); + border-radius: 3px; + margin: 5px; + width: 212px; + padding: 15px 5px; + cursor: pointer; + box-shadow: none; + transition: transform 0.12s ease-out; + transition-duration: 0.3s; + transition-property: box-shadow; + + display: flex; + align-items: center; + + &:hover { + box-shadow: rgba(102, 136, 153, 0.15) 0px 4px 9px -3px; + } + + img { + width: 64px !important; + height: 64px !important; + margin-right: 5px; + } + + h3 { + font-size: 13px; + color: #323232; + margin: 0 !important; + text-overflow: ellipsis; + overflow: hidden; + } +} + +@media (max-width: 1200px) { + .visual-card { + width: 217px; + } +} + +@media (max-width: 755px) { + .visual-card { + width: 47%; + } +} + +@media (max-width: 515px) { + .visual-card { + width: 47%; + + img { + width: 48px; + height: 48px; + } + } +} + +@media (max-width: 408px) { + .visual-card { + width: 100%; + padding: 5px; + + img { + width: 48px; + height: 48px; + } + } +} \ No newline at end of file diff --git a/client/app/components/color-box.less b/client/app/components/color-box.less index 7a23fd1308..d1bac3afbf 100644 --- a/client/app/components/color-box.less +++ b/client/app/components/color-box.less @@ -1,19 +1,10 @@ -@import '../assets/less/inc/variables'; - +// ANGULAR_REMOVE_ME color-box { vertical-align: text-bottom; - display: inline; + display: inline-block; + margin-right: 5px; - span { - width: 12px !important; - height: 12px !important; - display: inline-block !important; - margin-right: 5px; - vertical-align: middle; - border: 1px solid rgba(0,0,0,0.1); - } - & ~ span { + & ~ span { vertical-align: bottom; - color: @input-color; - } + } } diff --git a/client/app/components/dashboards/widget-dialog.html b/client/app/components/dashboards/widget-dialog.html index 0802a81411..32404bbd8b 100644 --- a/client/app/components/dashboards/widget-dialog.html +++ b/client/app/components/dashboards/widget-dialog.html @@ -5,7 +5,7 @@
- +
@@ -56,6 +56,7 @@ visualization="$ctrl.widget.visualization" query-result="$ctrl.widget.getQueryResult()" filters="$ctrl.filters" + context="'widget'" >
@@ -64,22 +65,26 @@
-
- - - - - - - - - {{$ctrl.widget.getQueryResult().getUpdatedAt() | dateTime}} +
+ + + + + + + + + + {{$ctrl.widget.getQueryResult().getUpdatedAt() | dateTime}} + - - + + + +
diff --git a/client/app/components/dashboards/widget.js b/client/app/components/dashboards/widget.js index 95b9d6380d..b51d427f91 100644 --- a/client/app/components/dashboards/widget.js +++ b/client/app/components/dashboards/widget.js @@ -92,6 +92,8 @@ function DashboardWidgetCtrl($scope, $location, $uibModal, $window, $rootScope, return this.widget.load(refresh, maxAge); }; + this.forceRefresh = () => this.load(true); + this.refresh = (buttonId) => { this.refreshClickButtonId = buttonId; this.load(true).finally(() => { diff --git a/client/app/components/dashboards/widget.less b/client/app/components/dashboards/widget.less index 0d356d7d12..d3e04b7520 100644 --- a/client/app/components/dashboards/widget.less +++ b/client/app/components/dashboards/widget.less @@ -1,3 +1,5 @@ +@import '../../assets/less/inc/variables'; + .tile .t-header .th-title a.query-link { color: rgba(0, 0, 0, 0.5); } @@ -10,12 +12,12 @@ visualization-name { font-size: 15px; font-weight: 500; color: rgba(0, 0, 0, 0.8); - + &:after { content: "−"; margin-left: 5px; } - + &:empty:after { content: none; } @@ -26,23 +28,27 @@ visualization-name { } .widget-wrapper { + .parameter-container { + margin: 0 15px; + } + .body-container { display: flex; flex-direction: column; align-items: stretch; - + .body-row { flex: 0 1 auto; } - + .body-row-auto { flex: 1 1 auto; } } - + .spinner-container { position: relative; - + .spinner { display: flex; align-items: center; @@ -55,31 +61,31 @@ visualization-name { height: 100%; } } - + .dropdown-header { padding: 0; - + .actions { position: static; } } - + .t-header.widget { .dropdown { margin-top: -15px; margin-right: -15px; - + .actions { position: static; } } } - + .scrollbox:empty { padding: 0 !important; font-size: 1px !important; } - + .widget-text { :first-child { margin-top: 0; @@ -90,27 +96,202 @@ visualization-name { } } +.editing-mode { + .widget-menu-regular { + display: none; + } + .widget-menu-remove { + display: block; + } + + a.query-link { + pointer-events: none; + cursor: move; + } + + .th-title { + cursor: move; + } + + .refresh-indicator { + transition-duration: 0s; + + rd-timer { + display: none; + } + + .refresh-indicator-mini(); + } +} + +.refresh-indicator { + font-size: 18px; + color: #86a1af; + transition: all 100ms linear; + transition-delay: 150ms; // waits for widget-menu to fade out before moving back over it + transform: translateX(22px); + position: absolute; + right: 29px; + top: 8px; + display: flex; + flex-direction: row-reverse; + + .refresh-icon { + position: relative; + + &:before { + content: ""; + position: absolute; + top: 0px; + right: 0; + width: 24px; + height: 24px; + background-color: #e8ecf0; + border-radius: 50%; + transition: opacity 100ms linear; + transition-delay: 150ms; + } + + i { + height: 24px; + width: 24px; + display: flex; + justify-content: center; + align-items: center; + } + } + + rd-timer { + font-size: 13px; + display: inline-block; + font-variant-numeric: tabular-nums; + opacity: 0; + transform: translateX(-6px); + transition: all 100ms linear; + transition-delay: 150ms; + color: #bbbbbb; + background-color: rgba(255,255,255,.9); + padding-left: 2px; + padding-right: 1px; + margin-right: -4px; + margin-top: 2px; + } + + .widget-visualization[data-refreshing="false"] & { + display: none; + } +} + +.refresh-indicator-mini() { + font-size: 13px; + transition-delay: 0s; + color: #bbbbbb; + transform: translateY(-4px); + + .refresh-icon:before { + transition-delay: 0s; + opacity: 0; + } + + rd-timer { + transition-delay: 0s; + opacity: 1; + transform: translateX(0); + } +} + +.tile { + .widget-menu-regular, .btn__refresh { + opacity: 0 !important; + transition: opacity 0.35s ease-in-out; + } + + .t-header { + .th-title { + padding-right: 23px; // no overlap on RefreshIndicator + + a { + color: fade(@redash-black, 80%); + font-size: 15px; + font-weight: 500; + } + } + + .query--description { + font-size: 14px; + line-height: 1.5; + font-style: italic; + + p { + margin-bottom: 0; + } + } + } + + .t-header.widget { + padding: 15px; + } + + &:hover { + .widget-menu-regular, .btn__refresh { + opacity: 1 !important; + transition: opacity 0.35s ease-in-out; + } + + .refresh-indicator { + .refresh-indicator-mini(); + } + } + + .tile__bottom-control { + padding: 10px 15px; + display: flex; + justify-content: space-between; + align-items: center; + + .btn-transparent { + &:first-child { + margin-left: -10px; + } + + &:last-child { + margin-right: -10px; + } + } + + + + a { + color: fade(@redash-black, 65%); + + &:hover { + color: fade(@redash-black, 95%); + } + } + } +} + // react-grid-layout overrides .react-grid-item { - + // placeholder color &.react-grid-placeholder { border-radius: 3px; background-color: #E0E6EB; opacity: 0.5; } - + // resize placeholder behind widget, the lib's default is above 🤷‍♂️ &.resizing { z-index: 3; } - + // auto-height animation &.cssTransforms:not(.resizing) { transition-property: transform, height; // added ", height" } - + // resize handle size & > .react-resizable-handle::after { width: 11px; diff --git a/client/app/components/items-list/components/ItemsTable.jsx b/client/app/components/items-list/components/ItemsTable.jsx index 565f08d661..b054634a72 100644 --- a/client/app/components/items-list/components/ItemsTable.jsx +++ b/client/app/components/items-list/components/ItemsTable.jsx @@ -72,6 +72,7 @@ Columns.custom.sortable = sortable; export default class ItemsTable extends React.Component { static propTypes = { + loading: PropTypes.bool, // eslint-disable-next-line react/forbid-prop-types items: PropTypes.arrayOf(PropTypes.object), columns: PropTypes.arrayOf(PropTypes.shape({ @@ -89,6 +90,7 @@ export default class ItemsTable extends React.Component { }; static defaultProps = { + loading: false, items: [], columns: [], showHeader: true, @@ -150,6 +152,7 @@ export default class ItemsTable extends React.Component { return ( -
- - - -
- - diff --git a/client/app/components/parameters.js b/client/app/components/parameters.js deleted file mode 100644 index 515abe5df5..0000000000 --- a/client/app/components/parameters.js +++ /dev/null @@ -1,98 +0,0 @@ -import { extend, filter, forEach, size } from 'lodash'; -import template from './parameters.html'; -import EditParameterSettingsDialog from './EditParameterSettingsDialog'; - -function ParametersDirective($location, KeyboardShortcuts) { - return { - restrict: 'E', - transclude: true, - scope: { - parameters: '=', - syncValues: '=?', - editable: '=?', - changed: '&onChange', - onUpdated: '=', - onValuesChange: '=', - applyOnKeyboardShortcut: ' scope.onApply(), - 'alt+enter': () => scope.onApply(), - }; - - const onFocus = () => { KeyboardShortcuts.bind(shortcuts); }; - const onBlur = () => { KeyboardShortcuts.unbind(shortcuts); }; - - el.addEventListener('focus', onFocus, true); - el.addEventListener('blur', onBlur, true); - - scope.$on('$destroy', () => { - KeyboardShortcuts.unbind(shortcuts); - el.removeEventListener('focus', onFocus); - el.removeEventListener('blur', onBlur); - }); - - // is this the correct location for this logic? - if (scope.syncValues !== false) { - scope.$watch( - 'parameters', - () => { - if (scope.changed) { - scope.changed({}); - } - const params = extend({}, $location.search()); - scope.parameters.forEach((param) => { - extend(params, param.toUrlParams()); - }); - Object.keys(params).forEach(key => params[key] == null && delete params[key]); - $location.search(params); - }, - true, - ); - } - - scope.showParameterSettings = (parameter, index) => { - EditParameterSettingsDialog - .showModal({ parameter }) - .result.then((updated) => { - scope.parameters[index] = extend(parameter, updated).setValue(updated.value); - scope.onUpdated(); - }); - }; - - scope.dirtyParamCount = 0; - scope.$watch( - 'parameters', - () => { - scope.dirtyParamCount = size(filter(scope.parameters, 'hasPendingValue')); - }, - true, - ); - - scope.isApplying = false; - scope.applyChanges = () => { - scope.isApplying = true; - forEach(scope.parameters, p => p.applyPendingValue()); - scope.isApplying = false; - }; - - scope.onApply = () => { - if (!scope.dirtyParamCount) { - return false; // so keyboard shortcut doesn't run needlessly - } - - scope.$apply(scope.applyChanges); - scope.onValuesChange(); - }; - }, - }; -} - -export default function init(ngModule) { - ngModule.directive('parameters', ParametersDirective); -} - -init.init = true; diff --git a/client/app/components/queries/visualization-embed.html b/client/app/components/queries/visualization-embed.html index 4a33cdc65f..563e4021e5 100644 --- a/client/app/components/queries/visualization-embed.html +++ b/client/app/components/queries/visualization-embed.html @@ -20,44 +20,43 @@

Error: {{$ctrl.error}}
- + -
-
-
- - - - - - {{$ctrl.queryResult.getUpdatedAt() | dateTime}} UTC -
-
- - - +
+ + + + + + + {{$ctrl.queryResult.getUpdatedAt() | dateTime}} UTC + + + + + + -
- - -
+
+ +
-
+
diff --git a/client/app/lib/value-format.js b/client/app/lib/value-format.js index 4d73741109..263e148cda 100644 --- a/client/app/lib/value-format.js +++ b/client/app/lib/value-format.js @@ -64,15 +64,6 @@ export function createNumberFormatter(format) { return value => toString(value); } -export function createFormatter(column) { - switch (column.displayAs) { - case 'number': return createNumberFormatter(column.numberFormat); - case 'boolean': return createBooleanFormatter(column.booleanValues); - case 'datetime': return createDateTimeFormatter(column.dateTimeFormat); - default: return createTextFormatter(column.allowHTML && column.highlightLinks); - } -} - export function formatSimpleTemplate(str, data) { if (!isString(str)) { return ''; diff --git a/client/app/pages/dashboards/dashboard.js b/client/app/pages/dashboards/dashboard.js index f283c3594b..e2ed937b50 100644 --- a/client/app/pages/dashboards/dashboard.js +++ b/client/app/pages/dashboards/dashboard.js @@ -137,8 +137,16 @@ function DashboardCtrl( this.extractGlobalParameters(); }); - const collectFilters = (dashboard, forceRefresh) => { - const queryResultPromises = _.compact(this.dashboard.widgets.map((widget) => { + const collectFilters = (dashboard, forceRefresh, updatedParameters = []) => { + const affectedWidgets = updatedParameters.length > 0 ? this.dashboard.widgets.filter( + widget => Object.values(widget.getParameterMappings()).filter( + ({ type }) => type === 'dashboard-level', + ).some( + ({ mapTo }) => _.includes(updatedParameters.map(p => p.name), mapTo), + ), + ) : this.dashboard.widgets; + + const queryResultPromises = _.compact(affectedWidgets.map((widget) => { widget.getParametersDefs(); // Force widget to read parameters values from URL return widget.load(forceRefresh); })); @@ -202,9 +210,9 @@ function DashboardCtrl( this.loadDashboard(); - this.refreshDashboard = () => { + this.refreshDashboard = (parameters) => { this.refreshInProgress = true; - collectFilters(this.dashboard, true).finally(() => { + collectFilters(this.dashboard, true, parameters).finally(() => { this.refreshInProgress = false; }); }; diff --git a/client/app/pages/dashboards/dashboard.less b/client/app/pages/dashboards/dashboard.less index 92a3d9e986..1c199b886f 100644 --- a/client/app/pages/dashboards/dashboard.less +++ b/client/app/pages/dashboards/dashboard.less @@ -1,3 +1,5 @@ +@import '../../assets/less/inc/variables'; + .dashboard-wrapper { flex-grow: 1; margin-bottom: 85px; @@ -20,7 +22,8 @@ padding: 0; } - .pivot-table-renderer > table, grid-renderer > div, visualization-renderer > div { + .pivot-table-renderer > table, + visualization-renderer > .visualization-renderer-wrapper { overflow: visible; } @@ -51,13 +54,6 @@ background-size: calc((100vw - 15px) / 6) 5px; background-position: -7px 1px; } - - .widget-menu-regular { - display: none; - } - .widget-menu-remove { - display: block; - } } .dashboard-widget-wrapper:not(.widget-auto-height-enabled) { @@ -70,7 +66,7 @@ right: 0; bottom: 0; - > div { + > .visualization-renderer-wrapper { flex-grow: 1; position: relative; } @@ -85,7 +81,7 @@ .map-visualization-container, .word-cloud-visualization-container, .box-plot-deprecated-visualization-container, - .plotly-chart-container { + .chart-visualization-container { position: absolute; left: 0; top: 0; @@ -96,7 +92,7 @@ overflow: hidden; } - counter { + .counter-visualization-content { position: absolute; left: 10px; top: 15px; @@ -126,6 +122,15 @@ margin: 3px 5px 0 0; } +.dashboard-header { + position: -webkit-sticky; // required for Safari + position: sticky; + background: #f6f7f9; + z-index: 99; + width: 100%; + top: 0; +} + .dashboard-header, .page-header--query { .tags-control a { opacity: 0; @@ -140,6 +145,8 @@ } .dashboard__control { + margin: 8px 0; + .save-status { vertical-align: middle; margin-right: 7px; @@ -234,3 +241,40 @@ dashboard-grid { display: flex; flex-direction: column; } + +.add-widget-container { + background: #fff; + border-radius: @redash-radius; + padding: 15px; + position: fixed; + left: 15px; + bottom: 20px; + width: calc(~'100% - 30px'); + z-index: 99; + box-shadow: fade(@redash-gray, 50%) 0px 7px 29px -3px; + display: flex; + justify-content: space-between; + + h2 { + margin: 0; + font-size: 14px; + line-height: 2.1; + font-weight: 400; + + .zmdi { + margin: 0; + margin-right: 5px; + font-size: 24px; + position: absolute; + bottom: 18px; + } + + span { + padding-left: 30px; + } + } + + .btn { + align-self: center; + } +} diff --git a/client/app/pages/home/home.html b/client/app/pages/home/home.html index 0e362af2b5..75340ac569 100644 --- a/client/app/pages/home/home.html +++ b/client/app/pages/home/home.html @@ -1,9 +1,24 @@
-
- You have enabled ALLOW_PARAMETERS_IN_EMBEDS. This setting is now deprecated and should be turned off. Parameters in embeds are supported by default. Read more. +
+ You have enabled ALLOW_PARAMETERS_IN_EMBEDS. This setting is + now deprecated and should be turned off. Parameters in embeds are supported + by default. + Read more.
-
- We have sent an email with a confirmation link to your email address. Please follow the link to verify your email address. Resend email. +
+ We have sent an email with a confirmation link to your email address. Please + follow the link to verify your email address. + Resend email.
+
+
+

Dunzo Updates

+
  • Do not name your saved with theses patterns. Any such saved query older than 7 days + will be removed from the system. +
      +
    • + New Query +
    • +
    • + Test Query +
    • +
    • + test_query +
    • +
    • + Copy of {any text} +
    • +
    +
  • +
    +
  • + Do add multiple tags to your saved queries. A tag with your name is helpful in filtering queries by user. +
  • + +
    +
    +
    -
    +
    diff --git a/client/app/pages/queries/query.html b/client/app/pages/queries/query.html index e2e50ae0ea..5232d00e67 100644 --- a/client/app/pages/queries/query.html +++ b/client/app/pages/queries/query.html @@ -85,9 +85,12 @@

    - {{$select.selected.name}} + + + {{$select.selected.name}} + - {{ds.name}} + {{ds.name}}
    @@ -191,8 +194,8 @@

    - +
    @@ -240,7 +243,7 @@

    - +

    @@ -260,8 +263,6 @@

    query="query" query-result="queryResult" query-executing="queryExecuting" - show-embed-dialog="showEmbedDialog" - embed="embed" apiKey="apiKey" selected-tab="selectedTab" open-add-to-dashboard-form="openAddToDashboardForm"> diff --git a/client/app/pages/queries/view.js b/client/app/pages/queries/view.js index aef1b66a39..592066f06a 100644 --- a/client/app/pages/queries/view.js +++ b/client/app/pages/queries/view.js @@ -215,6 +215,10 @@ function QueryViewCtrl( $scope.loadTags = () => getTags('api/queries/tags').then(tags => map(tags, t => t.name)); + $scope.applyParametersChanges = () => { + $scope.$apply(); + }; + $scope.saveQuery = (customOptions, data) => { let request = data; diff --git a/client/app/pages/query-snippets/QuerySnippetsList.jsx b/client/app/pages/query-snippets/QuerySnippetsList.jsx index a9f57ddfb2..63de531229 100644 --- a/client/app/pages/query-snippets/QuerySnippetsList.jsx +++ b/client/app/pages/query-snippets/QuerySnippetsList.jsx @@ -152,7 +152,7 @@ class QuerySnippetsList extends React.Component { There are no query snippets yet. {policy.isCreateQuerySnippetEnabled() && ( )} diff --git a/client/app/pages/settings/OrganizationSettings.jsx b/client/app/pages/settings/OrganizationSettings.jsx index 75bdfbbf0e..32e4d6c83b 100644 --- a/client/app/pages/settings/OrganizationSettings.jsx +++ b/client/app/pages/settings/OrganizationSettings.jsx @@ -10,13 +10,14 @@ import Select from 'antd/lib/select'; import Checkbox from 'antd/lib/checkbox'; import Tooltip from 'antd/lib/tooltip'; import LoadingState from '@/components/items-list/components/LoadingState'; -import { HelpTrigger } from '@/components/HelpTrigger'; import { routesToAngularRoutes } from '@/lib/utils'; import { clientConfig } from '@/services/auth'; import settingsMenu from '@/services/settingsMenu'; import recordEvent from '@/services/recordEvent'; import OrgSettings from '@/services/organizationSettings'; +import { HelpTrigger } from '@/components/HelpTrigger'; +import DynamicComponent from '@/components/DynamicComponent'; const Option = Select.Option; @@ -155,16 +156,16 @@ class OrganizationSettings extends React.Component { ))} - + this.handleChange('multi_byte_search_enabled', e.target.checked)} + name="feature_show_permissions_control" + checked={formValues.feature_show_permissions_control} + onChange={e => this.handleChange('feature_show_permissions_control', e.target.checked)} > - Enable multi-byte (Chinese, Japanese, and Korean) search for query names and descriptions (slower) + Enable experimental multiple owners support - + Email query owners when scheduled queries fail - + this.handleChange('feature_show_permissions_control', e.target.checked)} + name="multi_byte_search_enabled" + checked={formValues.multi_byte_search_enabled} + onChange={e => this.handleChange('multi_byte_search_enabled', e.target.checked)} > - Enable experimental multiple owners support + Enable multi-byte (Chinese, Japanese, and Korean) search for query names and descriptions (slower) + + Anonymous Usage Data Sharing }> + this.handleChange('beacon_consent', e.target.checked)} + > + Help Redash improve by automatically sending anonymous usage data + + + ); } diff --git a/client/app/services/organizationSettings.js b/client/app/services/organizationSettings.js index 1226bd35a6..dde3c01ea6 100644 --- a/client/app/services/organizationSettings.js +++ b/client/app/services/organizationSettings.js @@ -3,10 +3,13 @@ import notification from '@/services/notification'; export default { get: () => $http.get('api/settings/organization').then(response => response.data), - save: data => $http.post('api/settings/organization', data).then((response) => { - notification.success('Settings changes saved.'); - return response.data; - }).catch(() => { - notification.error('Failed saving changes.'); - }), + save: (data, message = 'Settings changes saved.') => $http + .post('api/settings/organization', data) + .then((response) => { + notification.success(message); + return response.data; + }) + .catch(() => { + notification.error('Failed saving changes.'); + }), }; diff --git a/client/app/services/query.js b/client/app/services/query.js index 317da77c11..b892e116b7 100644 --- a/client/app/services/query.js +++ b/client/app/services/query.js @@ -93,6 +93,10 @@ function collectParams(parts) { return parameters; } +function isEmptyValue(value) { + return isNull(value) || isUndefined(value) || (value === '') || (isArray(value) && value.length === 0); +} + function isDateParameter(paramType) { return includes(['date', 'datetime-local', 'datetime-with-seconds'], paramType); } @@ -164,10 +168,6 @@ export class Parameter { return isNull(this.getValue()); } - getValue(extra = {}) { - return this.constructor.getValue(this, extra); - } - get hasDynamicValue() { if (isDateParameter(this.type)) { return isDynamicDate(this.value); @@ -188,9 +188,12 @@ export class Parameter { return false; } + getValue(extra = {}) { + return this.constructor.getValue(this, extra); + } + static getValue(param, extra = {}) { const { value, type, useCurrentDateTime, multiValuesOptions } = param; - const isEmptyValue = isNull(value) || isUndefined(value) || (value === '') || (isArray(value) && value.length === 0); if (isDateRangeParameter(type) && param.hasDynamicValue) { const { dynamicValue } = param; if (dynamicValue) { @@ -211,7 +214,7 @@ export class Parameter { return null; } - if (isEmptyValue) { + if (isEmptyValue(value)) { // keep support for existing useCurentDateTime (not available in UI) if ( includes(['date', 'datetime-local', 'datetime-with-seconds'], type) && @@ -325,7 +328,11 @@ export class Parameter { } get hasPendingValue() { - return this.pendingValue !== undefined && this.pendingValue !== this.value; + // normalize empty values + const pendingValue = isEmptyValue(this.pendingValue) ? null : this.pendingValue; + const value = isEmptyValue(this.value) ? null : this.value; + + return this.pendingValue !== undefined && pendingValue !== value; } get normalizedValue() { @@ -417,7 +424,7 @@ class Parameters { const fallback = () => map(this.query.options.parameters, i => i.name); let parameters = []; - if (this.query.query) { + if (this.query.query !== undefined) { try { const parts = Mustache.parse(this.query.query); parameters = uniq(collectParams(parts)); diff --git a/client/app/visualizations/EditVisualizationDialog.jsx b/client/app/visualizations/EditVisualizationDialog.jsx index db2a94384c..4316e82757 100644 --- a/client/app/visualizations/EditVisualizationDialog.jsx +++ b/client/app/visualizations/EditVisualizationDialog.jsx @@ -191,6 +191,7 @@ function EditVisualizationDialog({ dialog, visualization, query, queryResult }) options={options} visualizationName={name} onOptionsChange={onOptionsChanged} + context="query" /> diff --git a/client/app/visualizations/VisualizationRenderer.jsx b/client/app/visualizations/VisualizationRenderer.jsx index 9b6e3db69a..2e4d95732c 100644 --- a/client/app/visualizations/VisualizationRenderer.jsx +++ b/client/app/visualizations/VisualizationRenderer.jsx @@ -60,12 +60,13 @@ export function VisualizationRenderer(props) { return ( {showFilters && } -
    +
    @@ -77,6 +78,7 @@ VisualizationRenderer.propTypes = { queryResult: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types filters: FiltersType, showFilters: PropTypes.bool, + context: PropTypes.oneOf(['query', 'widget']).isRequired, }; VisualizationRenderer.defaultProps = { diff --git a/client/app/visualizations/chart/Renderer/CustomPlotlyChart.jsx b/client/app/visualizations/chart/Renderer/CustomPlotlyChart.jsx new file mode 100644 index 0000000000..d60a43c213 --- /dev/null +++ b/client/app/visualizations/chart/Renderer/CustomPlotlyChart.jsx @@ -0,0 +1,48 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { RendererPropTypes } from '@/visualizations'; + +import { clientConfig } from '@/services/auth'; +import resizeObserver from '@/services/resizeObserver'; + +import getChartData from '../getChartData'; +import { Plotly, prepareCustomChartData, createCustomChartRenderer } from '../plotly'; + +export default function CustomPlotlyChart({ options, data }) { + if (!clientConfig.allowCustomJSVisualizations) { + return null; + } + + const [container, setContainer] = useState(null); + + const renderCustomChart = useMemo( + () => createCustomChartRenderer(options.customCode, options.enableConsoleLogs), + [options.customCode, options.enableConsoleLogs], + ); + + const plotlyData = useMemo( + () => prepareCustomChartData(getChartData(data.rows, options)), + [options, data], + ); + + useEffect(() => { + if (container) { + const unwatch = resizeObserver(container, () => { + // Clear existing data with blank data for succeeding codeCall adds data to existing plot. + Plotly.purge(container); + renderCustomChart(plotlyData.x, plotlyData.ys, container, Plotly); + }); + return unwatch; + } + }, [container, plotlyData]); + + // Cleanup when component destroyed + useEffect(() => { + if (container) { + return () => Plotly.purge(container); + } + }, [container]); + + return
    ; +} + +CustomPlotlyChart.propTypes = RendererPropTypes; diff --git a/client/app/visualizations/chart/Renderer/PlotlyChart.jsx b/client/app/visualizations/chart/Renderer/PlotlyChart.jsx new file mode 100644 index 0000000000..df96f730a6 --- /dev/null +++ b/client/app/visualizations/chart/Renderer/PlotlyChart.jsx @@ -0,0 +1,51 @@ +import { isArray, isObject } from 'lodash'; +import React, { useState, useEffect } from 'react'; +import { RendererPropTypes } from '@/visualizations'; +import resizeObserver from '@/services/resizeObserver'; + +import getChartData from '../getChartData'; +import { Plotly, prepareData, prepareLayout, updateData, applyLayoutFixes } from '../plotly'; + +export default function PlotlyChart({ options, data }) { + const [container, setContainer] = useState(null); + + useEffect(() => { + if (container) { + const plotlyOptions = { showLink: false, displaylogo: false }; + + const chartData = getChartData(data.rows, options); + const plotlyData = prepareData(chartData, options); + const plotlyLayout = prepareLayout(container, options, plotlyData); + + // It will auto-purge previous graph + Plotly.newPlot(container, plotlyData, plotlyLayout, plotlyOptions).then(() => { + applyLayoutFixes(container, plotlyLayout, (e, u) => Plotly.relayout(e, u)); + }); + + container.on('plotly_restyle', (updates) => { + // This event is triggered if some plotly data/layout has changed. + // We need to catch only changes of traces visibility to update stacking + if (isArray(updates) && isObject(updates[0]) && updates[0].visible) { + updateData(plotlyData, options); + Plotly.relayout(container, plotlyLayout); + } + }); + + const unwatch = resizeObserver(container, () => { + applyLayoutFixes(container, plotlyLayout, (e, u) => Plotly.relayout(e, u)); + }); + return unwatch; + } + }, [options, data, container]); + + // Cleanup when component destroyed + useEffect(() => { + if (container) { + return () => Plotly.purge(container); + } + }, [container]); + + return
    ; +} + +PlotlyChart.propTypes = RendererPropTypes; diff --git a/client/app/visualizations/chart/Renderer/index.jsx b/client/app/visualizations/chart/Renderer/index.jsx new file mode 100644 index 0000000000..f44f3065af --- /dev/null +++ b/client/app/visualizations/chart/Renderer/index.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { RendererPropTypes } from '@/visualizations'; + +import PlotlyChart from './PlotlyChart'; +import CustomPlotlyChart from './CustomPlotlyChart'; + +import './renderer.less'; + +export default function Renderer({ options, ...props }) { + if (options.globalSeriesType === 'custom') { + return ; + } + return ; +} + +Renderer.propTypes = RendererPropTypes; diff --git a/client/app/assets/less/inc/visualizations/chart.less b/client/app/visualizations/chart/Renderer/renderer.less similarity index 54% rename from client/app/assets/less/inc/visualizations/chart.less rename to client/app/visualizations/chart/Renderer/renderer.less index a9b3616285..524cec77b9 100644 --- a/client/app/assets/less/inc/visualizations/chart.less +++ b/client/app/visualizations/chart/Renderer/renderer.less @@ -1,4 +1,4 @@ -.plotly-chart-container { +.chart-visualization-container { height: 400px; overflow: hidden; } diff --git a/client/app/visualizations/chart/chart-editor.html b/client/app/visualizations/chart/chart-editor.html index a0f43a795c..ebe23910fa 100644 --- a/client/app/visualizations/chart/chart-editor.html +++ b/client/app/visualizations/chart/chart-editor.html @@ -168,13 +168,19 @@ Normalize values to percentage
    + +
    + +
    -
    diff --git a/client/app/visualizations/chart/chart.html b/client/app/visualizations/chart/chart.html deleted file mode 100644 index 8c2cbedc34..0000000000 --- a/client/app/visualizations/chart/chart.html +++ /dev/null @@ -1,6 +0,0 @@ -
    - -
    -
    - -
    diff --git a/client/app/visualizations/chart/fixtures/getChartData/multiple-series-grouped.json b/client/app/visualizations/chart/fixtures/getChartData/multiple-series-grouped.json new file mode 100644 index 0000000000..7fd5acd9c0 --- /dev/null +++ b/client/app/visualizations/chart/fixtures/getChartData/multiple-series-grouped.json @@ -0,0 +1,40 @@ +{ + "input": { + "data": [ + { "a": 42, "b": 10, "g": "first" }, + { "a": 62, "b": 73, "g": "first" }, + { "a": 21, "b": 82, "g": "second" }, + { "a": 85, "b": 50, "g": "first" }, + { "a": 95, "b": 32, "g": "second" } + ], + "options": { + "columnMapping": { + "a": "x", + "b": "y", + "g": "series" + }, + "seriesOptions": {} + } + }, + "output": { + "data": [ + { + "name": "first", + "type": "column", + "data": [ + { "x": 42, "y": 10, "$raw": { "a": 42, "b": 10, "g": "first" } }, + { "x": 62, "y": 73, "$raw": { "a": 62, "b": 73, "g": "first" } }, + { "x": 85, "y": 50, "$raw": { "a": 85, "b": 50, "g": "first" } } + ] + }, + { + "name": "second", + "type": "column", + "data": [ + { "x": 21, "y": 82, "$raw": { "a": 21, "b": 82, "g": "second" } }, + { "x": 95, "y": 32, "$raw": { "a": 95, "b": 32, "g": "second" } } + ] + } + ] + } +} diff --git a/client/app/visualizations/chart/fixtures/getChartData/multiple-series-multiple-y.json b/client/app/visualizations/chart/fixtures/getChartData/multiple-series-multiple-y.json new file mode 100644 index 0000000000..df4fa93629 --- /dev/null +++ b/client/app/visualizations/chart/fixtures/getChartData/multiple-series-multiple-y.json @@ -0,0 +1,41 @@ +{ + "input": { + "data": [ + { "a": 42, "b": 10, "c": 41, "d": 92 }, + { "a": 62, "b": 73 }, + { "a": 21, "b": null, "c": 33 }, + { "a": 85, "b": 50 }, + { "a": 95 } + ], + "options": { + "columnMapping": { + "a": "x", + "b": "y", + "c": "y" + }, + "seriesOptions": {} + } + }, + "output": { + "data": [ + { + "name": "b", + "type": "column", + "data": [ + { "x": 42, "y": 10, "$raw": { "a": 42, "b": 10, "c": 41, "d": 92 } }, + { "x": 62, "y": 73, "$raw": { "a": 62, "b": 73 } }, + { "x": 21, "y": null, "$raw": { "a": 21, "b": null, "c": 33 } }, + { "x": 85, "y": 50, "$raw": { "a": 85, "b": 50 } } + ] + }, + { + "name": "c", + "type": "column", + "data": [ + { "x": 42, "y": 41, "$raw": { "a": 42, "b": 10, "c": 41, "d": 92 } }, + { "x": 21, "y": 33, "$raw": { "a": 21, "b": null, "c": 33 } } + ] + } + ] + } +} diff --git a/client/app/visualizations/chart/fixtures/getChartData/multiple-series-sorted.json b/client/app/visualizations/chart/fixtures/getChartData/multiple-series-sorted.json new file mode 100644 index 0000000000..65f7c05cdc --- /dev/null +++ b/client/app/visualizations/chart/fixtures/getChartData/multiple-series-sorted.json @@ -0,0 +1,43 @@ +{ + "input": { + "data": [ + { "a": 42, "b": 10, "g": "first" }, + { "a": 62, "b": 73, "g": "first" }, + { "a": 21, "b": 82, "g": "second" }, + { "a": 85, "b": 50, "g": "first" }, + { "a": 95, "b": 32, "g": "second" } + ], + "options": { + "columnMapping": { + "a": "x", + "b": "y", + "g": "series" + }, + "seriesOptions": { + "first": { "zIndex": 2 }, + "second": { "zIndex": 1 } + } + } + }, + "output": { + "data": [ + { + "name": "second", + "type": "column", + "data": [ + { "x": 21, "y": 82, "$raw": { "a": 21, "b": 82, "g": "second" } }, + { "x": 95, "y": 32, "$raw": { "a": 95, "b": 32, "g": "second" } } + ] + }, + { + "name": "first", + "type": "column", + "data": [ + { "x": 42, "y": 10, "$raw": { "a": 42, "b": 10, "g": "first" } }, + { "x": 62, "y": 73, "$raw": { "a": 62, "b": 73, "g": "first" } }, + { "x": 85, "y": 50, "$raw": { "a": 85, "b": 50, "g": "first" } } + ] + } + ] + } +} diff --git a/client/app/visualizations/chart/fixtures/getChartData/single-series.json b/client/app/visualizations/chart/fixtures/getChartData/single-series.json new file mode 100644 index 0000000000..748ef9a921 --- /dev/null +++ b/client/app/visualizations/chart/fixtures/getChartData/single-series.json @@ -0,0 +1,32 @@ +{ + "input": { + "data": [ + { "a": 42, "b": 10, "c": 41, "d": 92 }, + { "a": 62, "b": 73 }, + { "a": 21, "b": null }, + { "a": 85, "b": 50 }, + { "a": 95 } + ], + "options": { + "columnMapping": { + "a": "x", + "b": "y" + }, + "seriesOptions": {} + } + }, + "output": { + "data": [ + { + "name": "b", + "type": "column", + "data": [ + { "x": 42, "y": 10, "$raw": { "a": 42, "b": 10, "c": 41, "d": 92 } }, + { "x": 62, "y": 73, "$raw": { "a": 62, "b": 73 } }, + { "x": 21, "y": null, "$raw": { "a": 21, "b": null } }, + { "x": 85, "y": 50, "$raw": { "a": 85, "b": 50 } } + ] + } + ] + } +} diff --git a/client/app/visualizations/chart/getChartData.js b/client/app/visualizations/chart/getChartData.js index 8e8f1ebd39..d9255c70c4 100644 --- a/client/app/visualizations/chart/getChartData.js +++ b/client/app/visualizations/chart/getChartData.js @@ -26,12 +26,11 @@ export default function getChartData(data, options) { let sizeValue = null; let zValue = null; - forOwn(row, (v, definition) => { + forOwn(row, (value, definition) => { definition = '' + definition; const definitionParts = definition.split('::') || definition.split('__'); const name = definitionParts[0]; const type = mappings ? mappings[definition] : definitionParts[1]; - let value = v; if (type === 'unused') { return; @@ -42,9 +41,6 @@ export default function getChartData(data, options) { point[type] = value; } if (type === 'y') { - if (value == null) { - value = 0; - } yValues[name] = value; point[type] = value; } diff --git a/client/app/visualizations/chart/getChartData.test.js b/client/app/visualizations/chart/getChartData.test.js new file mode 100644 index 0000000000..5d1239f6d4 --- /dev/null +++ b/client/app/visualizations/chart/getChartData.test.js @@ -0,0 +1,32 @@ +/* eslint-disable global-require, import/no-unresolved */ +import getChartData from './getChartData'; + +describe('Visualizations', () => { + describe('Chart', () => { + describe('getChartData', () => { + test('Single series', () => { + const { input, output } = require('./fixtures/getChartData/single-series'); + const data = getChartData(input.data, input.options); + expect(data).toEqual(output.data); + }); + + test('Multiple series: multiple Y mappings', () => { + const { input, output } = require('./fixtures/getChartData/multiple-series-multiple-y'); + const data = getChartData(input.data, input.options); + expect(data).toEqual(output.data); + }); + + test('Multiple series: grouped', () => { + const { input, output } = require('./fixtures/getChartData/multiple-series-grouped'); + const data = getChartData(input.data, input.options); + expect(data).toEqual(output.data); + }); + + test('Multiple series: sorted', () => { + const { input, output } = require('./fixtures/getChartData/multiple-series-sorted'); + const data = getChartData(input.data, input.options); + expect(data).toEqual(output.data); + }); + }); + }); +}); diff --git a/client/app/visualizations/chart/index.js b/client/app/visualizations/chart/index.js index f6857717b9..d8a3e2fed5 100644 --- a/client/app/visualizations/chart/index.js +++ b/client/app/visualizations/chart/index.js @@ -6,9 +6,10 @@ import { registerVisualization } from '@/visualizations'; import { clientConfig } from '@/services/auth'; import ColorPalette from '@/visualizations/ColorPalette'; import getChartData from './getChartData'; -import template from './chart.html'; import editorTemplate from './chart-editor.html'; +import Renderer from './Renderer'; + const DEFAULT_OPTIONS = { globalSeriesType: 'column', sortX: true, @@ -27,6 +28,8 @@ const DEFAULT_OPTIONS = { percentFormat: '0[.]00%', // dateTimeFormat: 'DD/MM/YYYY HH:mm', // will be set from clientConfig textFormat: '', // default: combination of {{ @@yPercent }} ({{ @@y }} ± {{ @@yError }}) + + missingValuesAsZero: true, }; function initEditorForm(options, columns) { @@ -69,26 +72,6 @@ function initEditorForm(options, columns) { return result; } -const ChartRenderer = { - template, - bindings: { - data: '<', - options: '<', - }, - controller($scope) { - this.chartSeries = []; - - const update = () => { - if (this.data) { - this.chartSeries = getChartData(this.data.rows, this.options); - } - }; - - $scope.$watch('$ctrl.data', update); - $scope.$watch('$ctrl.options', update, true); - }, -}; - const ChartEditor = { template: editorTemplate, bindings: { @@ -304,7 +287,6 @@ const ChartEditor = { }; export default function init(ngModule) { - ngModule.component('chartRenderer', ChartRenderer); ngModule.component('chartEditor', ChartEditor); ngModule.run(($injector) => { @@ -312,11 +294,21 @@ export default function init(ngModule) { type: 'CHART', name: 'Chart', isDefault: true, - getOptions: options => merge({}, DEFAULT_OPTIONS, { - showDataLabels: options.globalSeriesType === 'pie', - dateTimeFormat: clientConfig.dateTimeFormat, - }, options), - Renderer: angular2react('chartRenderer', ChartRenderer, $injector), + getOptions: (options) => { + const result = merge({}, DEFAULT_OPTIONS, { + showDataLabels: options.globalSeriesType === 'pie', + dateTimeFormat: clientConfig.dateTimeFormat, + }, options); + + // Backward compatibility + if (['normal', 'percent'].indexOf(result.series.stacking) >= 0) { + result.series.percentValues = result.series.stacking === 'percent'; + result.series.stacking = 'stack'; + } + + return result; + }, + Renderer, Editor: angular2react('chartEditor', ChartEditor, $injector), defaultColumns: 3, diff --git a/client/app/visualizations/chart/plotly/applyLayoutFixes.js b/client/app/visualizations/chart/plotly/applyLayoutFixes.js new file mode 100644 index 0000000000..56b3fa7d5e --- /dev/null +++ b/client/app/visualizations/chart/plotly/applyLayoutFixes.js @@ -0,0 +1,100 @@ +import { find, pick, reduce } from 'lodash'; + +function fixLegendContainer(plotlyElement) { + const legend = plotlyElement.querySelector('.legend'); + if (legend) { + let node = legend.parentNode; + while (node) { + if (node.tagName.toLowerCase() === 'svg') { + node.style.overflow = 'visible'; + break; + } + node = node.parentNode; + } + } +} + +export default function applyLayoutFixes(plotlyElement, layout, updatePlot) { + // update layout size to plot container + layout.width = Math.floor(plotlyElement.offsetWidth); + layout.height = Math.floor(plotlyElement.offsetHeight); + + const transformName = find([ + 'transform', + 'WebkitTransform', + 'MozTransform', + 'MsTransform', + 'OTransform', + ], prop => prop in plotlyElement.style); + + if (layout.width <= 600) { + // change legend orientation to horizontal; plotly has a bug with this + // legend alignment - it does not preserve enough space under the plot; + // so we'll hack this: update plot (it will re-render legend), compute + // legend height, reduce plot size by legend height (but not less than + // half of plot container's height - legend will have max height equal to + // plot height), re-render plot again and offset legend to the space under + // the plot. + layout.legend = { + orientation: 'h', + // locate legend inside of plot area - otherwise plotly will preserve + // some amount of space under the plot; also this will limit legend height + // to plot's height + y: 0, + x: 0, + xanchor: 'left', + yanchor: 'bottom', + }; + + // set `overflow: visible` to svg containing legend because later we will + // position legend outside of it + fixLegendContainer(plotlyElement); + + updatePlot(plotlyElement, pick(layout, ['width', 'height', 'legend'])).then(() => { + const legend = plotlyElement.querySelector('.legend'); // eslint-disable-line no-shadow + if (legend) { + // compute real height of legend - items may be split into few columnns, + // also scrollbar may be shown + const bounds = reduce(legend.querySelectorAll('.traces'), (result, node) => { + const b = node.getBoundingClientRect(); + result = result || b; + return { + top: Math.min(result.top, b.top), + bottom: Math.max(result.bottom, b.bottom), + }; + }, null); + // here we have two values: + // 1. height of plot container excluding height of legend items; + // it may be any value between 0 and plot container's height; + // 2. half of plot containers height. Legend cannot be larger than + // plot; if legend is too large, plotly will reduce it's height and + // show a scrollbar; in this case, height of plot === height of legend, + // so we can split container's height half by half between them. + layout.height = Math.floor(Math.max( + layout.height / 2, + layout.height - (bounds.bottom - bounds.top), + )); + // offset the legend + legend.style[transformName] = 'translate(0, ' + layout.height + 'px)'; + updatePlot(plotlyElement, pick(layout, ['height'])); + } + }); + } else { + layout.legend = { + orientation: 'v', + // vertical legend will be rendered properly, so just place it to the right + // side of plot + y: 1, + x: 1, + xanchor: 'left', + yanchor: 'top', + }; + + const legend = plotlyElement.querySelector('.legend'); + if (legend) { + legend.style[transformName] = null; + } + + updatePlot(plotlyElement, pick(layout, ['width', 'height', 'legend'])); + } +} diff --git a/client/app/visualizations/chart/plotly/customChartUtils.js b/client/app/visualizations/chart/plotly/customChartUtils.js new file mode 100644 index 0000000000..a6970100e0 --- /dev/null +++ b/client/app/visualizations/chart/plotly/customChartUtils.js @@ -0,0 +1,40 @@ +import { each } from 'lodash'; +import { normalizeValue } from './utils'; + +export function prepareCustomChartData(series) { + const x = []; + const ys = {}; + + each(series, ({ name, data }) => { + ys[name] = []; + each(data, (point) => { + x.push(normalizeValue(point.x)); + ys[name].push(normalizeValue(point.y)); + }); + }); + + return { x, ys }; +} + +export function createCustomChartRenderer(code, logErrorsToConsole = false) { + // Create a function from custom code; catch syntax errors + let render = () => {}; + try { + render = new Function('x, ys, element, Plotly', code); // eslint-disable-line no-new-func + } catch (err) { + if (logErrorsToConsole) { + console.log(`Error while executing custom graph: ${err}`); // eslint-disable-line no-console + } + } + + // Return function that will invoke custom code; catch runtime errors + return (x, ys, element, Plotly) => { + try { + render(x, ys, element, Plotly); + } catch (err) { + if (logErrorsToConsole) { + console.log(`Error while executing custom graph: ${err}`); // eslint-disable-line no-console + } + } + }; +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/bar/default.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/bar/default.json new file mode 100644 index 0000000000..8a31fe2c83 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/bar/default.json @@ -0,0 +1,56 @@ +{ + "input": { + "options": { + "globalSeriesType": "column", + "numberFormat": "0,0[.]00000", + "percentFormat": "0[.]00%", + "textFormat": "", + "showDataLabels": true, + "direction": { "type": "counterclockwise" }, + "xAxis": { "type": "-", "labels": { "enabled": true } }, + "yAxis": [ + { "type": "linear" }, + { "type": "linear", "opposite": true } + ], + "series": { "stacking": null, "error_y": { "type": "data", "visible": true } }, + "seriesOptions": { + "a": { "type": "column", "color": "red" } + }, + "columnMapping": { + "x": "x", + "y1": "y" + }, + "missingValuesAsZero": true + }, + "data": [ + { + "name": "a", + "data": [ + { "x": "x1", "y": 10, "yError": 0 }, + { "x": "x2", "y": 20, "yError": 0 }, + { "x": "x3", "y": 30, "yError": 0 }, + { "x": "x4", "y": 40, "yError": 0 } + ] + } + ] + }, + "output": { + "series": [ + { + "visible": true, + "type": "bar", + "name": "a", + "x": ["x1", "x2", "x3", "x4"], + "y": [10, 20, 30, 40], + "error_y": { "array": [0, 0, 0, 0], "color": "red" }, + "hoverinfo": "text+x+name", + "hover": [], + "text": ["10 ± 0", "20 ± 0", "30 ± 0", "40 ± 0"], + "textposition": "inside", + "marker": { "color": "red" }, + "insidetextfont": { "color": "#333333" }, + "yaxis": "y" + } + ] + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/bar/normalized.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/bar/normalized.json new file mode 100644 index 0000000000..3a29cdf376 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/bar/normalized.json @@ -0,0 +1,81 @@ +{ + "input": { + "options": { + "globalSeriesType": "column", + "numberFormat": "0,0[.]00000", + "percentFormat": "0[.]00%", + "textFormat": "", + "showDataLabels": true, + "direction": { "type": "counterclockwise" }, + "xAxis": { "type": "-", "labels": { "enabled": true } }, + "yAxis": [ + { "type": "linear" }, + { "type": "linear", "opposite": true } + ], + "series": { "stacking": "stack", "error_y": { "type": "data", "visible": true }, "percentValues": true }, + "seriesOptions": { + "a": { "type": "column", "color": "red" }, + "b": { "type": "column", "color": "blue" } + }, + "columnMapping": { + "x": "x", + "y1": "y" + }, + "missingValuesAsZero": true + }, + "data": [ + { + "name": "a", + "data": [ + { "x": "x1", "y": 10, "yError": 0 }, + { "x": "x2", "y": 20, "yError": 0 }, + { "x": "x3", "y": 30, "yError": 0 }, + { "x": "x4", "y": 40, "yError": 0 } + ] + }, + { + "name": "b", + "data": [ + { "x": "x1", "y": 40, "yError": 0 }, + { "x": "x2", "y": 30, "yError": 0 }, + { "x": "x3", "y": 20, "yError": 0 }, + { "x": "x4", "y": 10, "yError": 0 } + ] + } + ] + }, + "output": { + "series": [ + { + "visible": true, + "type": "bar", + "name": "a", + "x": ["x1", "x2", "x3", "x4"], + "y": [20, 40, 60, 80], + "error_y": { "array": [0, 0, 0, 0], "color": "red" }, + "hoverinfo": "text+x+name", + "hover": [], + "text": ["20% (10 ± 0)", "40% (20 ± 0)", "60% (30 ± 0)", "80% (40 ± 0)"], + "textposition": "inside", + "marker": { "color": "red" }, + "insidetextfont": { "color": "#333333" }, + "yaxis": "y" + }, + { + "visible": true, + "type": "bar", + "name": "b", + "x": ["x1", "x2", "x3", "x4"], + "y": [80, 60, 40, 20], + "error_y": { "array": [0, 0, 0, 0], "color": "blue" }, + "hoverinfo": "text+x+name", + "hover": [], + "text": ["80% (40 ± 0)", "60% (30 ± 0)", "40% (20 ± 0)", "20% (10 ± 0)"], + "textposition": "inside", + "marker": { "color": "blue" }, + "insidetextfont": { "color": "#333333" }, + "yaxis": "y" + } + ] + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/bar/stacked.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/bar/stacked.json new file mode 100644 index 0000000000..cb54f92407 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/bar/stacked.json @@ -0,0 +1,81 @@ +{ + "input": { + "options": { + "globalSeriesType": "column", + "numberFormat": "0,0[.]00000", + "percentFormat": "0[.]00%", + "textFormat": "", + "showDataLabels": true, + "direction": { "type": "counterclockwise" }, + "xAxis": { "type": "-", "labels": { "enabled": true } }, + "yAxis": [ + { "type": "linear" }, + { "type": "linear", "opposite": true } + ], + "series": { "stacking": "stack", "error_y": { "type": "data", "visible": true } }, + "seriesOptions": { + "a": { "type": "column", "color": "red" }, + "b": { "type": "column", "color": "blue" } + }, + "columnMapping": { + "x": "x", + "y1": "y" + }, + "missingValuesAsZero": true + }, + "data": [ + { + "name": "a", + "data": [ + { "x": "x1", "y": 10, "yError": 0 }, + { "x": "x2", "y": 20, "yError": 0 }, + { "x": "x3", "y": 30, "yError": 0 }, + { "x": "x4", "y": 40, "yError": 0 } + ] + }, + { + "name": "b", + "data": [ + { "x": "x1", "y": 1, "yError": 0 }, + { "x": "x2", "y": 2, "yError": 0 }, + { "x": "x3", "y": 3, "yError": 0 }, + { "x": "x4", "y": 4, "yError": 0 } + ] + } + ] + }, + "output": { + "series": [ + { + "visible": true, + "type": "bar", + "name": "a", + "x": ["x1", "x2", "x3", "x4"], + "y": [10, 20, 30, 40], + "error_y": { "array": [0, 0, 0, 0], "color": "red" }, + "hoverinfo": "text+x+name", + "hover": [], + "text": ["10 ± 0", "20 ± 0", "30 ± 0", "40 ± 0"], + "textposition": "inside", + "marker": { "color": "red" }, + "insidetextfont": { "color": "#333333" }, + "yaxis": "y" + }, + { + "visible": true, + "type": "bar", + "name": "b", + "x": ["x1", "x2", "x3", "x4"], + "y": [1, 2, 3, 4], + "error_y": { "array": [0, 0, 0, 0], "color": "blue" }, + "hoverinfo": "text+x+name", + "hover": [], + "text": ["1 ± 0", "2 ± 0", "3 ± 0", "4 ± 0"], + "textposition": "inside", + "marker": { "color": "blue" }, + "insidetextfont": { "color": "#333333" }, + "yaxis": "y" + } + ] + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/box/default.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/box/default.json new file mode 100644 index 0000000000..5a5ba12dbe --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/box/default.json @@ -0,0 +1,57 @@ +{ + "input": { + "options": { + "globalSeriesType": "box", + "numberFormat": "0,0[.]00000", + "percentFormat": "0[.]00%", + "textFormat": "", + "showDataLabels": true, + "direction": { "type": "counterclockwise" }, + "xAxis": { "type": "-", "labels": { "enabled": true } }, + "yAxis": [ + { "type": "linear" }, + { "type": "linear", "opposite": true } + ], + "series": { "stacking": null, "error_y": { "type": "data", "visible": true } }, + "seriesOptions": { + "a": { "type": "box", "color": "red" } + }, + "columnMapping": { + "x": "x", + "y1": "y" + }, + "missingValuesAsZero": true + }, + "data": [ + { + "name": "a", + "data": [ + { "x": "x1", "y": 10, "yError": 0 }, + { "x": "x2", "y": 20, "yError": 0 }, + { "x": "x3", "y": 30, "yError": 0 }, + { "x": "x4", "y": 40, "yError": 0 } + ] + } + ] + }, + "output": { + "series": [ + { + "visible": true, + "name": "a", + "type": "box", + "mode": "markers", + "boxpoints": "outliers", + "hoverinfo": false, + "marker": { "color": "red", "size": 3 }, + "x": ["x1", "x2", "x3", "x4"], + "y": [10, 20, 30, 40], + "error_y": { "array": [0, 0, 0, 0], "color": "red" }, + "hover": [], + "text": ["10 ± 0", "20 ± 0", "30 ± 0", "40 ± 0"], + "insidetextfont": { "color": "#333333" }, + "yaxis": "y" + } + ] + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/box/with-points.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/box/with-points.json new file mode 100644 index 0000000000..710cf6bd11 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/box/with-points.json @@ -0,0 +1,60 @@ +{ + "input": { + "options": { + "globalSeriesType": "box", + "numberFormat": "0,0[.]00000", + "percentFormat": "0[.]00%", + "textFormat": "", + "showDataLabels": true, + "direction": { "type": "counterclockwise" }, + "xAxis": { "type": "-", "labels": { "enabled": true } }, + "yAxis": [ + { "type": "linear" }, + { "type": "linear", "opposite": true } + ], + "series": { "stacking": null, "error_y": { "type": "data", "visible": true } }, + "seriesOptions": { + "a": { "type": "box", "color": "red" } + }, + "columnMapping": { + "x": "x", + "y1": "y" + }, + "missingValuesAsZero": true, + "showpoints": true + }, + "data": [ + { + "name": "a", + "data": [ + { "x": "x1", "y": 10, "yError": 0 }, + { "x": "x2", "y": 20, "yError": 0 }, + { "x": "x3", "y": 30, "yError": 0 }, + { "x": "x4", "y": 40, "yError": 0 } + ] + } + ] + }, + "output": { + "series": [ + { + "visible": true, + "name": "a", + "type": "box", + "mode": "markers", + "boxpoints": "all", + "jitter": 0.3, + "pointpos": -1.8, + "hoverinfo": false, + "marker": { "color": "red", "size": 3 }, + "x": ["x1", "x2", "x3", "x4"], + "y": [10, 20, 30, 40], + "error_y": { "array": [0, 0, 0, 0], "color": "red" }, + "hover": [], + "text": ["10 ± 0", "20 ± 0", "30 ± 0", "40 ± 0"], + "insidetextfont": { "color": "#333333" }, + "yaxis": "y" + } + ] + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/bubble/default.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/bubble/default.json new file mode 100644 index 0000000000..10e9b45505 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/bubble/default.json @@ -0,0 +1,55 @@ +{ + "input": { + "options": { + "globalSeriesType": "bubble", + "numberFormat": "0,0[.]00000", + "percentFormat": "0[.]00%", + "textFormat": "", + "showDataLabels": true, + "direction": { "type": "counterclockwise" }, + "xAxis": { "type": "-", "labels": { "enabled": true } }, + "yAxis": [ + { "type": "linear" }, + { "type": "linear", "opposite": true } + ], + "series": { "stacking": null, "error_y": { "type": "data", "visible": true } }, + "seriesOptions": { + "a": { "type": "bubble", "color": "red" } + }, + "columnMapping": { + "x": "x", + "y1": "y" + }, + "missingValuesAsZero": true + }, + "data": [ + { + "name": "a", + "data": [ + { "x": "x1", "y": 10, "yError": 0, "size": 51 }, + { "x": "x2", "y": 20, "yError": 0, "size": 52 }, + { "x": "x3", "y": 30, "yError": 0, "size": 53 }, + { "x": "x4", "y": 40, "yError": 0, "size": 54 } + ] + } + ] + }, + "output": { + "series": [ + { + "visible": true, + "name": "a", + "mode": "markers", + "marker": { "color": "red", "size": [51, 52, 53, 54] }, + "x": ["x1", "x2", "x3", "x4"], + "y": [10, 20, 30, 40], + "error_y": { "array": [0, 0, 0, 0], "color": "red" }, + "hoverinfo": "text+x+name", + "hover": [], + "text": ["10 ± 0: 51", "20 ± 0: 52", "30 ± 0: 53", "40 ± 0: 54"], + "insidetextfont": { "color": "#333333" }, + "yaxis": "y" + } + ] + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/heatmap/default.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/heatmap/default.json new file mode 100644 index 0000000000..4006b3d12e --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/heatmap/default.json @@ -0,0 +1,33 @@ +{ + "input": { + "options": { + "globalSeriesType": "heatmap", + "colorScheme": "Bluered", + "seriesOptions": {}, + "showDataLabels": false + }, + "data": [ + { + "name": "a", + "data": [ + { "x": 12, "y": 21, "zVal": 3 }, + { "x": 11, "y": 22, "zVal": 2 }, + { "x": 11, "y": 21, "zVal": 1 }, + { "x": 12, "y": 22, "zVal": 4 } + ] + } + ] + }, + "output": { + "series": [ + { + "x": [12, 11], + "y": [21, 22], + "z": [[3, 1], [4, 2]], + "type": "heatmap", + "name": "", + "colorscale": "Bluered" + } + ] + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/heatmap/reversed.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/heatmap/reversed.json new file mode 100644 index 0000000000..ff1e16e0f5 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/heatmap/reversed.json @@ -0,0 +1,35 @@ +{ + "input": { + "options": { + "globalSeriesType": "heatmap", + "colorScheme": "Bluered", + "seriesOptions": {}, + "showDataLabels": false, + "reverseX": true, + "reverseY": true + }, + "data": [ + { + "name": "a", + "data": [ + { "x": 12, "y": 21, "zVal": 3 }, + { "x": 11, "y": 22, "zVal": 2 }, + { "x": 11, "y": 21, "zVal": 1 }, + { "x": 12, "y": 22, "zVal": 4 } + ] + } + ] + }, + "output": { + "series": [ + { + "x": [11, 12], + "y": [22, 21], + "z": [[2, 4], [1, 3]], + "type": "heatmap", + "name": "", + "colorscale": "Bluered" + } + ] + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/heatmap/sorted-reversed.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/heatmap/sorted-reversed.json new file mode 100644 index 0000000000..ac8d69f694 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/heatmap/sorted-reversed.json @@ -0,0 +1,37 @@ +{ + "input": { + "options": { + "globalSeriesType": "heatmap", + "colorScheme": "Bluered", + "seriesOptions": {}, + "showDataLabels": false, + "sortX": true, + "sortY": true, + "reverseX": true, + "reverseY": true + }, + "data": [ + { + "name": "a", + "data": [ + { "x": 12, "y": 21, "zVal": 3 }, + { "x": 11, "y": 22, "zVal": 2 }, + { "x": 11, "y": 21, "zVal": 1 }, + { "x": 12, "y": 22, "zVal": 4 } + ] + } + ] + }, + "output": { + "series": [ + { + "x": [12, 11], + "y": [22, 21], + "z": [[4, 2], [3, 1]], + "type": "heatmap", + "name": "", + "colorscale": "Bluered" + } + ] + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/heatmap/sorted.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/heatmap/sorted.json new file mode 100644 index 0000000000..3073a32916 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/heatmap/sorted.json @@ -0,0 +1,35 @@ +{ + "input": { + "options": { + "globalSeriesType": "heatmap", + "colorScheme": "Bluered", + "seriesOptions": {}, + "showDataLabels": false, + "sortX": true, + "sortY": true + }, + "data": [ + { + "name": "a", + "data": [ + { "x": 12, "y": 21, "zVal": 3 }, + { "x": 11, "y": 22, "zVal": 2 }, + { "x": 11, "y": 21, "zVal": 1 }, + { "x": 12, "y": 22, "zVal": 4 } + ] + } + ] + }, + "output": { + "series": [ + { + "x": [11, 12], + "y": [21, 22], + "z": [[1, 3], [2, 4]], + "type": "heatmap", + "name": "", + "colorscale": "Bluered" + } + ] + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/heatmap/with-labels.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/heatmap/with-labels.json new file mode 100644 index 0000000000..87a2583541 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/heatmap/with-labels.json @@ -0,0 +1,44 @@ +{ + "input": { + "options": { + "globalSeriesType": "heatmap", + "colorScheme": "Bluered", + "seriesOptions": {}, + "showDataLabels": true + }, + "data": [ + { + "name": "a", + "data": [ + { "x": 12, "y": 21, "zVal": 3 }, + { "x": 11, "y": 22, "zVal": 2 }, + { "x": 11, "y": 21, "zVal": 1 }, + { "x": 12, "y": 22, "zVal": 4 } + ] + } + ] + }, + "output": { + "series": [ + { + "x": [12, 11], + "y": [21, 22], + "z": [[3, 1], [4, 2]], + "type": "heatmap", + "name": "", + "colorscale": "Bluered" + }, + { + "x": [12, 11, 12, 11], + "y": [21, 21, 22, 22], + "mode": "text", + "hoverinfo": "skip", + "showlegend": false, + "text": ["3", "1", "4", "2"], + "textfont": { + "color": ["black", "black", "black", "black"] + } + } + ] + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/default.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/default.json new file mode 100644 index 0000000000..ca3f540979 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/default.json @@ -0,0 +1,55 @@ +{ + "input": { + "options": { + "globalSeriesType": "line", + "numberFormat": "0,0[.]00000", + "percentFormat": "0[.]00%", + "textFormat": "", + "showDataLabels": true, + "direction": { "type": "counterclockwise" }, + "xAxis": { "type": "-", "labels": { "enabled": true } }, + "yAxis": [ + { "type": "linear" }, + { "type": "linear", "opposite": true } + ], + "series": { "stacking": null, "error_y": { "type": "data", "visible": true } }, + "seriesOptions": { + "a": { "type": "line", "color": "red" } + }, + "columnMapping": { + "x": "x", + "y1": "y" + }, + "missingValuesAsZero": true + }, + "data": [ + { + "name": "a", + "data": [ + { "x": "x1", "y": 10, "yError": 0 }, + { "x": "x2", "y": 20, "yError": 0 }, + { "x": "x3", "y": 30, "yError": 0 }, + { "x": "x4", "y": 40, "yError": 0 } + ] + } + ] + }, + "output": { + "series": [ + { + "visible": true, + "name": "a", + "mode": "lines+text", + "x": ["x1", "x2", "x3", "x4"], + "y": [10, 20, 30, 40], + "error_y": { "array": [0, 0, 0, 0], "color": "red" }, + "hoverinfo": "text+x+name", + "hover": [], + "text": ["10 ± 0", "20 ± 0", "30 ± 0", "40 ± 0"], + "marker": { "color": "red" }, + "insidetextfont": { "color": "#333333" }, + "yaxis": "y" + } + ] + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/keep-missing-values.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/keep-missing-values.json new file mode 100644 index 0000000000..108be880c8 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/keep-missing-values.json @@ -0,0 +1,77 @@ +{ + "input": { + "options": { + "globalSeriesType": "line", + "numberFormat": "0,0[.]00000", + "percentFormat": "0[.]00%", + "textFormat": "", + "showDataLabels": true, + "direction": { "type": "counterclockwise" }, + "xAxis": { "type": "-", "labels": { "enabled": true } }, + "yAxis": [ + { "type": "linear" }, + { "type": "linear", "opposite": true } + ], + "series": { "stacking": "stack", "error_y": { "type": "data", "visible": true } }, + "seriesOptions": { + "a": { "type": "line", "color": "red" }, + "b": { "type": "line", "color": "blue" } + }, + "columnMapping": { + "x": "x", + "y1": "y" + }, + "missingValuesAsZero": false + }, + "data": [ + { + "name": "a", + "data": [ + { "x": "x1", "y": 10, "yError": 0 }, + { "x": "x2", "y": 20, "yError": 0 }, + { "x": "x3", "y": 30, "yError": 0 }, + { "x": "x4", "y": 40, "yError": 0 } + ] + }, + { + "name": "b", + "data": [ + { "x": "x2", "y": 2, "yError": 0 }, + { "x": "x4", "y": 4, "yError": 0 } + ] + } + ] + }, + "output": { + "series": [ + { + "visible": true, + "name": "a", + "mode": "lines+text", + "x": ["x1", "x2", "x3", "x4"], + "y": [10, 20, 30, 40], + "error_y": { "array": [0, 0, 0, 0], "color": "red" }, + "hoverinfo": "text+x+name", + "hover": [], + "text": ["10 ± 0", "20 ± 0", "30 ± 0", "40 ± 0"], + "marker": { "color": "red" }, + "insidetextfont": { "color": "#333333" }, + "yaxis": "y" + }, + { + "visible": true, + "name": "b", + "mode": "lines+text", + "x": ["x1", "x2", "x3", "x4"], + "y": [null, 22, null, 44], + "error_y": { "array": [null, 0, null, 0], "color": "blue" }, + "hoverinfo": "text+x+name", + "hover": [], + "text": ["", "2 ± 0", "", "4 ± 0"], + "marker": { "color": "blue" }, + "insidetextfont": { "color": "#333333" }, + "yaxis": "y" + } + ] + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/missing-values-0.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/missing-values-0.json new file mode 100644 index 0000000000..23e6a15df2 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/missing-values-0.json @@ -0,0 +1,77 @@ +{ + "input": { + "options": { + "globalSeriesType": "line", + "numberFormat": "0,0[.]00000", + "percentFormat": "0[.]00%", + "textFormat": "", + "showDataLabels": true, + "direction": { "type": "counterclockwise" }, + "xAxis": { "type": "-", "labels": { "enabled": true } }, + "yAxis": [ + { "type": "linear" }, + { "type": "linear", "opposite": true } + ], + "series": { "stacking": "stack", "error_y": { "type": "data", "visible": true } }, + "seriesOptions": { + "a": { "type": "line", "color": "red" }, + "b": { "type": "line", "color": "blue" } + }, + "columnMapping": { + "x": "x", + "y1": "y" + }, + "missingValuesAsZero": true + }, + "data": [ + { + "name": "a", + "data": [ + { "x": "x1", "y": 10, "yError": 0 }, + { "x": "x2", "y": 20, "yError": 0 }, + { "x": "x3", "y": 30, "yError": 0 }, + { "x": "x4", "y": 40, "yError": 0 } + ] + }, + { + "name": "b", + "data": [ + { "x": "x2", "y": 2, "yError": 0 }, + { "x": "x4", "y": 4, "yError": 0 } + ] + } + ] + }, + "output": { + "series": [ + { + "visible": true, + "name": "a", + "mode": "lines+text", + "x": ["x1", "x2", "x3", "x4"], + "y": [10, 20, 30, 40], + "error_y": { "array": [0, 0, 0, 0], "color": "red" }, + "hoverinfo": "text+x+name", + "hover": [], + "text": ["10 ± 0", "20 ± 0", "30 ± 0", "40 ± 0"], + "marker": { "color": "red" }, + "insidetextfont": { "color": "#333333" }, + "yaxis": "y" + }, + { + "visible": true, + "name": "b", + "mode": "lines+text", + "x": ["x1", "x2", "x3", "x4"], + "y": [10, 22, 30, 44], + "error_y": { "array": [null, 0, null, 0], "color": "blue" }, + "hoverinfo": "text+x+name", + "hover": [], + "text": ["0", "2 ± 0", "0", "4 ± 0"], + "marker": { "color": "blue" }, + "insidetextfont": { "color": "#333333" }, + "yaxis": "y" + } + ] + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/normalized-stacked.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/normalized-stacked.json new file mode 100644 index 0000000000..a5b25b6e79 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/normalized-stacked.json @@ -0,0 +1,79 @@ +{ + "input": { + "options": { + "globalSeriesType": "line", + "numberFormat": "0,0[.]00000", + "percentFormat": "0[.]00%", + "textFormat": "", + "showDataLabels": true, + "direction": { "type": "counterclockwise" }, + "xAxis": { "type": "-", "labels": { "enabled": true } }, + "yAxis": [ + { "type": "linear" }, + { "type": "linear", "opposite": true } + ], + "series": { "stacking": "stack", "error_y": { "type": "data", "visible": true }, "percentValues": true }, + "seriesOptions": { + "a": { "type": "line", "color": "red" }, + "b": { "type": "line", "color": "blue" } + }, + "columnMapping": { + "x": "x", + "y1": "y" + }, + "missingValuesAsZero": true + }, + "data": [ + { + "name": "a", + "data": [ + { "x": "x1", "y": 10, "yError": 0 }, + { "x": "x2", "y": 20, "yError": 0 }, + { "x": "x3", "y": 30, "yError": 0 }, + { "x": "x4", "y": 40, "yError": 0 } + ] + }, + { + "name": "b", + "data": [ + { "x": "x1", "y": 40, "yError": 0 }, + { "x": "x2", "y": 30, "yError": 0 }, + { "x": "x3", "y": 20, "yError": 0 }, + { "x": "x4", "y": 10, "yError": 0 } + ] + } + ] + }, + "output": { + "series": [ + { + "visible": true, + "name": "a", + "mode": "lines+text", + "x": ["x1", "x2", "x3", "x4"], + "y": [20, 40, 60, 80], + "error_y": { "array": [0, 0, 0, 0], "color": "red" }, + "hoverinfo": "text+x+name", + "hover": [], + "text": ["20% (10 ± 0)", "40% (20 ± 0)", "60% (30 ± 0)", "80% (40 ± 0)"], + "marker": { "color": "red" }, + "insidetextfont": { "color": "#333333" }, + "yaxis": "y" + }, + { + "visible": true, + "name": "b", + "mode": "lines+text", + "x": ["x1", "x2", "x3", "x4"], + "y": [100, 100, 100, 100], + "error_y": { "array": [0, 0, 0, 0], "color": "blue" }, + "hoverinfo": "text+x+name", + "hover": [], + "text": ["80% (40 ± 0)", "60% (30 ± 0)", "40% (20 ± 0)", "20% (10 ± 0)"], + "marker": { "color": "blue" }, + "insidetextfont": { "color": "#333333" }, + "yaxis": "y" + } + ] + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/normalized.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/normalized.json new file mode 100644 index 0000000000..c016e392d4 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/normalized.json @@ -0,0 +1,79 @@ +{ + "input": { + "options": { + "globalSeriesType": "line", + "numberFormat": "0,0[.]00000", + "percentFormat": "0[.]00%", + "textFormat": "", + "showDataLabels": true, + "direction": { "type": "counterclockwise" }, + "xAxis": { "type": "-", "labels": { "enabled": true } }, + "yAxis": [ + { "type": "linear" }, + { "type": "linear", "opposite": true } + ], + "series": { "stacking": null, "error_y": { "type": "data", "visible": true }, "percentValues": true }, + "seriesOptions": { + "a": { "type": "line", "color": "red" }, + "b": { "type": "line", "color": "blue" } + }, + "columnMapping": { + "x": "x", + "y1": "y" + }, + "missingValuesAsZero": true + }, + "data": [ + { + "name": "a", + "data": [ + { "x": "x1", "y": 10, "yError": 0 }, + { "x": "x2", "y": 20, "yError": 0 }, + { "x": "x3", "y": 30, "yError": 0 }, + { "x": "x4", "y": 40, "yError": 0 } + ] + }, + { + "name": "b", + "data": [ + { "x": "x1", "y": 40, "yError": 0 }, + { "x": "x2", "y": 30, "yError": 0 }, + { "x": "x3", "y": 20, "yError": 0 }, + { "x": "x4", "y": 10, "yError": 0 } + ] + } + ] + }, + "output": { + "series": [ + { + "visible": true, + "name": "a", + "mode": "lines+text", + "x": ["x1", "x2", "x3", "x4"], + "y": [20, 40, 60, 80], + "error_y": { "array": [0, 0, 0, 0], "color": "red" }, + "hoverinfo": "text+x+name", + "hover": [], + "text": ["20% (10 ± 0)", "40% (20 ± 0)", "60% (30 ± 0)", "80% (40 ± 0)"], + "marker": { "color": "red" }, + "insidetextfont": { "color": "#333333" }, + "yaxis": "y" + }, + { + "visible": true, + "name": "b", + "mode": "lines+text", + "x": ["x1", "x2", "x3", "x4"], + "y": [80, 60, 40, 20], + "error_y": { "array": [0, 0, 0, 0], "color": "blue" }, + "hoverinfo": "text+x+name", + "hover": [], + "text": ["80% (40 ± 0)", "60% (30 ± 0)", "40% (20 ± 0)", "20% (10 ± 0)"], + "marker": { "color": "blue" }, + "insidetextfont": { "color": "#333333" }, + "yaxis": "y" + } + ] + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/stacked.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/stacked.json new file mode 100644 index 0000000000..bcb7a5157b --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/stacked.json @@ -0,0 +1,79 @@ +{ + "input": { + "options": { + "globalSeriesType": "line", + "numberFormat": "0,0[.]00000", + "percentFormat": "0[.]00%", + "textFormat": "", + "showDataLabels": true, + "direction": { "type": "counterclockwise" }, + "xAxis": { "type": "-", "labels": { "enabled": true } }, + "yAxis": [ + { "type": "linear" }, + { "type": "linear", "opposite": true } + ], + "series": { "stacking": "stack", "error_y": { "type": "data", "visible": true } }, + "seriesOptions": { + "a": { "type": "line", "color": "red" }, + "b": { "type": "line", "color": "blue" } + }, + "columnMapping": { + "x": "x", + "y1": "y" + }, + "missingValuesAsZero": true + }, + "data": [ + { + "name": "a", + "data": [ + { "x": "x1", "y": 10, "yError": 0 }, + { "x": "x2", "y": 20, "yError": 0 }, + { "x": "x3", "y": 30, "yError": 0 }, + { "x": "x4", "y": 40, "yError": 0 } + ] + }, + { + "name": "b", + "data": [ + { "x": "x1", "y": 1, "yError": 0 }, + { "x": "x2", "y": 2, "yError": 0 }, + { "x": "x3", "y": 3, "yError": 0 }, + { "x": "x4", "y": 4, "yError": 0 } + ] + } + ] + }, + "output": { + "series": [ + { + "visible": true, + "name": "a", + "mode": "lines+text", + "x": ["x1", "x2", "x3", "x4"], + "y": [10, 20, 30, 40], + "error_y": { "array": [0, 0, 0, 0], "color": "red" }, + "hoverinfo": "text+x+name", + "hover": [], + "text": ["10 ± 0", "20 ± 0", "30 ± 0", "40 ± 0"], + "marker": { "color": "red" }, + "insidetextfont": { "color": "#333333" }, + "yaxis": "y" + }, + { + "visible": true, + "name": "b", + "mode": "lines+text", + "x": ["x1", "x2", "x3", "x4"], + "y": [11, 22, 33, 44], + "error_y": { "array": [0, 0, 0, 0], "color": "blue" }, + "hoverinfo": "text+x+name", + "hover": [], + "text": ["1 ± 0", "2 ± 0", "3 ± 0", "4 ± 0"], + "marker": { "color": "blue" }, + "insidetextfont": { "color": "#333333" }, + "yaxis": "y" + } + ] + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/pie/custom-tooltip.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/pie/custom-tooltip.json new file mode 100644 index 0000000000..2b8be824d1 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/pie/custom-tooltip.json @@ -0,0 +1,57 @@ +{ + "input": { + "options": { + "globalSeriesType": "pie", + "seriesOptions": {}, + "numberFormat": "0,0[.]00000", + "percentFormat": "0[.]00%", + "textFormat": "{{ @@name }}: {{ @@yPercent }} ({{ @@y }})", + "showDataLabels": true, + "direction": { "type": "counterclockwise" }, + "xAxis": { "type": "-", "labels": { "enabled": true } }, + "yAxis": [ + { "type": "linear" }, + { "type": "linear", "opposite": true } + ], + "series": { "stacking": null, "error_y": { "type": "data", "visible": true } }, + "columnMapping": { + "x": "x", + "y": "y" + } + }, + "data": [ + { + "name": "a", + "data": [ + { "x": "a1", "y": 10 }, + { "x": "a2", "y": 60 }, + { "x": "a3", "y": 100 }, + { "x": "a4", "y": 30 } + ] + } + ] + }, + "output": { + "series": [ + { + "visible": true, + "values": [10, 60, 100, 30], + "labels": ["a1", "a2", "a3", "a4"], + "type": "pie", + "hole": 0.4, + "marker": { + "colors": ["#356AFF", "#E92828", "#3BD973", "#604FE9"] + }, + "hoverinfo": "text+label", + "hover": [], + "text": ["a: 5% (10)", "a: 30% (60)", "a: 50% (100)", "a: 15% (30)"], + "textinfo": "percent", + "textposition": "inside", + "textfont": { "color": "#ffffff" }, + "name": "a", + "direction": "counterclockwise", + "domain": { "x": [0, 0.98], "y": [0, 0.9] } + } + ] + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/pie/default.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/pie/default.json new file mode 100644 index 0000000000..ffabb1db31 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/pie/default.json @@ -0,0 +1,57 @@ +{ + "input": { + "options": { + "globalSeriesType": "pie", + "seriesOptions": {}, + "numberFormat": "0,0[.]00000", + "percentFormat": "0[.]00%", + "textFormat": "", + "showDataLabels": true, + "direction": { "type": "counterclockwise" }, + "xAxis": { "type": "-", "labels": { "enabled": true } }, + "yAxis": [ + { "type": "linear" }, + { "type": "linear", "opposite": true } + ], + "series": { "stacking": null, "error_y": { "type": "data", "visible": true } }, + "columnMapping": { + "x": "x", + "y": "y" + } + }, + "data": [ + { + "name": "a", + "data": [ + { "x": "a1", "y": 10 }, + { "x": "a2", "y": 60 }, + { "x": "a3", "y": 100 }, + { "x": "a4", "y": 30 } + ] + } + ] + }, + "output": { + "series": [ + { + "visible": true, + "values": [10, 60, 100, 30], + "labels": ["a1", "a2", "a3", "a4"], + "type": "pie", + "hole": 0.4, + "marker": { + "colors": ["#356AFF", "#E92828", "#3BD973", "#604FE9"] + }, + "hoverinfo": "text+label", + "hover": [], + "text": ["5% (10)", "30% (60)", "50% (100)", "15% (30)"], + "textinfo": "percent", + "textposition": "inside", + "textfont": { "color": "#ffffff" }, + "name": "a", + "direction": "counterclockwise", + "domain": { "x": [0, 0.98], "y": [0, 0.9] } + } + ] + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/pie/without-labels.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/pie/without-labels.json new file mode 100644 index 0000000000..8ef0d06136 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/pie/without-labels.json @@ -0,0 +1,57 @@ +{ + "input": { + "options": { + "globalSeriesType": "pie", + "seriesOptions": {}, + "numberFormat": "0,0[.]00000", + "percentFormat": "0[.]00%", + "textFormat": "", + "showDataLabels": false, + "direction": { "type": "counterclockwise" }, + "xAxis": { "type": "-", "labels": { "enabled": true } }, + "yAxis": [ + { "type": "linear" }, + { "type": "linear", "opposite": true } + ], + "series": { "stacking": null, "error_y": { "type": "data", "visible": true } }, + "columnMapping": { + "x": "x", + "y": "y" + } + }, + "data": [ + { + "name": "a", + "data": [ + { "x": "a1", "y": 10 }, + { "x": "a2", "y": 60 }, + { "x": "a3", "y": 100 }, + { "x": "a4", "y": 30 } + ] + } + ] + }, + "output": { + "series": [ + { + "visible": true, + "values": [10, 60, 100, 30], + "labels": ["a1", "a2", "a3", "a4"], + "type": "pie", + "hole": 0.4, + "marker": { + "colors": ["#356AFF", "#E92828", "#3BD973", "#604FE9"] + }, + "hoverinfo": "text+label", + "hover": [], + "text": ["5% (10)", "30% (60)", "50% (100)", "15% (30)"], + "textinfo": "none", + "textposition": "inside", + "textfont": { "color": "#ffffff" }, + "name": "a", + "direction": "counterclockwise", + "domain": { "x": [0, 0.98], "y": [0, 0.9] } + } + ] + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/pie/without-x.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/pie/without-x.json new file mode 100644 index 0000000000..a5c69b24a7 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/pie/without-x.json @@ -0,0 +1,53 @@ +{ + "input": { + "options": { + "globalSeriesType": "pie", + "seriesOptions": {}, + "numberFormat": "0,0[.]00000", + "percentFormat": "0[.]00%", + "textFormat": "", + "showDataLabels": true, + "direction": { "type": "counterclockwise" }, + "xAxis": { "type": "-", "labels": { "enabled": true } }, + "yAxis": [ + { "type": "linear" }, + { "type": "linear", "opposite": true } + ], + "series": { "stacking": null, "error_y": { "type": "data", "visible": true } } + }, + "data": [ + { + "name": "a", + "data": [ + { "x": "a1", "y": 10 }, + { "x": "a2", "y": 60 }, + { "x": "a3", "y": 100 }, + { "x": "a4", "y": 30 } + ] + } + ] + }, + "output": { + "series": [ + { + "visible": true, + "values": [10, 60, 100, 30], + "labels": ["Slice 0", "Slice 0", "Slice 0", "Slice 0"], + "type": "pie", + "hole": 0.4, + "marker": { + "colors": ["#356AFF", "#E92828", "#3BD973", "#604FE9"] + }, + "hoverinfo": "text+label", + "hover": [], + "text": ["15% (30)", "15% (30)", "15% (30)", "15% (30)"], + "textinfo": "percent", + "textposition": "inside", + "textfont": { "color": "#ffffff" }, + "name": "a", + "direction": "counterclockwise", + "domain": { "x": [0, 0.98], "y": [0, 0.9] } + } + ] + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/scatter/default.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/scatter/default.json new file mode 100644 index 0000000000..5daed94941 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/scatter/default.json @@ -0,0 +1,56 @@ +{ + "input": { + "options": { + "globalSeriesType": "scatter", + "numberFormat": "0,0[.]00000", + "percentFormat": "0[.]00%", + "textFormat": "", + "showDataLabels": true, + "direction": { "type": "counterclockwise" }, + "xAxis": { "type": "-", "labels": { "enabled": true } }, + "yAxis": [ + { "type": "linear" }, + { "type": "linear", "opposite": true } + ], + "series": { "stacking": null, "error_y": { "type": "data", "visible": true } }, + "seriesOptions": { + "a": { "type": "scatter", "color": "red" } + }, + "columnMapping": { + "x": "x", + "y1": "y" + }, + "missingValuesAsZero": true + }, + "data": [ + { + "name": "a", + "data": [ + { "x": "x1", "y": 10, "yError": 0 }, + { "x": "x2", "y": 20, "yError": 0 }, + { "x": "x3", "y": 30, "yError": 0 }, + { "x": "x4", "y": 40, "yError": 0 } + ] + } + ] + }, + "output": { + "series": [ + { + "visible": true, + "name": "a", + "type": "scatter", + "mode": "markers+text", + "x": ["x1", "x2", "x3", "x4"], + "y": [10, 20, 30, 40], + "error_y": { "array": [0, 0, 0, 0], "color": "red" }, + "hoverinfo": "text+x+name", + "hover": [], + "text": ["10 ± 0", "20 ± 0", "30 ± 0", "40 ± 0"], + "marker": { "color": "red" }, + "insidetextfont": { "color": "#333333" }, + "yaxis": "y" + } + ] + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/scatter/without-labels.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/scatter/without-labels.json new file mode 100644 index 0000000000..9267346196 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/scatter/without-labels.json @@ -0,0 +1,56 @@ +{ + "input": { + "options": { + "globalSeriesType": "scatter", + "numberFormat": "0,0[.]00000", + "percentFormat": "0[.]00%", + "textFormat": "", + "showDataLabels": false, + "direction": { "type": "counterclockwise" }, + "xAxis": { "type": "-", "labels": { "enabled": true } }, + "yAxis": [ + { "type": "linear" }, + { "type": "linear", "opposite": true } + ], + "series": { "stacking": null, "error_y": { "type": "data", "visible": true } }, + "seriesOptions": { + "a": { "type": "scatter", "color": "red" } + }, + "columnMapping": { + "x": "x", + "y1": "y" + }, + "missingValuesAsZero": true + }, + "data": [ + { + "name": "a", + "data": [ + { "x": "x1", "y": 10, "yError": 0 }, + { "x": "x2", "y": 20, "yError": 0 }, + { "x": "x3", "y": 30, "yError": 0 }, + { "x": "x4", "y": 40, "yError": 0 } + ] + } + ] + }, + "output": { + "series": [ + { + "visible": true, + "name": "a", + "type": "scatter", + "mode": "markers", + "x": ["x1", "x2", "x3", "x4"], + "y": [10, 20, 30, 40], + "error_y": { "array": [0, 0, 0, 0], "color": "red" }, + "hoverinfo": "text+x+name", + "hover": [], + "text": ["10 ± 0", "20 ± 0", "30 ± 0", "40 ± 0"], + "marker": { "color": "red" }, + "insidetextfont": { "color": "#333333" }, + "yaxis": "y" + } + ] + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareLayout/box-single-axis.json b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/box-single-axis.json new file mode 100644 index 0000000000..2ca978f540 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/box-single-axis.json @@ -0,0 +1,38 @@ +{ + "input": { + "options": { + "globalSeriesType": "box", + "xAxis": { "type": "-", "labels": { "enabled": true } }, + "yAxis": [ + { "type": "linear" }, + { "type": "linear", "opposite": true } + ], + "series": { "stacking": null, "error_y": { "type": "data", "visible": true } } + }, + "series": [ + { "name": "a" } + ] + }, + "output": { + "layout": { + "margin": { "l": 10, "r": 10, "b": 10, "t": 25, "pad": 4 }, + "width": 400, + "height": 300, + "autosize": true, + "showlegend": true, + "boxmode": "group", + "boxgroupgap": 0.50, + "xaxis": { + "automargin": true, + "showticklabels": true, + "title": null, + "type": "-" + }, + "yaxis": { + "automargin": true, + "title": null, + "type": "linear" + } + } + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareLayout/box-with-second-axis.json b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/box-with-second-axis.json new file mode 100644 index 0000000000..db513dd4a3 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/box-with-second-axis.json @@ -0,0 +1,46 @@ +{ + "input": { + "options": { + "globalSeriesType": "box", + "xAxis": { "type": "-", "labels": { "enabled": true } }, + "yAxis": [ + { "type": "linear" }, + { "type": "linear", "opposite": true } + ], + "series": { "stacking": null, "error_y": { "type": "data", "visible": true } } + }, + "series": [ + { "name": "a" }, + { "name": "b", "yaxis": "y2" } + ] + }, + "output": { + "layout": { + "margin": { "l": 10, "r": 10, "b": 10, "t": 25, "pad": 4 }, + "width": 400, + "height": 300, + "autosize": true, + "showlegend": true, + "boxmode": "group", + "boxgroupgap": 0.50, + "xaxis": { + "automargin": true, + "showticklabels": true, + "title": null, + "type": "-" + }, + "yaxis": { + "automargin": true, + "title": null, + "type": "linear" + }, + "yaxis2": { + "automargin": true, + "title": null, + "type": "linear", + "overlaying": "y", + "side": "right" + } + } + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareLayout/default-single-axis.json b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/default-single-axis.json new file mode 100644 index 0000000000..f80aa28be2 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/default-single-axis.json @@ -0,0 +1,36 @@ +{ + "input": { + "options": { + "globalSeriesType": "column", + "xAxis": { "type": "-", "labels": { "enabled": true } }, + "yAxis": [ + { "type": "linear" }, + { "type": "linear", "opposite": true } + ], + "series": { "stacking": null, "error_y": { "type": "data", "visible": true } } + }, + "series": [ + { "name": "a" } + ] + }, + "output": { + "layout": { + "margin": { "l": 10, "r": 10, "b": 10, "t": 25, "pad": 4 }, + "width": 400, + "height": 300, + "autosize": true, + "showlegend": true, + "xaxis": { + "automargin": true, + "showticklabels": true, + "title": null, + "type": "-" + }, + "yaxis": { + "automargin": true, + "title": null, + "type": "linear" + } + } + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareLayout/default-with-second-axis.json b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/default-with-second-axis.json new file mode 100644 index 0000000000..2d83b6a12e --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/default-with-second-axis.json @@ -0,0 +1,44 @@ +{ + "input": { + "options": { + "globalSeriesType": "column", + "xAxis": { "type": "-", "labels": { "enabled": true } }, + "yAxis": [ + { "type": "linear" }, + { "type": "linear", "opposite": true } + ], + "series": { "stacking": null, "error_y": { "type": "data", "visible": true } } + }, + "series": [ + { "name": "a" }, + { "name": "b", "yaxis": "y2" } + ] + }, + "output": { + "layout": { + "margin": { "l": 10, "r": 10, "b": 10, "t": 25, "pad": 4 }, + "width": 400, + "height": 300, + "autosize": true, + "showlegend": true, + "xaxis": { + "automargin": true, + "showticklabels": true, + "title": null, + "type": "-" + }, + "yaxis": { + "automargin": true, + "title": null, + "type": "linear" + }, + "yaxis2": { + "automargin": true, + "title": null, + "type": "linear", + "overlaying": "y", + "side": "right" + } + } + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareLayout/default-with-stacking.json b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/default-with-stacking.json new file mode 100644 index 0000000000..7dfd7c1e10 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/default-with-stacking.json @@ -0,0 +1,38 @@ +{ + "input": { + "options": { + "globalSeriesType": "column", + "legend": { "enabled": false }, + "xAxis": { "type": "-", "labels": { "enabled": true } }, + "yAxis": [ + { "type": "linear" }, + { "type": "linear", "opposite": true } + ], + "series": { "stacking": "stack", "error_y": { "type": "data", "visible": true } } + }, + "series": [ + { "name": "a" } + ] + }, + "output": { + "layout": { + "margin": { "l": 10, "r": 10, "b": 10, "t": 25, "pad": 4 }, + "width": 400, + "height": 300, + "autosize": true, + "showlegend": false, + "barmode": "relative", + "xaxis": { + "automargin": true, + "showticklabels": true, + "title": null, + "type": "-" + }, + "yaxis": { + "automargin": true, + "title": null, + "type": "linear" + } + } + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareLayout/default-without-legend.json b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/default-without-legend.json new file mode 100644 index 0000000000..93747d5c03 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/default-without-legend.json @@ -0,0 +1,37 @@ +{ + "input": { + "options": { + "globalSeriesType": "column", + "legend": { "enabled": false }, + "xAxis": { "type": "-", "labels": { "enabled": true } }, + "yAxis": [ + { "type": "linear" }, + { "type": "linear", "opposite": true } + ], + "series": { "stacking": null, "error_y": { "type": "data", "visible": true } } + }, + "series": [ + { "name": "a" } + ] + }, + "output": { + "layout": { + "margin": { "l": 10, "r": 10, "b": 10, "t": 25, "pad": 4 }, + "width": 400, + "height": 300, + "autosize": true, + "showlegend": false, + "xaxis": { + "automargin": true, + "showticklabels": true, + "title": null, + "type": "-" + }, + "yaxis": { + "automargin": true, + "title": null, + "type": "linear" + } + } + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareLayout/pie-multiple-series.json b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/pie-multiple-series.json new file mode 100644 index 0000000000..ef935b4b12 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/pie-multiple-series.json @@ -0,0 +1,48 @@ +{ + "input": { + "options": { + "globalSeriesType": "pie", + "textFormat": "" + }, + "series": [ + { "name": "a" }, + { "name": "b" }, + { "name": "c" } + ] + }, + "output": { + "layout": { + "margin": { "l": 10, "r": 10, "b": 10, "t": 25, "pad": 4 }, + "width": 400, + "height": 300, + "autosize": true, + "showlegend": true, + "annotations": [ + { + "x": 0.24, + "y": 0.485, + "xanchor": "center", + "yanchor": "top", + "text": "a", + "showarrow": false + }, + { + "x": 0.74, + "y": 0.485, + "xanchor": "center", + "yanchor": "top", + "text": "b", + "showarrow": false + }, + { + "x": 0.24, + "y": 0.985, + "xanchor": "center", + "yanchor": "top", + "text": "c", + "showarrow": false + } + ] + } + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareLayout/pie-without-annotations.json b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/pie-without-annotations.json new file mode 100644 index 0000000000..c306a7dfc4 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/pie-without-annotations.json @@ -0,0 +1,21 @@ +{ + "input": { + "options": { + "globalSeriesType": "pie", + "textFormat": "{{ @@name }}" + }, + "series": [ + { "name": "a" } + ] + }, + "output": { + "layout": { + "margin": { "l": 10, "r": 10, "b": 10, "t": 25, "pad": 4 }, + "width": 400, + "height": 300, + "autosize": true, + "showlegend": true, + "annotations": [] + } + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareLayout/pie.json b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/pie.json new file mode 100644 index 0000000000..be6ec72584 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/pie.json @@ -0,0 +1,30 @@ +{ + "input": { + "options": { + "globalSeriesType": "pie", + "textFormat": "" + }, + "series": [ + { "name": "a" } + ] + }, + "output": { + "layout": { + "margin": { "l": 10, "r": 10, "b": 10, "t": 25, "pad": 4 }, + "width": 400, + "height": 300, + "autosize": true, + "showlegend": true, + "annotations": [ + { + "x": 0.49, + "y": 0.985, + "xanchor": "center", + "yanchor": "top", + "text": "a", + "showarrow": false + } + ] + } + } +} diff --git a/client/app/visualizations/chart/plotly/index.js b/client/app/visualizations/chart/plotly/index.js index 4d0a91d4d0..3aa3ad382c 100644 --- a/client/app/visualizations/chart/plotly/index.js +++ b/client/app/visualizations/chart/plotly/index.js @@ -1,5 +1,3 @@ -import { each, debounce, isArray, isObject } from 'lodash'; - import Plotly from 'plotly.js/lib/core'; import bar from 'plotly.js/lib/bar'; import pie from 'plotly.js/lib/pie'; @@ -7,134 +5,23 @@ import histogram from 'plotly.js/lib/histogram'; import box from 'plotly.js/lib/box'; import heatmap from 'plotly.js/lib/heatmap'; -import { - prepareData, - prepareLayout, - updateData, - updateLayout, - normalizeValue, -} from './utils'; +import prepareData from './prepareData'; +import prepareLayout from './prepareLayout'; +import updateData from './updateData'; +import applyLayoutFixes from './applyLayoutFixes'; +import { prepareCustomChartData, createCustomChartRenderer } from './customChartUtils'; Plotly.register([bar, pie, histogram, box, heatmap]); Plotly.setPlotConfig({ modeBarButtonsToRemove: ['sendDataToCloud'], }); -const PlotlyChart = () => ({ - restrict: 'E', - template: '
    ', - scope: { - options: '=', - series: '=', - }, - link(scope, element) { - const plotlyElement = element[0].querySelector('.plotly-chart-container'); - const plotlyOptions = { showLink: false, displaylogo: false }; - let layout = {}; - let data = []; - - function update() { - if (['normal', 'percent'].indexOf(scope.options.series.stacking) >= 0) { - // Backward compatibility - scope.options.series.percentValues = scope.options.series.stacking === 'percent'; - scope.options.series.stacking = 'stack'; - } - - data = prepareData(scope.series, scope.options); - updateData(data, scope.options); - layout = prepareLayout(plotlyElement, scope.series, scope.options, data); - - // It will auto-purge previous graph - Plotly.newPlot(plotlyElement, data, layout, plotlyOptions).then(() => { - updateLayout(plotlyElement, layout, (e, u) => Plotly.relayout(e, u)); - }); - - plotlyElement.on('plotly_restyle', (updates) => { - // This event is triggered if some plotly data/layout has changed. - // We need to catch only changes of traces visibility to update stacking - if (isArray(updates) && isObject(updates[0]) && updates[0].visible) { - updateData(data, scope.options); - Plotly.relayout(plotlyElement, layout); - } - }); - } - update(); - - scope.$watch('series', (oldValue, newValue) => { - if (oldValue !== newValue) { - update(); - } - }); - scope.$watch('options', (oldValue, newValue) => { - if (oldValue !== newValue) { - update(); - } - }, true); - - scope.handleResize = debounce(() => { - updateLayout(plotlyElement, layout, (e, u) => Plotly.relayout(e, u)); - }, 50); - }, -}); - -const CustomPlotlyChart = clientConfig => ({ - restrict: 'E', - template: '
    ', - scope: { - series: '=', - options: '=', - }, - link(scope, element) { - if (!clientConfig.allowCustomJSVisualizations) { - return; - } - - const refresh = () => { - // Clear existing data with blank data for succeeding codeCall adds data to existing plot. - Plotly.newPlot(element[0].firstChild); - - try { - // eslint-disable-next-line no-new-func - const codeCall = new Function('x, ys, element, Plotly', scope.options.customCode); - codeCall(scope.x, scope.ys, element[0].children[0], Plotly); - } catch (err) { - if (scope.options.enableConsoleLogs) { - // eslint-disable-next-line no-console - console.log(`Error while executing custom graph: ${err}`); - } - } - }; - - const timeSeriesToPlotlySeries = () => { - scope.x = []; - scope.ys = {}; - each(scope.series, (series) => { - scope.ys[series.name] = []; - each(series.data, (point) => { - scope.x.push(normalizeValue(point.x)); - scope.ys[series.name].push(normalizeValue(point.y)); - }); - }); - }; - - scope.handleResize = () => { - refresh(); - }; - - scope.$watch('[options.customCode, options.autoRedraw]', () => { - refresh(); - }, true); - - scope.$watch('series', () => { - timeSeriesToPlotlySeries(); - refresh(); - }, true); - }, -}); - -export default function init(ngModule) { - ngModule.directive('plotlyChart', PlotlyChart); - ngModule.directive('customPlotlyChart', CustomPlotlyChart); -} - -init.init = true; +export { + Plotly, + prepareData, + prepareLayout, + updateData, + applyLayoutFixes, + prepareCustomChartData, + createCustomChartRenderer, +}; diff --git a/client/app/visualizations/chart/plotly/prepareData.js b/client/app/visualizations/chart/plotly/prepareData.js new file mode 100644 index 0000000000..e8819d9986 --- /dev/null +++ b/client/app/visualizations/chart/plotly/prepareData.js @@ -0,0 +1,12 @@ +import preparePieData from './preparePieData'; +import prepareHeatmapData from './prepareHeatmapData'; +import prepareDefaultData from './prepareDefaultData'; +import updateData from './updateData'; + +export default function prepareData(seriesList, options) { + switch (options.globalSeriesType) { + case 'pie': return updateData(preparePieData(seriesList, options), options); + case 'heatmap': return updateData(prepareHeatmapData(seriesList, options, options)); + default: return updateData(prepareDefaultData(seriesList, options), options); + } +} diff --git a/client/app/visualizations/chart/plotly/prepareData.test.js b/client/app/visualizations/chart/plotly/prepareData.test.js new file mode 100644 index 0000000000..3aaf54d8c1 --- /dev/null +++ b/client/app/visualizations/chart/plotly/prepareData.test.js @@ -0,0 +1,160 @@ +/* eslint-disable global-require, import/no-unresolved */ +import prepareData from './prepareData'; + +function cleanSeries(series) { + return series.map(({ sourceData, ...rest }) => rest); +} + +describe('Visualizations', () => { + describe('Chart', () => { + describe('prepareData', () => { + describe('heatmap', () => { + test('default', () => { + const { input, output } = require('./fixtures/prepareData/heatmap/default'); + const series = prepareData(input.data, input.options); + expect(series).toEqual(output.series); + }); + test('sorted', () => { + const { input, output } = require('./fixtures/prepareData/heatmap/sorted'); + const series = prepareData(input.data, input.options); + expect(series).toEqual(output.series); + }); + test('reversed', () => { + const { input, output } = require('./fixtures/prepareData/heatmap/reversed'); + const series = prepareData(input.data, input.options); + expect(series).toEqual(output.series); + }); + test('sorted & reversed', () => { + const { input, output } = require('./fixtures/prepareData/heatmap/sorted'); + const series = prepareData(input.data, input.options); + expect(series).toEqual(output.series); + }); + test('with labels', () => { + const { input, output } = require('./fixtures/prepareData/heatmap/with-labels'); + const series = prepareData(input.data, input.options); + expect(series).toEqual(output.series); + }); + }); + + describe('pie', () => { + test('default', () => { + const { input, output } = require('./fixtures/prepareData/pie/default'); + const series = cleanSeries(prepareData(input.data, input.options)); + expect(series).toEqual(output.series); + }); + + test('without X mapped', () => { + const { input, output } = require('./fixtures/prepareData/pie/without-x'); + const series = cleanSeries(prepareData(input.data, input.options)); + expect(series).toEqual(output.series); + }); + + test('without labels', () => { + const { input, output } = require('./fixtures/prepareData/pie/without-labels'); + const series = cleanSeries(prepareData(input.data, input.options)); + expect(series).toEqual(output.series); + }); + + test('custom tooltip', () => { + const { input, output } = require('./fixtures/prepareData/pie/custom-tooltip'); + const series = cleanSeries(prepareData(input.data, input.options)); + expect(series).toEqual(output.series); + }); + }); + + describe('bar (column)', () => { + test('default', () => { + const { input, output } = require('./fixtures/prepareData/bar/default'); + const series = cleanSeries(prepareData(input.data, input.options)); + expect(series).toEqual(output.series); + }); + + test('stacked', () => { + const { input, output } = require('./fixtures/prepareData/bar/stacked'); + const series = cleanSeries(prepareData(input.data, input.options)); + expect(series).toEqual(output.series); + }); + + test('normalized values', () => { + const { input, output } = require('./fixtures/prepareData/bar/normalized'); + const series = cleanSeries(prepareData(input.data, input.options)); + expect(series).toEqual(output.series); + }); + }); + + describe('lines & area', () => { + test('default', () => { + const { input, output } = require('./fixtures/prepareData/line-area/default'); + const series = cleanSeries(prepareData(input.data, input.options)); + expect(series).toEqual(output.series); + }); + + test('stacked', () => { + const { input, output } = require('./fixtures/prepareData/line-area/stacked'); + const series = cleanSeries(prepareData(input.data, input.options)); + expect(series).toEqual(output.series); + }); + + test('normalized values', () => { + const { input, output } = require('./fixtures/prepareData/line-area/normalized'); + const series = cleanSeries(prepareData(input.data, input.options)); + expect(series).toEqual(output.series); + }); + + test('stacked & normalized values', () => { + const { input, output } = require('./fixtures/prepareData/line-area/normalized-stacked'); + const series = cleanSeries(prepareData(input.data, input.options)); + expect(series).toEqual(output.series); + }); + + test('keep missing values', () => { + const { input, output } = require('./fixtures/prepareData/line-area/keep-missing-values'); + const series = cleanSeries(prepareData(input.data, input.options)); + expect(series).toEqual(output.series); + }); + + test('convert missing values to 0', () => { + const { input, output } = require('./fixtures/prepareData/line-area/missing-values-0'); + const series = cleanSeries(prepareData(input.data, input.options)); + expect(series).toEqual(output.series); + }); + }); + + describe('scatter', () => { + test('default', () => { + const { input, output } = require('./fixtures/prepareData/scatter/default'); + const series = cleanSeries(prepareData(input.data, input.options)); + expect(series).toEqual(output.series); + }); + + test('without labels', () => { + const { input, output } = require('./fixtures/prepareData/scatter/without-labels'); + const series = cleanSeries(prepareData(input.data, input.options)); + expect(series).toEqual(output.series); + }); + }); + + describe('bubble', () => { + test('default', () => { + const { input, output } = require('./fixtures/prepareData/bubble/default'); + const series = cleanSeries(prepareData(input.data, input.options)); + expect(series).toEqual(output.series); + }); + }); + + describe('box', () => { + test('default', () => { + const { input, output } = require('./fixtures/prepareData/box/default'); + const series = cleanSeries(prepareData(input.data, input.options)); + expect(series).toEqual(output.series); + }); + + test('with points', () => { + const { input, output } = require('./fixtures/prepareData/box/with-points'); + const series = cleanSeries(prepareData(input.data, input.options)); + expect(series).toEqual(output.series); + }); + }); + }); + }); +}); diff --git a/client/app/visualizations/chart/plotly/prepareDefaultData.js b/client/app/visualizations/chart/plotly/prepareDefaultData.js new file mode 100644 index 0000000000..aeedae3ba3 --- /dev/null +++ b/client/app/visualizations/chart/plotly/prepareDefaultData.js @@ -0,0 +1,177 @@ +import { isNil, each, includes, isString, map, sortBy } from 'lodash'; +import { cleanNumber, normalizeValue, getSeriesAxis } from './utils'; +import { ColorPaletteArray } from '@/visualizations/ColorPalette'; + +function getSeriesColor(seriesOptions, seriesIndex) { + return seriesOptions.color || ColorPaletteArray[seriesIndex % ColorPaletteArray.length]; +} + +function getFontColor(backgroundColor) { + let result = '#333333'; + if (isString(backgroundColor)) { + let matches = /#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})/i.exec(backgroundColor); + let r; + let g; + let b; + if (matches) { + r = parseInt(matches[1], 16); + g = parseInt(matches[2], 16); + b = parseInt(matches[3], 16); + } else { + matches = /#?([0-9a-f])([0-9a-f])([0-9a-f])/i.exec(backgroundColor); + if (matches) { + r = parseInt(matches[1] + matches[1], 16); + g = parseInt(matches[2] + matches[2], 16); + b = parseInt(matches[3] + matches[3], 16); + } else { + return result; + } + } + + const lightness = r * 0.299 + g * 0.587 + b * 0.114; + if (lightness < 170) { + result = '#ffffff'; + } + } + + return result; +} + +function getHoverInfoPattern(options) { + const hasX = /{{\s*@@x\s*}}/.test(options.textFormat); + const hasName = /{{\s*@@name\s*}}/.test(options.textFormat); + let result = 'text'; + if (!hasX) result += '+x'; + if (!hasName) result += '+name'; + return result; +} + +function prepareBarSeries(series, options) { + series.type = 'bar'; + if (options.showDataLabels) { + series.textposition = 'inside'; + } + return series; +} + +function prepareLineSeries(series, options) { + series.mode = 'lines' + (options.showDataLabels ? '+text' : ''); + return series; +} + +function prepareAreaSeries(series, options) { + series.mode = 'lines' + (options.showDataLabels ? '+text' : ''); + series.fill = options.series.stacking ? 'tonexty' : 'tozeroy'; + return series; +} + +function prepareScatterSeries(series, options) { + series.type = 'scatter'; + series.mode = 'markers' + (options.showDataLabels ? '+text' : ''); + return series; +} + +function prepareBubbleSeries(series, options, { seriesColor, data }) { + series.mode = 'markers'; + series.marker = { + color: seriesColor, + size: map(data, i => i.size), + }; + return series; +} + +function prepareBoxSeries(series, options, { seriesColor }) { + series.type = 'box'; + series.mode = 'markers'; + + series.boxpoints = 'outliers'; + series.hoverinfo = false; + series.marker = { + color: seriesColor, + size: 3, + }; + if (options.showpoints) { + series.boxpoints = 'all'; + series.jitter = 0.3; + series.pointpos = -1.8; + } + return series; +} + +function prepareSeries(series, options, additionalOptions) { + const { hoverInfoPattern, index } = additionalOptions; + + const seriesOptions = options.seriesOptions[series.name] || { type: options.globalSeriesType }; + const seriesColor = getSeriesColor(seriesOptions, index); + const seriesYAxis = getSeriesAxis(series, options); + + // Sort by x - `Map` preserves order of items + const data = options.sortX ? sortBy(series.data, d => normalizeValue(d.x, options.xAxis.type)) : series.data; + + // For bubble/scatter charts `y` may be any (similar to `x`) - numeric is only bubble size; + // for other types `y` is always number + const cleanYValue = includes(['bubble', 'scatter'], seriesOptions.type) ? normalizeValue : (v) => { + v = cleanNumber(v); + return (options.missingValuesAsZero && isNil(v)) ? 0.0 : v; + }; + + const sourceData = new Map(); + const xValues = []; + const yValues = []; + const yErrorValues = []; + each(data, (row) => { + const x = normalizeValue(row.x, options.xAxis.type); // number/datetime/category + const y = cleanYValue(row.y, seriesYAxis === 'y2' ? options.yAxis[1].type : options.yAxis[0].type); // depends on series type! + const yError = cleanNumber(row.yError); // always number + const size = cleanNumber(row.size); // always number + sourceData.set(x, { + x, + y, + yError, + size, + yPercent: null, // will be updated later + row, + }); + xValues.push(x); + yValues.push(y); + yErrorValues.push(yError); + }); + + const plotlySeries = { + visible: true, + hoverinfo: hoverInfoPattern, + x: xValues, + y: yValues, + error_y: { + array: yErrorValues, + color: seriesColor, + }, + name: seriesOptions.name || series.name, + marker: { color: seriesColor }, + insidetextfont: { + color: getFontColor(seriesColor), + }, + yaxis: seriesYAxis, + sourceData, + }; + + additionalOptions = { ...additionalOptions, seriesColor, data }; + + switch (seriesOptions.type) { + case 'column': return prepareBarSeries(plotlySeries, options, additionalOptions); + case 'line': return prepareLineSeries(plotlySeries, options, additionalOptions); + case 'area': return prepareAreaSeries(plotlySeries, options, additionalOptions); + case 'scatter': return prepareScatterSeries(plotlySeries, options, additionalOptions); + case 'bubble': return prepareBubbleSeries(plotlySeries, options, additionalOptions); + case 'box': return prepareBoxSeries(plotlySeries, options, additionalOptions); + default: return plotlySeries; + } +} + +export default function prepareDefaultData(seriesList, options) { + const additionalOptions = { + hoverInfoPattern: getHoverInfoPattern(options), + }; + + return map(seriesList, (series, index) => prepareSeries(series, options, { ...additionalOptions, index })); +} diff --git a/client/app/visualizations/chart/plotly/prepareHeatmapData.js b/client/app/visualizations/chart/plotly/prepareHeatmapData.js new file mode 100644 index 0000000000..0f3448c669 --- /dev/null +++ b/client/app/visualizations/chart/plotly/prepareHeatmapData.js @@ -0,0 +1,109 @@ +import { map, max, uniq, sortBy, flatten, find } from 'lodash'; +import { createNumberFormatter } from '@/lib/value-format'; + +const defaultColorScheme = [ + [0, '#356aff'], + [0.14, '#4a7aff'], + [0.28, '#5d87ff'], + [0.42, '#7398ff'], + [0.56, '#fb8c8c'], + [0.71, '#ec6463'], + [0.86, '#ec4949'], + [1, '#e92827'], +]; + +function prepareSeries(series, options, additionalOptions) { + const { colorScheme, formatNumber } = additionalOptions; + + const plotlySeries = { + x: [], + y: [], + z: [], + type: 'heatmap', + name: '', + colorscale: colorScheme, + }; + + plotlySeries.x = uniq(map(series.data, v => v.x)); + plotlySeries.y = uniq(map(series.data, v => v.y)); + + if (options.sortX) { + plotlySeries.x = sortBy(plotlySeries.x); + } + + if (options.sortY) { + plotlySeries.y = sortBy(plotlySeries.y); + } + + if (options.reverseX) { + plotlySeries.x.reverse(); + } + + if (options.reverseY) { + plotlySeries.y.reverse(); + } + + const zMax = max(map(series.data, d => d.zVal)); + + // Use text trace instead of default annotation for better performance + const dataLabels = { + x: [], + y: [], + mode: 'text', + hoverinfo: 'skip', + showlegend: false, + text: [], + textfont: { + color: [], + }, + }; + + for (let i = 0; i < plotlySeries.y.length; i += 1) { + const item = []; + for (let j = 0; j < plotlySeries.x.length; j += 1) { + const datum = find( + series.data, + { x: plotlySeries.x[j], y: plotlySeries.y[i] }, + ); + + const zValue = datum && datum.zVal || 0; + item.push(zValue); + + if (isFinite(zMax) && options.showDataLabels) { + dataLabels.x.push(plotlySeries.x[j]); + dataLabels.y.push(plotlySeries.y[i]); + dataLabels.text.push(formatNumber(zValue)); + if (options.colorScheme && options.colorScheme === 'Custom...') { + dataLabels.textfont.color.push('white'); + } else { + dataLabels.textfont.color.push((zValue / zMax) < 0.25 ? 'white' : 'black'); + } + } + } + plotlySeries.z.push(item); + } + + if (isFinite(zMax) && options.showDataLabels) { + return [plotlySeries, dataLabels]; + } + return [plotlySeries]; +} + +export default function prepareHeatmapData(seriesList, options) { + let colorScheme = []; + + if (!options.colorScheme) { + colorScheme = defaultColorScheme; + } else if (options.colorScheme === 'Custom...') { + colorScheme = [[0, options.heatMinColor], [1, options.heatMaxColor]]; + } else { + colorScheme = options.colorScheme; + } + + const additionalOptions = { + colorScheme, + formatNumber: createNumberFormatter(options.numberFormat), + }; + + return flatten(map(seriesList, series => prepareSeries(series, options, additionalOptions))); +} diff --git a/client/app/visualizations/chart/plotly/prepareLayout.js b/client/app/visualizations/chart/plotly/prepareLayout.js new file mode 100644 index 0000000000..b028eecbb8 --- /dev/null +++ b/client/app/visualizations/chart/plotly/prepareLayout.js @@ -0,0 +1,128 @@ +import { filter, has, isNumber, isObject, isUndefined, map, max, min } from 'lodash'; +import { getPieDimensions } from './preparePieData'; + +function getAxisTitle(axis) { + return isObject(axis.title) ? axis.title.text : null; +} + +function getAxisScaleType(axis) { + switch (axis.type) { + case 'datetime': return 'date'; + case 'logarithmic': return 'log'; + default: return axis.type; + } +} + +function calculateAxisRange(seriesList, minValue, maxValue) { + if (!isNumber(minValue)) { + minValue = Math.min(0, min(map(seriesList, series => min(series.y)))); + } + if (!isNumber(maxValue)) { + maxValue = max(map(seriesList, series => max(series.y))); + } + return [minValue, maxValue]; +} + +function prepareXAxis(axisOptions, additionalOptions) { + const axis = { + title: getAxisTitle(axisOptions), + type: getAxisScaleType(axisOptions), + automargin: true, + }; + + if (additionalOptions.sortX && axis.type === 'category') { + if (additionalOptions.reverseX) { + axis.categoryorder = 'category descending'; + } else { + axis.categoryorder = 'category ascending'; + } + } + + if (!isUndefined(axisOptions.labels)) { + axis.showticklabels = axisOptions.labels.enabled; + } + + return axis; +} + +function prepareYAxis(axisOptions, additionalOptions, data) { + const axis = { + title: getAxisTitle(axisOptions), + type: getAxisScaleType(axisOptions), + automargin: true, + }; + + if (isNumber(axisOptions.rangeMin) || isNumber(axisOptions.rangeMax)) { + axis.range = calculateAxisRange(data, axisOptions.rangeMin, axisOptions.rangeMax); + } + + return axis; +} + +function preparePieLayout(layout, options, data) { + const hasName = /{{\s*@@name\s*}}/.test(options.textFormat); + + const { cellsInRow, cellWidth, cellHeight, xPadding } = getPieDimensions(data); + + if (hasName) { + layout.annotations = []; + } else { + layout.annotations = filter(map(data, (series, index) => { + const xPosition = (index % cellsInRow) * cellWidth; + const yPosition = Math.floor(index / cellsInRow) * cellHeight; + return { + x: xPosition + ((cellWidth - xPadding) / 2), + y: yPosition + cellHeight - 0.015, + xanchor: 'center', + yanchor: 'top', + text: series.name, + showarrow: false, + }; + })); + } + + return layout; +} + +function prepareDefaultLayout(layout, options, data) { + const ySeries = data.filter(s => s.yaxis !== 'y2'); + const y2Series = data.filter(s => s.yaxis === 'y2'); + + layout.xaxis = prepareXAxis(options.xAxis, options); + + layout.yaxis = prepareYAxis(options.yAxis[0], options, ySeries); + if (y2Series.length > 0) { + layout.yaxis2 = prepareYAxis(options.yAxis[1], options, y2Series); + layout.yaxis2.overlaying = 'y'; + layout.yaxis2.side = 'right'; + } + + if (options.series.stacking) { + layout.barmode = 'relative'; + } + + return layout; +} + +function prepareBoxLayout(layout, options, data) { + layout = prepareDefaultLayout(layout, options, data); + layout.boxmode = 'group'; + layout.boxgroupgap = 0.50; + return layout; +} + +export default function prepareLayout(element, options, data) { + const layout = { + margin: { l: 10, r: 10, b: 10, t: 25, pad: 4 }, + width: Math.floor(element.offsetWidth), + height: Math.floor(element.offsetHeight), + autosize: true, + showlegend: has(options, 'legend') ? options.legend.enabled : true, + }; + + switch (options.globalSeriesType) { + case 'pie': return preparePieLayout(layout, options, data); + case 'box': return prepareBoxLayout(layout, options, data); + default: return prepareDefaultLayout(layout, options, data); + } +} diff --git a/client/app/visualizations/chart/plotly/prepareLayout.test.js b/client/app/visualizations/chart/plotly/prepareLayout.test.js new file mode 100644 index 0000000000..6af330cbcd --- /dev/null +++ b/client/app/visualizations/chart/plotly/prepareLayout.test.js @@ -0,0 +1,64 @@ +/* eslint-disable global-require, import/no-unresolved */ +import prepareLayout from './prepareLayout'; + +const fakeElement = { offsetWidth: 400, offsetHeight: 300 }; + +describe('Visualizations', () => { + describe('Chart', () => { + describe('prepareLayout', () => { + test('Pie', () => { + const { input, output } = require('./fixtures/prepareLayout/pie'); + const layout = prepareLayout(fakeElement, input.options, input.series); + expect(layout).toEqual(output.layout); + }); + + test('Pie without annotations', () => { + const { input, output } = require('./fixtures/prepareLayout/pie-without-annotations'); + const layout = prepareLayout(fakeElement, input.options, input.series); + expect(layout).toEqual(output.layout); + }); + + test('Pie with multiple series', () => { + const { input, output } = require('./fixtures/prepareLayout/pie-multiple-series'); + const layout = prepareLayout(fakeElement, input.options, input.series); + expect(layout).toEqual(output.layout); + }); + + test('Box with single Y axis', () => { + const { input, output } = require('./fixtures/prepareLayout/box-single-axis'); + const layout = prepareLayout(fakeElement, input.options, input.series); + expect(layout).toEqual(output.layout); + }); + + test('Box with second Y axis', () => { + const { input, output } = require('./fixtures/prepareLayout/box-with-second-axis'); + const layout = prepareLayout(fakeElement, input.options, input.series); + expect(layout).toEqual(output.layout); + }); + + test('Default with single Y axis', () => { + const { input, output } = require('./fixtures/prepareLayout/default-single-axis'); + const layout = prepareLayout(fakeElement, input.options, input.series); + expect(layout).toEqual(output.layout); + }); + + test('Default with second Y axis', () => { + const { input, output } = require('./fixtures/prepareLayout/default-with-second-axis'); + const layout = prepareLayout(fakeElement, input.options, input.series); + expect(layout).toEqual(output.layout); + }); + + test('Default without legend', () => { + const { input, output } = require('./fixtures/prepareLayout/default-without-legend'); + const layout = prepareLayout(fakeElement, input.options, input.series); + expect(layout).toEqual(output.layout); + }); + + test('Default with stacking', () => { + const { input, output } = require('./fixtures/prepareLayout/default-with-stacking'); + const layout = prepareLayout(fakeElement, input.options, input.series); + expect(layout).toEqual(output.layout); + }); + }); + }); +}); diff --git a/client/app/visualizations/chart/plotly/preparePieData.js b/client/app/visualizations/chart/plotly/preparePieData.js new file mode 100644 index 0000000000..b8ac696993 --- /dev/null +++ b/client/app/visualizations/chart/plotly/preparePieData.js @@ -0,0 +1,96 @@ +import { each, includes, isString, map, reduce } from 'lodash'; +import d3 from 'd3'; +import { ColorPaletteArray } from '@/visualizations/ColorPalette'; + +import { cleanNumber, normalizeValue } from './utils'; + +export function getPieDimensions(series) { + const rows = series.length > 2 ? 2 : 1; + const cellsInRow = Math.ceil(series.length / rows); + const cellWidth = 1 / cellsInRow; + const cellHeight = 1 / rows; + const xPadding = 0.02; + const yPadding = 0.1; + + return { rows, cellsInRow, cellWidth, cellHeight, xPadding, yPadding }; +} + +function getPieHoverInfoPattern(options) { + const hasX = /{{\s*@@x\s*}}/.test(options.textFormat); + let result = 'text'; + if (!hasX) result += '+label'; + return result; +} + +function prepareSeries(series, options, additionalOptions) { + const { + cellWidth, cellHeight, xPadding, yPadding, cellsInRow, hasX, + index, hoverInfoPattern, getValueColor, + } = additionalOptions; + + const xPosition = (index % cellsInRow) * cellWidth; + const yPosition = Math.floor(index / cellsInRow) * cellHeight; + + const labels = []; + const values = []; + const sourceData = new Map(); + const seriesTotal = reduce(series.data, (result, row) => { + const y = cleanNumber(row.y); + return result + Math.abs(y); + }, 0); + each(series.data, (row) => { + const x = hasX ? normalizeValue(row.x, options.xAxis.type) : `Slice ${index}`; + const y = cleanNumber(row.y); + labels.push(x); + values.push(y); + sourceData.set(x, { + x, + y, + yPercent: y / seriesTotal * 100, + row, + }); + }); + + return { + visible: true, + values, + labels, + type: 'pie', + hole: 0.4, + marker: { + colors: map(series.data, row => getValueColor(row.x)), + }, + hoverinfo: hoverInfoPattern, + text: [], + textinfo: options.showDataLabels ? 'percent' : 'none', + textposition: 'inside', + textfont: { color: '#ffffff' }, + name: series.name, + direction: options.direction.type, + domain: { + x: [xPosition, xPosition + cellWidth - xPadding], + y: [yPosition, yPosition + cellHeight - yPadding], + }, + sourceData, + }; +} + +export default function preparePieData(seriesList, options) { + // we will use this to assign colors for values that have no explicitly set color + const getDefaultColor = d3.scale.ordinal().domain([]).range(ColorPaletteArray); + const valuesColors = {}; + each(options.valuesOptions, (item, key) => { + if (isString(item.color) && (item.color !== '')) { + valuesColors[key] = item.color; + } + }); + + const additionalOptions = { + ...getPieDimensions(seriesList), + hasX: includes(options.columnMapping, 'x'), + hoverInfoPattern: getPieHoverInfoPattern(options), + getValueColor: v => valuesColors[v] || getDefaultColor(v), + }; + + return map(seriesList, (series, index) => prepareSeries(series, options, { ...additionalOptions, index })); +} diff --git a/client/app/visualizations/chart/plotly/updateData.js b/client/app/visualizations/chart/plotly/updateData.js new file mode 100644 index 0000000000..b91f043ebe --- /dev/null +++ b/client/app/visualizations/chart/plotly/updateData.js @@ -0,0 +1,223 @@ +import { isNil, each, extend, filter, identity, includes, map, sortBy } from 'lodash'; +import { createNumberFormatter, formatSimpleTemplate } from '@/lib/value-format'; +import { normalizeValue } from './utils'; + +function shouldUseUnifiedXAxis(options) { + return options.sortX && (options.xAxis.type === 'category') && (options.globalSeriesType !== 'box'); +} + +function defaultFormatSeriesText(item) { + let result = item['@@y']; + if (item['@@yError'] !== undefined) { + result = `${result} \u00B1 ${item['@@yError']}`; + } + if (item['@@yPercent'] !== undefined) { + result = `${item['@@yPercent']} (${result})`; + } + if (item['@@size'] !== undefined) { + result = `${result}: ${item['@@size']}`; + } + return result; +} + +function defaultFormatSeriesTextForPie(item) { + return item['@@yPercent'] + ' (' + item['@@y'] + ')'; +} + +function createTextFormatter(options) { + if (options.textFormat === '') { + return options.globalSeriesType === 'pie' ? defaultFormatSeriesTextForPie : defaultFormatSeriesText; + } + return item => formatSimpleTemplate(options.textFormat, item); +} + +function formatValue(value, axis, options) { + let axisType = null; + switch (axis) { + case 'x': axisType = options.xAxis.type; break; + case 'y': axisType = options.yAxis[0].type; break; + case 'y2': axisType = options.yAxis[1].type; break; + // no default + } + return normalizeValue(value, axisType, options.dateTimeFormat); +} + +function updateSeriesText(seriesList, options) { + const formatNumber = createNumberFormatter(options.numberFormat); + const formatPercent = createNumberFormatter(options.percentFormat); + const formatText = createTextFormatter(options); + + const defaultY = options.missingValuesAsZero ? 0.0 : null; + + each(seriesList, (series) => { + const seriesOptions = options.seriesOptions[series.name] || { type: options.globalSeriesType }; + + series.text = []; + series.hover = []; + const xValues = (options.globalSeriesType === 'pie') ? series.labels : series.x; + xValues.forEach((x) => { + const text = { + '@@name': series.name, + }; + const item = series.sourceData.get(x) || { x, y: defaultY, row: { x, y: defaultY } }; + + const yValueIsAny = includes(['bubble', 'scatter'], seriesOptions.type); + + // for `formatValue` we have to use original value of `x` and `y`: `item.x`/`item.y` contains value + // already processed with `normalizeValue`, and if they were `moment` instances - they are formatted + // using default (ISO) date/time format. Here we need to use custom date/time format, so we pass original value + // to `formatValue` which will call `normalizeValue` again, but this time with different date/time format + // (if needed) + text['@@x'] = formatValue(item.row.x, 'x', options); + text['@@y'] = yValueIsAny ? formatValue(item.row.y, series.yaxis, options) : formatNumber(item.y); + if (item.yError !== undefined) { + text['@@yError'] = formatNumber(item.yError); + } + if (item.size !== undefined) { + text['@@size'] = formatNumber(item.size); + } + + if (options.series.percentValues || (options.globalSeriesType === 'pie')) { + text['@@yPercent'] = formatPercent(Math.abs(item.yPercent)); + } + + extend(text, item.row.$raw); + + series.text.push(formatText(text)); + }); + }); +} + +function updatePercentValues(seriesList, options) { + if (options.series.percentValues) { + // Some series may not have corresponding x-values; + // do calculations for each x only for series that do have that x + const sumOfCorrespondingPoints = new Map(); + each(seriesList, (series) => { + series.sourceData.forEach((item) => { + const sum = sumOfCorrespondingPoints.get(item.x) || 0; + sumOfCorrespondingPoints.set(item.x, sum + Math.abs(item.y || 0.0)); + }); + }); + + each(seriesList, (series) => { + const yValues = []; + + series.sourceData.forEach((item) => { + if (isNil(item.y) && !options.missingValuesAsZero) { + item.yPercent = null; + } else { + const sum = sumOfCorrespondingPoints.get(item.x); + item.yPercent = item.y / sum * 100; + } + yValues.push(item.yPercent); + }); + + series.y = yValues; + }); + } +} + +function getUnifiedXAxisValues(seriesList, sorted) { + const set = new Set(); + each(seriesList, (series) => { + // `Map.forEach` will walk items in insertion order + series.sourceData.forEach((item) => { + set.add(item.x); + }); + }); + + const result = [...set]; + return sorted ? sortBy(result, identity) : result; +} + +function updateUnifiedXAxisValues(seriesList, options) { + const unifiedX = getUnifiedXAxisValues(seriesList, options.sortX); + const defaultY = options.missingValuesAsZero ? 0.0 : null; + each(seriesList, (series) => { + series.x = []; + series.y = []; + series.error_y.array = []; + each(unifiedX, (x) => { + series.x.push(x); + const item = series.sourceData.get(x); + if (item) { + series.y.push(options.series.percentValues ? item.yPercent : item.y); + series.error_y.array.push(item.yError); + } else { + series.y.push(defaultY); + series.error_y.array.push(null); + } + }); + }); +} + +function updatePieData(seriesList, options) { + updateSeriesText(seriesList, options); +} + +function updateLineAreaData(seriesList, options) { + // Apply "percent values" modification + updatePercentValues(seriesList, options); + if (options.series.stacking) { + updateUnifiedXAxisValues(seriesList, options); + + // Calculate cumulative value for each x tick + const cumulativeValues = {}; + each(seriesList, (series) => { + series.y = map(series.y, (y, i) => { + if (isNil(y) && !options.missingValuesAsZero) { + return null; + } + const x = series.x[i]; + const stackedY = y + (cumulativeValues[x] || 0.0); + cumulativeValues[x] = stackedY; + return stackedY; + }); + }); + } else { + if (shouldUseUnifiedXAxis(options)) { + updateUnifiedXAxisValues(seriesList, options); + } + } + + // Finally - update text labels + updateSeriesText(seriesList, options); +} + +function updateDefaultData(seriesList, options) { + // Apply "percent values" modification + updatePercentValues(seriesList, options); + + if (!options.series.stacking) { + if (shouldUseUnifiedXAxis(options)) { + updateUnifiedXAxisValues(seriesList, options); + } + } + + // Finally - update text labels + updateSeriesText(seriesList, options); +} + +export default function updateData(seriesList, options) { + // Use only visible series + const visibleSeriesList = filter(seriesList, s => s.visible === true); + + if (visibleSeriesList.length > 0) { + switch (options.globalSeriesType) { + case 'pie': + updatePieData(visibleSeriesList, options); + break; + case 'line': + case 'area': + updateLineAreaData(visibleSeriesList, options); + break; + case 'heatmap': + break; + default: + updateDefaultData(visibleSeriesList, options); + break; + } + } + return seriesList; +} diff --git a/client/app/visualizations/chart/plotly/utils.js b/client/app/visualizations/chart/plotly/utils.js index 0c02734baf..006339c46f 100644 --- a/client/app/visualizations/chart/plotly/utils.js +++ b/client/app/visualizations/chart/plotly/utils.js @@ -1,80 +1,17 @@ -import { - isArray, isNumber, isString, isUndefined, includes, min, max, has, find, - each, values, sortBy, identity, filter, map, extend, reduce, pick, flatten, uniq, -} from 'lodash'; +import { isUndefined } from 'lodash'; import moment from 'moment'; -import d3 from 'd3'; import plotlyCleanNumber from 'plotly.js/src/lib/clean_number'; -import { createFormatter, formatSimpleTemplate } from '@/lib/value-format'; -import { ColorPaletteArray } from '@/visualizations/ColorPalette'; -function cleanNumber(value) { - return isUndefined(value) ? value : (plotlyCleanNumber(value) || 0.0); +export function cleanNumber(value) { + return isUndefined(value) ? value : plotlyCleanNumber(value); } -function defaultFormatSeriesText(item) { - let result = item['@@y']; - if (item['@@yError'] !== undefined) { - result = `${result} \u00B1 ${item['@@yError']}`; +export function getSeriesAxis(series, options) { + const seriesOptions = options.seriesOptions[series.name] || { type: options.globalSeriesType }; + if ((seriesOptions.yAxis === 1) && (!options.series.stacking || (seriesOptions.type === 'line'))) { + return 'y2'; } - if (item['@@yPercent'] !== undefined) { - result = `${item['@@yPercent']} (${result})`; - } - if (item['@@size'] !== undefined) { - result = `${result}: ${item['@@size']}`; - } - return result; -} - -function defaultFormatSeriesTextForPie(item) { - return item['@@yPercent'] + ' (' + item['@@y'] + ')'; -} - -function getFontColor(bgcolor) { - let result = '#333333'; - if (isString(bgcolor)) { - let matches = /#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})/i.exec(bgcolor); - let r; - let g; - let b; - if (matches) { - r = parseInt(matches[1], 16); - g = parseInt(matches[2], 16); - b = parseInt(matches[3], 16); - } else { - matches = /#?([0-9a-f])([0-9a-f])([0-9a-f])/i.exec(bgcolor); - if (matches) { - r = parseInt(matches[1] + matches[1], 16); - g = parseInt(matches[2] + matches[2], 16); - b = parseInt(matches[3] + matches[3], 16); - } else { - return result; - } - } - - const lightness = r * 0.299 + g * 0.587 + b * 0.114; - if (lightness < 170) { - result = '#ffffff'; - } - } - - return result; -} - -function getPieHoverInfoPattern(options) { - const hasX = /{{\s*@@x\s*}}/.test(options.textFormat); - let result = 'text'; - if (!hasX) result += '+label'; - return result; -} - -function getHoverInfoPattern(options) { - const hasX = /{{\s*@@x\s*}}/.test(options.textFormat); - const hasName = /{{\s*@@name\s*}}/.test(options.textFormat); - let result = 'text'; - if (!hasX) result += '+x'; - if (!hasName) result += '+name'; - return result; + return 'y'; } export function normalizeValue(value, axisType, dateTimeFormat = 'YYYY-MM-DD HH:mm:ss') { @@ -86,743 +23,3 @@ export function normalizeValue(value, axisType, dateTimeFormat = 'YYYY-MM-DD HH: } return value; } - -function naturalSort($a, $b) { - if ($a === $b) { - return 0; - } else if ($a < $b) { - return -1; - } - return 1; -} - -function calculateAxisRange(seriesList, minValue, maxValue) { - if (!isNumber(minValue)) { - minValue = Math.min(0, min(map(seriesList, series => min(series.y)))); - } - if (!isNumber(maxValue)) { - maxValue = max(map(seriesList, series => max(series.y))); - } - return [minValue, maxValue]; -} - -function getScaleType(scale) { - if (scale === 'datetime') { - return 'date'; - } - if (scale === 'logarithmic') { - return 'log'; - } - return scale; -} - -function getSeriesColor(seriesOptions, seriesIndex) { - return seriesOptions.color || ColorPaletteArray[seriesIndex % ColorPaletteArray.length]; -} - -function getTitle(axis) { - if (!isUndefined(axis) && !isUndefined(axis.title)) { - return axis.title.text; - } - return null; -} - -function setType(series, type, options) { - switch (type) { - case 'column': - series.type = 'bar'; - if (options.showDataLabels) { - series.textposition = 'inside'; - } - break; - case 'line': - series.mode = 'lines' + (options.showDataLabels ? '+text' : ''); - break; - case 'area': - series.mode = 'lines' + (options.showDataLabels ? '+text' : ''); - series.fill = options.series.stacking === null ? 'tozeroy' : 'tonexty'; - break; - case 'scatter': - series.type = 'scatter'; - series.mode = 'markers' + (options.showDataLabels ? '+text' : ''); - break; - case 'bubble': - series.mode = 'markers'; - break; - case 'box': - series.type = 'box'; - series.mode = 'markers'; - break; - default: - break; - } -} - -function calculateDimensions(series, options) { - const rows = series.length > 2 ? 2 : 1; - const cellsInRow = Math.ceil(series.length / rows); - const cellWidth = 1 / cellsInRow; - const cellHeight = 1 / rows; - const xPadding = 0.02; - const yPadding = 0.1; - - const hasX = includes(values(options.columnMapping), 'x'); - const hasY2 = !!find(series, (serie) => { - const seriesOptions = options.seriesOptions[serie.name] || { type: options.globalSeriesType }; - return (seriesOptions.yAxis === 1) && ( - (options.series.stacking === null) || (seriesOptions.type === 'line') - ); - }); - - return { - rows, cellsInRow, cellWidth, cellHeight, xPadding, yPadding, hasX, hasY2, - }; -} - -function getUnifiedXAxisValues(seriesList, sorted) { - const set = new Set(); - each(seriesList, (series) => { - // `Map.forEach` will walk items in insertion order - series.sourceData.forEach((item) => { - set.add(item.x); - }); - }); - - const result = []; - // `Set.forEach` will walk items in insertion order - set.forEach((item) => { - result.push(item); - }); - - return sorted ? sortBy(result, identity) : result; -} - -function preparePieData(seriesList, options) { - const { - cellWidth, cellHeight, xPadding, yPadding, cellsInRow, hasX, - } = calculateDimensions(seriesList, options); - - const formatNumber = createFormatter({ - displayAs: 'number', - numberFormat: options.numberFormat, - }); - const formatPercent = createFormatter({ - displayAs: 'number', - numberFormat: options.percentFormat, - }); - const formatText = options.textFormat === '' - ? defaultFormatSeriesTextForPie : - item => formatSimpleTemplate(options.textFormat, item); - - const hoverinfo = getPieHoverInfoPattern(options); - - // we will use this to assign colors for values that have not explicitly set color - const getDefaultColor = d3.scale.ordinal().domain([]).range(ColorPaletteArray); - const valuesColors = {}; - each(options.valuesOptions, (item, key) => { - if (isString(item.color) && (item.color !== '')) { - valuesColors[key] = item.color; - } - }); - - return map(seriesList, (serie, index) => { - const xPosition = (index % cellsInRow) * cellWidth; - const yPosition = Math.floor(index / cellsInRow) * cellHeight; - - const sourceData = new Map(); - const seriesTotal = reduce(serie.data, (result, row) => { - const y = cleanNumber(row.y); - return result + Math.abs(y); - }, 0); - each(serie.data, (row) => { - const x = normalizeValue(row.x); - const y = cleanNumber(row.y); - sourceData.set(x, { - x, - y, - yPercent: y / seriesTotal * 100, - raw: extend({}, row.$raw, { - // use custom display format - see also `updateSeriesText` - '@@x': normalizeValue(row.x, options.xAxis.type, options.dateTimeFormat), - }), - }); - }); - - return { - values: map(serie.data, i => i.y), - labels: map(serie.data, row => (hasX ? normalizeValue(row.x) : `Slice ${index}`)), - type: 'pie', - hole: 0.4, - marker: { - colors: map(serie.data, row => valuesColors[row.x] || getDefaultColor(row.x)), - }, - hoverinfo, - text: [], - textinfo: options.showDataLabels ? 'percent' : 'none', - textposition: 'inside', - textfont: { color: '#ffffff' }, - name: serie.name, - direction: options.direction.type, - domain: { - x: [xPosition, xPosition + cellWidth - xPadding], - y: [yPosition, yPosition + cellHeight - yPadding], - }, - sourceData, - formatNumber, - formatPercent, - formatText, - }; - }); -} - -function prepareHeatmapData(seriesList, options) { - const defaultColorScheme = [ - [0, '#356aff'], - [0.14, '#4a7aff'], - [0.28, '#5d87ff'], - [0.42, '#7398ff'], - [0.56, '#fb8c8c'], - [0.71, '#ec6463'], - [0.86, '#ec4949'], - [1, '#e92827'], - ]; - - const formatNumber = createFormatter({ - displayAs: 'number', - numberFormat: options.numberFormat, - }); - - let colorScheme = []; - - if (!options.colorScheme) { - colorScheme = defaultColorScheme; - } else if (options.colorScheme === 'Custom...') { - colorScheme = [[0, options.heatMinColor], [1, options.heatMaxColor]]; - } else { - colorScheme = options.colorScheme; - } - - return map(seriesList, (series) => { - const plotlySeries = { - x: [], - y: [], - z: [], - type: 'heatmap', - name: '', - colorscale: colorScheme, - }; - - plotlySeries.x = uniq(map(series.data, 'x')); - plotlySeries.y = uniq(map(series.data, 'y')); - - if (options.sortX) { - plotlySeries.x.sort(naturalSort); - } - - if (options.sortY) { - plotlySeries.y.sort(naturalSort); - } - - if (options.reverseX) { - plotlySeries.x.reverse(); - } - - if (options.reverseY) { - plotlySeries.y.reverse(); - } - - const zMax = max(map(series.data, 'zVal')); - - // Use text trace instead of default annotation for better performance - const dataLabels = { - x: [], - y: [], - mode: 'text', - hoverinfo: 'skip', - showlegend: false, - text: [], - textfont: { - color: [], - }, - }; - - for (let i = 0; i < plotlySeries.y.length; i += 1) { - const item = []; - for (let j = 0; j < plotlySeries.x.length; j += 1) { - const datum = find( - series.data, - { x: plotlySeries.x[j], y: plotlySeries.y[i] }, - ); - - const zValue = datum ? datum.zVal : 0; - item.push(zValue); - - if (isFinite(zMax) && options.showDataLabels) { - dataLabels.x.push(plotlySeries.x[j]); - dataLabels.y.push(plotlySeries.y[i]); - dataLabels.text.push(formatNumber(zValue)); - if (options.colorScheme && options.colorScheme === 'Custom...') { - dataLabels.textfont.color.push('white'); - } else { - dataLabels.textfont.color.push((zValue / zMax) < 0.25 ? 'white' : 'black'); - } - } - } - plotlySeries.z.push(item); - } - - if (isFinite(zMax) && options.showDataLabels) { - return [plotlySeries, dataLabels]; - } - return [plotlySeries]; - }); -} - -function prepareChartData(seriesList, options) { - const sortX = (options.sortX === true) || (options.sortX === undefined); - - const formatNumber = createFormatter({ - displayAs: 'number', - numberFormat: options.numberFormat, - }); - const formatPercent = createFormatter({ - displayAs: 'number', - numberFormat: options.percentFormat, - }); - const formatText = options.textFormat === '' - ? defaultFormatSeriesText : - item => formatSimpleTemplate(options.textFormat, item); - - const hoverinfo = getHoverInfoPattern(options); - - return map(seriesList, (series, index) => { - const seriesOptions = options.seriesOptions[series.name] || - { type: options.globalSeriesType }; - - const seriesColor = getSeriesColor(seriesOptions, index); - - // Sort by x - `Map` preserves order of items - const data = sortX ? sortBy(series.data, d => normalizeValue(d.x, options.xAxis.type)) : series.data; - - // For bubble/scatter charts `y` may be any (similar to `x`) - numeric is only bubble size; - // for other types `y` is always number - const cleanYValue = includes(['bubble', 'scatter'], seriesOptions.type) ? normalizeValue : cleanNumber; - - const sourceData = new Map(); - const xValues = []; - const yValues = []; - const yErrorValues = []; - each(data, (row) => { - const x = normalizeValue(row.x, options.xAxis.type); // number/datetime/category - const y = cleanYValue(row.y, options.yAxis[0].type); // depends on series type! - const yError = cleanNumber(row.yError); // always number - const size = cleanNumber(row.size); // always number - sourceData.set(x, { - x, - y, - yError, - size, - yPercent: null, // will be updated later - raw: extend({}, row.$raw, { - // use custom display format - see also `updateSeriesText` - '@@x': normalizeValue(row.x, options.xAxis.type, options.dateTimeFormat), - }), - }); - xValues.push(x); - yValues.push(y); - yErrorValues.push(yError); - }); - - const plotlySeries = { - visible: true, - hoverinfo, - x: xValues, - y: yValues, - error_y: { - array: yErrorValues, - color: seriesColor, - }, - name: seriesOptions.name || series.name, - marker: { color: seriesColor }, - insidetextfont: { - color: getFontColor(seriesColor), - }, - sourceData, - formatNumber, - formatPercent, - formatText, - }; - - if ( - (seriesOptions.yAxis === 1) && - ((options.series.stacking === null) || (seriesOptions.type === 'line')) - ) { - plotlySeries.yaxis = 'y2'; - } - - setType(plotlySeries, seriesOptions.type, options); - - if (seriesOptions.type === 'bubble') { - plotlySeries.marker = { - color: seriesColor, - size: map(data, i => i.size), - }; - } else if (seriesOptions.type === 'box') { - plotlySeries.boxpoints = 'outliers'; - plotlySeries.hoverinfo = false; - plotlySeries.marker = { - color: seriesColor, - size: 3, - }; - if (options.showpoints) { - plotlySeries.boxpoints = 'all'; - plotlySeries.jitter = 0.3; - plotlySeries.pointpos = -1.8; - } - } - - return plotlySeries; - }); -} - -export function prepareData(seriesList, options) { - if (options.globalSeriesType === 'pie') { - return preparePieData(seriesList, options); - } - if (options.globalSeriesType === 'heatmap') { - return flatten(prepareHeatmapData(seriesList, options)); - } - return prepareChartData(seriesList, options); -} - -export function prepareLayout(element, seriesList, options, data) { - const { - cellsInRow, cellWidth, cellHeight, xPadding, hasY2, - } = calculateDimensions(seriesList, options); - - const result = { - margin: { - l: 10, - r: 10, - b: 10, - t: 25, - pad: 4, - }, - width: Math.floor(element.offsetWidth), - height: Math.floor(element.offsetHeight), - autosize: true, - showlegend: has(options, 'legend') ? options.legend.enabled : true, - }; - - if (options.globalSeriesType === 'pie') { - const hasName = /{{\s*@@name\s*}}/.test(options.textFormat); - - if (hasName) { - result.annotations = []; - } else { - result.annotations = filter(map(seriesList, (series, index) => { - const xPosition = (index % cellsInRow) * cellWidth; - const yPosition = Math.floor(index / cellsInRow) * cellHeight; - return { - x: xPosition + ((cellWidth - xPadding) / 2), - y: yPosition + cellHeight - 0.015, - xanchor: 'center', - yanchor: 'top', - text: (options.seriesOptions[series.name] || {}).name || series.name, - showarrow: false, - }; - })); - } - } else { - if (options.globalSeriesType === 'box') { - result.boxmode = 'group'; - result.boxgroupgap = 0.50; - } - - result.xaxis = { - title: getTitle(options.xAxis), - type: getScaleType(options.xAxis.type), - automargin: true, - }; - - if (options.sortX && result.xaxis.type === 'category') { - if (options.reverseX) { - result.xaxis.categoryorder = 'category descending'; - } else { - result.xaxis.categoryorder = 'category ascending'; - } - } - - if (!isUndefined(options.xAxis.labels)) { - result.xaxis.showticklabels = options.xAxis.labels.enabled; - } - - if (isArray(options.yAxis)) { - result.yaxis = { - title: getTitle(options.yAxis[0]), - type: getScaleType(options.yAxis[0].type), - automargin: true, - }; - - if (isNumber(options.yAxis[0].rangeMin) || isNumber(options.yAxis[0].rangeMax)) { - result.yaxis.range = calculateAxisRange( - data.filter(s => !s.yaxis !== 'y2'), - options.yAxis[0].rangeMin, - options.yAxis[0].rangeMax, - ); - } - } - - if (hasY2 && !isUndefined(options.yAxis)) { - result.yaxis2 = { - title: getTitle(options.yAxis[1]), - type: getScaleType(options.yAxis[1].type), - overlaying: 'y', - side: 'right', - automargin: true, - }; - - if (isNumber(options.yAxis[1].rangeMin) || isNumber(options.yAxis[1].rangeMax)) { - result.yaxis2.range = calculateAxisRange( - data.filter(s => s.yaxis === 'y2'), - options.yAxis[1].rangeMin, - options.yAxis[1].rangeMax, - ); - } - } - - if (options.series.stacking) { - result.barmode = 'relative'; - } - } - - return result; -} - -function updateSeriesText(seriesList, options) { - each(seriesList, (series) => { - const seriesOptions = options.seriesOptions[series.name] || - { type: options.globalSeriesType }; - - series.text = []; - series.hover = []; - const xValues = (options.globalSeriesType === 'pie') ? series.labels : series.x; - xValues.forEach((x) => { - const text = { - '@@name': series.name, - // '@@x' is already in `item.$raw` - }; - const item = series.sourceData.get(x); - if (item) { - text['@@y'] = includes(['bubble', 'scatter'], seriesOptions.type) ? item.y : series.formatNumber(item.y); - if (item.yError !== undefined) { - text['@@yError'] = series.formatNumber(item.yError); - } - if (item.size !== undefined) { - text['@@size'] = series.formatNumber(item.size); - } - - if (options.series.percentValues || (options.globalSeriesType === 'pie')) { - text['@@yPercent'] = series.formatPercent(Math.abs(item.yPercent)); - } - - extend(text, item.raw); - } - - series.text.push(series.formatText(text)); - }); - }); - return seriesList; -} - -function updatePercentValues(seriesList, options) { - if (options.series.percentValues && (seriesList.length > 0)) { - // Some series may not have corresponding x-values; - // do calculations for each x only for series that do have that x - const sumOfCorrespondingPoints = new Map(); - each(seriesList, (series) => { - series.sourceData.forEach((item) => { - const sum = sumOfCorrespondingPoints.get(item.x) || 0; - sumOfCorrespondingPoints.set(item.x, sum + Math.abs(item.y)); - }); - }); - - each(seriesList, (series) => { - const yValues = []; - - series.sourceData.forEach((item) => { - const sum = sumOfCorrespondingPoints.get(item.x); - item.yPercent = Math.sign(item.y) * Math.abs(item.y) / sum * 100; - yValues.push(item.yPercent); - }); - - series.y = yValues; - }); - } - - return seriesList; -} - -function updateUnifiedXAxisValues(seriesList, options, sorted, defaultY) { - const unifiedX = getUnifiedXAxisValues(seriesList, sorted); - defaultY = defaultY === undefined ? null : defaultY; - each(seriesList, (series) => { - series.x = []; - series.y = []; - series.error_y.array = []; - each(unifiedX, (x) => { - series.x.push(x); - const item = series.sourceData.get(x); - if (item) { - series.y.push(options.series.percentValues ? item.yPercent : item.y); - series.error_y.array.push(item.yError); - } else { - series.y.push(defaultY); - series.error_y.array.push(null); - } - }); - }); -} - -export function updateData(seriesList, options) { - if (seriesList.length === 0) { - return seriesList; - } - if (options.globalSeriesType === 'pie') { - updateSeriesText(seriesList, options); - return seriesList; - } - if (options.globalSeriesType === 'heatmap') { - return seriesList; - } - - // Use only visible series - seriesList = filter(seriesList, s => s.visible === true); - - // Apply "percent values" modification - updatePercentValues(seriesList, options); - - const sortX = (options.sortX === true) || (options.sortX === undefined); - - if (options.series.stacking) { - if (['line', 'area'].indexOf(options.globalSeriesType) >= 0) { - updateUnifiedXAxisValues(seriesList, options, sortX, 0); - - // Calculate cumulative value for each x tick - let prevSeries = null; - each(seriesList, (series) => { - if (prevSeries) { - series.y = map(series.y, (y, i) => prevSeries.y[i] + y); - } - prevSeries = series; - }); - } - } else { - const useUnifiedXAxis = sortX && (options.xAxis.type === 'category') && (options.globalSeriesType !== 'box'); - if (useUnifiedXAxis) { - updateUnifiedXAxisValues(seriesList, options, sortX); - } - } - - // Finally - update text labels - updateSeriesText(seriesList, options); -} - -function fixLegendContainer(plotlyElement) { - const legend = plotlyElement.querySelector('.legend'); - if (legend) { - let node = legend.parentNode; - while (node) { - if (node.tagName.toLowerCase() === 'svg') { - node.style.overflow = 'visible'; - break; - } - node = node.parentNode; - } - } -} - -export function updateLayout(plotlyElement, layout, updatePlot) { - // update layout size to plot container - layout.width = Math.floor(plotlyElement.offsetWidth); - layout.height = Math.floor(plotlyElement.offsetHeight); - - const transformName = find([ - 'transform', - 'WebkitTransform', - 'MozTransform', - 'MsTransform', - 'OTransform', - ], prop => prop in plotlyElement.style); - - if (layout.width <= 600) { - // change legend orientation to horizontal; plotly has a bug with this - // legend alignment - it does not preserve enough space under the plot; - // so we'll hack this: update plot (it will re-render legend), compute - // legend height, reduce plot size by legend height (but not less than - // half of plot container's height - legend will have max height equal to - // plot height), re-render plot again and offset legend to the space under - // the plot. - layout.legend = { - orientation: 'h', - // locate legend inside of plot area - otherwise plotly will preserve - // some amount of space under the plot; also this will limit legend height - // to plot's height - y: 0, - x: 0, - xanchor: 'left', - yanchor: 'bottom', - }; - - // set `overflow: visible` to svg containing legend because later we will - // position legend outside of it - fixLegendContainer(plotlyElement); - - updatePlot(plotlyElement, pick(layout, ['width', 'height', 'legend'])).then(() => { - const legend = plotlyElement.querySelector('.legend'); // eslint-disable-line no-shadow - if (legend) { - // compute real height of legend - items may be split into few columnns, - // also scrollbar may be shown - const bounds = reduce(legend.querySelectorAll('.traces'), (result, node) => { - const b = node.getBoundingClientRect(); - result = result || b; - return { - top: Math.min(result.top, b.top), - bottom: Math.max(result.bottom, b.bottom), - }; - }, null); - // here we have two values: - // 1. height of plot container excluding height of legend items; - // it may be any value between 0 and plot container's height; - // 2. half of plot containers height. Legend cannot be larger than - // plot; if legend is too large, plotly will reduce it's height and - // show a scrollbar; in this case, height of plot === height of legend, - // so we can split container's height half by half between them. - layout.height = Math.floor(Math.max( - layout.height / 2, - layout.height - (bounds.bottom - bounds.top), - )); - // offset the legend - legend.style[transformName] = 'translate(0, ' + layout.height + 'px)'; - updatePlot(plotlyElement, pick(layout, ['height'])); - } - }); - } else { - layout.legend = { - orientation: 'v', - // vertical legend will be rendered properly, so just place it to the right - // side of plot - y: 1, - x: 1, - xanchor: 'left', - yanchor: 'top', - }; - - const legend = plotlyElement.querySelector('.legend'); - if (legend) { - legend.style[transformName] = null; - } - - updatePlot(plotlyElement, pick(layout, ['width', 'height', 'legend'])); - } -} diff --git a/client/app/visualizations/choropleth/utils.js b/client/app/visualizations/choropleth/utils.js index 15b3e80544..4b65171e59 100644 --- a/client/app/visualizations/choropleth/utils.js +++ b/client/app/visualizations/choropleth/utils.js @@ -1,6 +1,6 @@ import chroma from 'chroma-js'; import _ from 'lodash'; -import { createFormatter } from '@/lib/value-format'; +import { createNumberFormatter as createFormatter } from '@/lib/value-format'; export const AdditionalColors = { White: '#ffffff', @@ -13,10 +13,7 @@ export function darkenColor(color) { } export function createNumberFormatter(format, placeholder) { - const formatter = createFormatter({ - displayAs: 'number', - numberFormat: format, - }); + const formatter = createFormatter(format); return (value) => { if (_.isNumber(value) && isFinite(value)) { return formatter(value); diff --git a/client/app/visualizations/counter/Editor/FormatSettings.jsx b/client/app/visualizations/counter/Editor/FormatSettings.jsx new file mode 100644 index 0000000000..144a1e3566 --- /dev/null +++ b/client/app/visualizations/counter/Editor/FormatSettings.jsx @@ -0,0 +1,107 @@ +import React from 'react'; +import * as Grid from 'antd/lib/grid'; +import Input from 'antd/lib/input'; +import InputNumber from 'antd/lib/input-number'; +import Switch from 'antd/lib/switch'; +import { EditorPropTypes } from '@/visualizations'; + +import { isValueNumber } from '../utils'; + +export default function FormatSettings({ options, data, onOptionsChange }) { + const inputsEnabled = isValueNumber(data.rows, options); + return ( + + + + + + + onOptionsChange({ stringDecimal })} + /> + + + + + + + + + onOptionsChange({ stringDecChar: e.target.value })} + /> + + + + + + + + + onOptionsChange({ stringThouSep: e.target.value })} + /> + + + + + + + + + onOptionsChange({ stringPrefix: e.target.value })} + /> + + + + + + + + + onOptionsChange({ stringSuffix: e.target.value })} + /> + + + + + + ); +} + +FormatSettings.propTypes = EditorPropTypes; diff --git a/client/app/visualizations/counter/Editor/GeneralSettings.jsx b/client/app/visualizations/counter/Editor/GeneralSettings.jsx new file mode 100644 index 0000000000..554a588bc9 --- /dev/null +++ b/client/app/visualizations/counter/Editor/GeneralSettings.jsx @@ -0,0 +1,113 @@ +import { map } from 'lodash'; +import React from 'react'; +import * as Grid from 'antd/lib/grid'; +import Select from 'antd/lib/select'; +import Input from 'antd/lib/input'; +import InputNumber from 'antd/lib/input-number'; +import Switch from 'antd/lib/switch'; +import { EditorPropTypes } from '@/visualizations'; + +export default function GeneralSettings({ options, data, visualizationName, onOptionsChange }) { + return ( + + + + + + + onOptionsChange({ counterLabel: e.target.value })} + /> + + + + + + + + + + + + + + + + + + onOptionsChange({ rowNumber })} + /> + + + + + + + + + + + + + + + + + + onOptionsChange({ targetRowNumber })} + /> + + + + + + ); +} + +GeneralSettings.propTypes = EditorPropTypes; diff --git a/client/app/visualizations/counter/Editor/index.jsx b/client/app/visualizations/counter/Editor/index.jsx new file mode 100644 index 0000000000..e897a56672 --- /dev/null +++ b/client/app/visualizations/counter/Editor/index.jsx @@ -0,0 +1,28 @@ +import { merge } from 'lodash'; +import React from 'react'; +import Tabs from 'antd/lib/tabs'; +import { EditorPropTypes } from '@/visualizations'; + +import GeneralSettings from './GeneralSettings'; +import FormatSettings from './FormatSettings'; + +export default function Editor(props) { + const { options, onOptionsChange } = props; + + const optionsChanged = (newOptions) => { + onOptionsChange(merge({}, options, newOptions)); + }; + + return ( + + General}> + + + Format}> + + + + ); +} + +Editor.propTypes = EditorPropTypes; diff --git a/client/app/visualizations/counter/Renderer.jsx b/client/app/visualizations/counter/Renderer.jsx new file mode 100644 index 0000000000..417ef24edb --- /dev/null +++ b/client/app/visualizations/counter/Renderer.jsx @@ -0,0 +1,73 @@ +import { isFinite } from 'lodash'; +import React, { useState, useEffect } from 'react'; +import cx from 'classnames'; +import resizeObserver from '@/services/resizeObserver'; +import { RendererPropTypes } from '@/visualizations'; + +import { getCounterData } from './utils'; + +import './render.less'; + +function getCounterStyles(scale) { + return { + msTransform: `scale(${scale})`, + MozTransform: `scale(${scale})`, + WebkitTransform: `scale(${scale})`, + transform: `scale(${scale})`, + }; +} + +function getCounterScale(container) { + const inner = container.firstChild; + const scale = Math.min(container.offsetWidth / inner.offsetWidth, container.offsetHeight / inner.offsetHeight); + return Number(isFinite(scale) ? scale : 1).toFixed(2); // keep only two decimal places +} + +export default function Renderer({ data, options, visualizationName }) { + const [scale, setScale] = useState('1.00'); + const [container, setContainer] = useState(null); + + useEffect(() => { + if (container) { + const unwatch = resizeObserver(container, () => { + setScale(getCounterScale(container)); + }); + return unwatch; + } + }, [container]); + + useEffect(() => { + if (container) { + // update scaling when options or data change (new formatting, values, etc. + // may change inner container dimensions which will not be tracked by `resizeObserver`); + setScale(getCounterScale(container)); + } + }, [data, options, container]); + + const { + showTrend, trendPositive, + counterValue, counterValueTooltip, + targetValue, targetValueTooltip, + counterLabel, + } = getCounterData(data.rows, options, visualizationName); + return ( +
    +
    +
    +
    {counterValue}
    + {targetValue && ( +
    ({targetValue})
    + )} +
    {counterLabel}
    +
    +
    +
    + ); +} + +Renderer.propTypes = RendererPropTypes; diff --git a/client/app/visualizations/counter/counter-editor.html b/client/app/visualizations/counter/counter-editor.html deleted file mode 100644 index f47238c4ac..0000000000 --- a/client/app/visualizations/counter/counter-editor.html +++ /dev/null @@ -1,95 +0,0 @@ -
    - -
    -
    - -
    - -
    -
    -
    - -
    - -
    -
    -
    - -
    - -
    -
    -
    - -
    - -
    -
    -
    - -
    - -
    -
    -
    -
    - -
    -
    -
    - -
    -
    - -
    - -
    -
    -
    - -
    - -
    -
    -
    - -
    - -
    -
    -
    - -
    - -
    -
    -
    - -
    - -
    -
    -
    -
    - -
    -
    -
    -
    diff --git a/client/app/visualizations/counter/counter.html b/client/app/visualizations/counter/counter.html deleted file mode 100644 index d7493f4378..0000000000 --- a/client/app/visualizations/counter/counter.html +++ /dev/null @@ -1,16 +0,0 @@ - -
    - {{ counterValue }} - ({{ targetValue }}) - {{counterLabel}} -
    -
    diff --git a/client/app/visualizations/counter/index.js b/client/app/visualizations/counter/index.js index 2ece76b2b2..07bd18af5d 100644 --- a/client/app/visualizations/counter/index.js +++ b/client/app/visualizations/counter/index.js @@ -1,10 +1,7 @@ -import { isNumber, toString } from 'lodash'; -import numeral from 'numeral'; -import { angular2react } from 'angular2react'; import { registerVisualization } from '@/visualizations'; -import counterTemplate from './counter.html'; -import counterEditorTemplate from './counter-editor.html'; +import Renderer from './Renderer'; +import Editor from './Editor'; const DEFAULT_OPTIONS = { counterLabel: '', @@ -17,207 +14,16 @@ const DEFAULT_OPTIONS = { tooltipFormat: '0,0.000', // TODO: Show in editor }; -// TODO: allow user to specify number format string instead of delimiters only -// It will allow to remove this function (move all that weird formatting logic to a migration -// that will set number format for all existing counter visualization) -function numberFormat(value, decimalPoints, decimalDelimiter, thousandsDelimiter) { - // Temporarily update locale data (restore defaults after formatting) - const locale = numeral.localeData(); - const savedDelimiters = locale.delimiters; +export default function init() { + registerVisualization({ + type: 'COUNTER', + name: 'Counter', + getOptions: options => ({ ...DEFAULT_OPTIONS, ...options }), + Renderer, + Editor, - // Mimic old behavior - AngularJS `number` filter defaults: - // - `,` as thousands delimiter - // - `.` as decimal delimiter - // - three decimal points - locale.delimiters = { - thousands: ',', - decimal: '.', - }; - let formatString = '0,0.000'; - if ( - (Number.isFinite(decimalPoints) && (decimalPoints >= 0)) || - decimalDelimiter || - thousandsDelimiter - ) { - locale.delimiters = { - thousands: thousandsDelimiter, - decimal: decimalDelimiter || '.', - }; - - formatString = '0,0'; - if (decimalPoints > 0) { - formatString += '.'; - while (decimalPoints > 0) { - formatString += '0'; - decimalPoints -= 1; - } - } - } - const result = numeral(value).format(formatString); - - locale.delimiters = savedDelimiters; - return result; -} - -// TODO: Need to review this function, it does not properly handle edge cases. -function getRowNumber(index, size) { - if (index >= 0) { - return index - 1; - } - - if (Math.abs(index) > size) { - index %= size; - } - - return size + index; -} - -function formatValue(value, { stringPrefix, stringSuffix, stringDecimal, stringDecChar, stringThouSep }) { - if (isNumber(value)) { - value = numberFormat(value, stringDecimal, stringDecChar, stringThouSep); - return toString(stringPrefix) + value + toString(stringSuffix); - } - return toString(value); -} - -function formatTooltip(value, formatString) { - if (isNumber(value)) { - return numeral(value).format(formatString); - } - return toString(value); -} - -const CounterRenderer = { - template: counterTemplate, - bindings: { - data: '<', - options: '<', - visualizationName: '<', - }, - controller($scope, $element, $timeout) { - $scope.fontSize = '1em'; - - $scope.scale = 1; - const root = $element[0].querySelector('counter'); - const container = $element[0].querySelector('counter > div'); - $scope.handleResize = () => { - const scale = Math.min(root.offsetWidth / container.offsetWidth, root.offsetHeight / container.offsetHeight); - $scope.scale = Math.floor(scale * 100) / 100; // keep only two decimal places - }; - - const update = () => { - const options = this.options; - const data = this.data.rows; - - if (data.length > 0) { - const rowNumber = getRowNumber(options.rowNumber, data.length); - const targetRowNumber = getRowNumber(options.targetRowNumber, data.length); - const counterColName = options.counterColName; - const targetColName = options.targetColName; - const counterLabel = options.counterLabel; - - if (counterLabel) { - $scope.counterLabel = counterLabel; - } else { - $scope.counterLabel = this.visualizationName; - } - - if (options.countRow) { - $scope.counterValue = data.length; - } else if (counterColName) { - $scope.counterValue = data[rowNumber][counterColName]; - } - - $scope.showTrend = false; - if (targetColName) { - $scope.targetValue = data[targetRowNumber][targetColName]; - - if (Number.isFinite($scope.counterValue) && Number.isFinite($scope.targetValue)) { - const delta = $scope.counterValue - $scope.targetValue; - $scope.showTrend = true; - $scope.trendPositive = delta >= 0; - } - } else { - $scope.targetValue = null; - } - - $scope.counterValueTooltip = formatTooltip($scope.counterValue, options.tooltipFormat); - $scope.targetValueTooltip = formatTooltip($scope.targetValue, options.tooltipFormat); - - $scope.counterValue = formatValue($scope.counterValue, options); - - if (options.formatTargetValue) { - $scope.targetValue = formatValue($scope.targetValue, options); - } else { - if (Number.isFinite($scope.targetValue)) { - $scope.targetValue = numeral($scope.targetValue).format('0[.]00[0]'); - } - } - } - - $timeout(() => { - $scope.handleResize(); - }); - }; - - $scope.$watch('$ctrl.data', update); - $scope.$watch('$ctrl.options', update, true); - }, -}; - -const CounterEditor = { - template: counterEditorTemplate, - bindings: { - data: '<', - options: '<', - visualizationName: '<', - onOptionsChange: '<', - }, - controller($scope) { - this.currentTab = 'general'; - this.changeTab = (tab) => { - this.currentTab = tab; - }; - - this.isValueNumber = () => { - const options = this.options; - const data = this.data.rows; - - if (data.length > 0) { - const rowNumber = getRowNumber(options.rowNumber, data.length); - const counterColName = options.counterColName; - - if (options.countRow) { - this.counterValue = data.length; - } else if (counterColName) { - this.counterValue = data[rowNumber][counterColName]; - } - } - - return isNumber(this.counterValue); - }; - - $scope.$watch('$ctrl.options', (options) => { - this.onOptionsChange(options); - }, true); - }, -}; - -export default function init(ngModule) { - ngModule.component('counterRenderer', CounterRenderer); - ngModule.component('counterEditor', CounterEditor); - - ngModule.run(($injector) => { - registerVisualization({ - type: 'COUNTER', - name: 'Counter', - getOptions: options => ({ ...DEFAULT_OPTIONS, ...options }), - Renderer: angular2react('counterRenderer', CounterRenderer, $injector), - Editor: angular2react('counterEditor', CounterEditor, $injector), - - defaultColumns: 2, - defaultRows: 5, - }); + defaultColumns: 2, + defaultRows: 5, }); } diff --git a/client/app/visualizations/counter/render.less b/client/app/visualizations/counter/render.less new file mode 100755 index 0000000000..252d0c0242 --- /dev/null +++ b/client/app/visualizations/counter/render.less @@ -0,0 +1,46 @@ +.counter-visualization-container { + display: block; + text-align: center; + padding: 15px 10px; + overflow: hidden; + + .counter-visualization-content { + margin: 0; + padding: 0; + font-size: 80px; + line-height: normal; + overflow: hidden; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + .counter-visualization-value, + .counter-visualization-target { + font-size: 1em; + display: block; + } + + .counter-visualization-label { + font-size: 0.5em; + display: block; + } + + .counter-visualization-target { + color: #ccc; + } + + .counter-visualization-label { + font-size: 0.5em; + display: block; + } + } + + &.trend-positive .counter-visualization-value { + color: #5cb85c; + } + + &.trend-negative .counter-visualization-value { + color: #d9534f; + } +} diff --git a/client/app/visualizations/counter/utils.js b/client/app/visualizations/counter/utils.js new file mode 100644 index 0000000000..8a4bd1b0f6 --- /dev/null +++ b/client/app/visualizations/counter/utils.js @@ -0,0 +1,141 @@ +import { isNumber, isFinite, toString } from 'lodash'; +import numeral from 'numeral'; + +// TODO: allow user to specify number format string instead of delimiters only +// It will allow to remove this function (move all that weird formatting logic to a migration +// that will set number format for all existing counter visualization) +function numberFormat(value, decimalPoints, decimalDelimiter, thousandsDelimiter) { + // Temporarily update locale data (restore defaults after formatting) + const locale = numeral.localeData(); + const savedDelimiters = locale.delimiters; + + // Mimic old behavior - AngularJS `number` filter defaults: + // - `,` as thousands delimiter + // - `.` as decimal delimiter + // - three decimal points + locale.delimiters = { + thousands: ',', + decimal: '.', + }; + let formatString = '0,0.000'; + if ( + (Number.isFinite(decimalPoints) && (decimalPoints >= 0)) || + decimalDelimiter || + thousandsDelimiter + ) { + locale.delimiters = { + thousands: thousandsDelimiter, + decimal: decimalDelimiter || '.', + }; + + formatString = '0,0'; + if (decimalPoints > 0) { + formatString += '.'; + while (decimalPoints > 0) { + formatString += '0'; + decimalPoints -= 1; + } + } + } + const result = numeral(value).format(formatString); + + locale.delimiters = savedDelimiters; + return result; +} + +// 0 - special case, use first record +// 1..N - 1-based record number from beginning (wraps if greater than dataset size) +// -1..-N - 1-based record number from end (wraps if greater than dataset size) +function getRowNumber(index, rowsCount) { + index = parseInt(index, 10) || 0; + if (index === 0) { + return index; + } + const wrappedIndex = (Math.abs(index) - 1) % rowsCount; + return index > 0 ? wrappedIndex : rowsCount - wrappedIndex - 1; +} + +function formatValue(value, { stringPrefix, stringSuffix, stringDecimal, stringDecChar, stringThouSep }) { + if (isNumber(value)) { + value = numberFormat(value, stringDecimal, stringDecChar, stringThouSep); + return toString(stringPrefix) + value + toString(stringSuffix); + } + return toString(value); +} + +function formatTooltip(value, formatString) { + if (isNumber(value)) { + return numeral(value).format(formatString); + } + return toString(value); +} + +export function getCounterData(rows, options, visualizationName) { + const result = {}; + + const rowsCount = rows.length; + if (rowsCount > 0) { + const rowNumber = getRowNumber(options.rowNumber, rowsCount); + const targetRowNumber = getRowNumber(options.targetRowNumber, rowsCount); + const counterColName = options.counterColName; + const targetColName = options.targetColName; + const counterLabel = options.counterLabel; + + if (counterLabel) { + result.counterLabel = counterLabel; + } else { + result.counterLabel = visualizationName; + } + + if (options.countRow) { + result.counterValue = rowsCount; + } else if (counterColName) { + result.counterValue = rows[rowNumber][counterColName]; + } + + result.showTrend = false; + if (targetColName) { + result.targetValue = rows[targetRowNumber][targetColName]; + + if (Number.isFinite(result.counterValue) && isFinite(result.targetValue)) { + const delta = result.counterValue - result.targetValue; + result.showTrend = true; + result.trendPositive = delta >= 0; + } + } else { + result.targetValue = null; + } + + result.counterValueTooltip = formatTooltip(result.counterValue, options.tooltipFormat); + result.targetValueTooltip = formatTooltip(result.targetValue, options.tooltipFormat); + + result.counterValue = formatValue(result.counterValue, options); + + if (options.formatTargetValue) { + result.targetValue = formatValue(result.targetValue, options); + } else { + if (isFinite(result.targetValue)) { + result.targetValue = numeral(result.targetValue).format('0[.]00[0]'); + } + } + } + + return result; +} + +export function isValueNumber(rows, options) { + if (options.countRow) { + return true; // array length is always a number + } + + const rowsCount = rows.length; + if (rowsCount > 0) { + const rowNumber = getRowNumber(options.rowNumber, rowsCount); + const counterColName = options.counterColName; + if (counterColName) { + return isNumber(rows[rowNumber][counterColName]); + } + } + + return false; +} diff --git a/client/app/visualizations/index.js b/client/app/visualizations/index.js index ba731d9cad..02ddad2287 100644 --- a/client/app/visualizations/index.js +++ b/client/app/visualizations/index.js @@ -25,6 +25,7 @@ export const RendererPropTypes = { data: Data.isRequired, options: VisualizationOptions.isRequired, onOptionsChange: PropTypes.func, // (newOptions) => void + context: PropTypes.oneOf(['query', 'widget']).isRequired, }; // For each visualization's editor diff --git a/client/app/visualizations/table/Renderer.jsx b/client/app/visualizations/table/Renderer.jsx index 2880bb81dc..35b5bd70a4 100644 --- a/client/app/visualizations/table/Renderer.jsx +++ b/client/app/visualizations/table/Renderer.jsx @@ -8,7 +8,7 @@ import { prepareColumns, filterRows, sortRows } from './utils'; import './renderer.less'; -export default function Renderer({ options, data }) { +export default function Renderer({ options, data, context }) { const [rowKeyPrefix, setRowKeyPrefix] = useState(`row:1:${options.itemsPerPage}:`); const [searchTerm, setSearchTerm] = useState(''); const [orderBy, setOrderBy] = useState([]); @@ -61,11 +61,13 @@ export default function Renderer({ options, data }) { return (

    rowKeyPrefix + index} pagination={{ + size: context === 'widget' ? 'small' : '', position: 'bottom', pageSize: options.itemsPerPage, hideOnSinglePage: true, diff --git a/client/app/visualizations/table/renderer.less b/client/app/visualizations/table/renderer.less index 05dcb66017..2ee4e5a318 100644 --- a/client/app/visualizations/table/renderer.less +++ b/client/app/visualizations/table/renderer.less @@ -8,7 +8,7 @@ margin-bottom: 0; } - .ant-table-body { + .ant-table { overflow-x: auto; } @@ -100,4 +100,27 @@ } } } + + /* START table x scroll */ + .dashboard-widget-wrapper:not(.widget-auto-height-enabled) & { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + + & div { + height: inherit; + } + + .ant-spin-container { + display: flex; + flex-direction: column; + + .ant-table { + flex-grow: 1; + } + } + } + /* END */ } diff --git a/client/cypress/integration/dashboard/grid_compliant_widgets_spec.js b/client/cypress/integration/dashboard/grid_compliant_widgets_spec.js index b30041e155..85541d8ebe 100644 --- a/client/cypress/integration/dashboard/grid_compliant_widgets_spec.js +++ b/client/cypress/integration/dashboard/grid_compliant_widgets_spec.js @@ -1,7 +1,7 @@ /* global cy */ import { createDashboard, addTextbox } from '../../support/redash-api'; -import { getWidgetTestId, editDashboard, dragBy, resizeBy } from '../../support/dashboard'; +import { getWidgetTestId, editDashboard, resizeBy } from '../../support/dashboard'; describe('Grid compliant widgets', () => { @@ -26,19 +26,22 @@ describe('Grid compliant widgets', () => { }); it('stays put when dragged under snap threshold', () => { - dragBy(cy.get('@textboxEl'), 90) + cy.get('@textboxEl') + .dragBy(90) .invoke('offset') .should('have.property', 'left', 15); // no change, 15 -> 15 }); it('moves one column when dragged over snap threshold', () => { - dragBy(cy.get('@textboxEl'), 110) + cy.get('@textboxEl') + .dragBy(110) .invoke('offset') .should('have.property', 'left', 215); // moved by 200, 15 -> 215 }); it('moves two columns when dragged over snap threshold', () => { - dragBy(cy.get('@textboxEl'), 330) + cy.get('@textboxEl') + .dragBy(330) .invoke('offset') .should('have.property', 'left', 415); // moved by 400, 15 -> 415 }); @@ -49,7 +52,8 @@ describe('Grid compliant widgets', () => { cy.route('POST', 'api/widgets/*').as('WidgetSave'); editDashboard(); - dragBy(cy.get('@textboxEl'), 330); + cy.get('@textboxEl') + .dragBy(330); cy.wait('@WidgetSave'); }); }); diff --git a/client/cypress/integration/dashboard/widget_spec.js b/client/cypress/integration/dashboard/widget_spec.js index 743035e559..7a42bbd1e6 100644 --- a/client/cypress/integration/dashboard/widget_spec.js +++ b/client/cypress/integration/dashboard/widget_spec.js @@ -139,4 +139,36 @@ describe('Widget', () => { }); }); }); + + it('sets the correct height of table visualization', function () { + const queryData = { + query: `select '${'loremipsum'.repeat(15)}' FROM generate_series(1,15)`, + }; + + const widgetOptions = { position: { col: 0, row: 0, sizeX: 3, sizeY: 10, autoHeight: false } }; + + createQueryAndAddWidget(this.dashboardId, queryData, widgetOptions).then(() => { + cy.visit(this.dashboardUrl); + cy.getByTestId('TableVisualization') + .its('0.offsetHeight') + .should('eq', 381); + cy.percySnapshot('Shows correct height of table visualization'); + }); + }); + + it('shows fixed pagination for overflowing tabular content ', function () { + const queryData = { + query: 'select \'lorem ipsum\' FROM generate_series(1,50)', + }; + + const widgetOptions = { position: { col: 0, row: 0, sizeX: 3, sizeY: 10, autoHeight: false } }; + + createQueryAndAddWidget(this.dashboardId, queryData, widgetOptions).then(() => { + cy.visit(this.dashboardUrl); + cy.getByTestId('TableVisualization') + .next('.ant-pagination.mini') + .should('be.visible'); + cy.percySnapshot('Shows fixed mini pagination for overflowing tabular content'); + }); + }); }); diff --git a/client/cypress/integration/query/parameter_spec.js b/client/cypress/integration/query/parameter_spec.js index 63eeeaea31..e6824f9a11 100644 --- a/client/cypress/integration/query/parameter_spec.js +++ b/client/cypress/integration/query/parameter_spec.js @@ -506,4 +506,82 @@ describe('Parameter', () => { cy.getByTestId('ExecuteButton').should('not.be.disabled'); }); }); + + describe('Draggable', () => { + beforeEach(() => { + const queryData = { + name: 'Draggable', + query: "SELECT '{{param1}}', '{{param2}}', '{{param3}}', '{{param4}}' AS parameter", + options: { + parameters: [ + { name: 'param1', title: 'Parameter 1', type: 'text' }, + { name: 'param2', title: 'Parameter 2', type: 'text' }, + { name: 'param3', title: 'Parameter 3', type: 'text' }, + { name: 'param4', title: 'Parameter 4', type: 'text' }, + ], + }, + }; + + createQuery(queryData, false) + .then(({ id }) => cy.visit(`/queries/${id}/source`)); + + cy.get('.parameter-block') + .first() + .invoke('width') + .as('paramWidth'); + }); + + const dragParam = (paramName, offsetLeft, offsetTop) => { + cy.getByTestId(`DragHandle-${paramName}`) + .trigger('mouseover') + .trigger('mousedown'); + + cy.get('.parameter-dragged .drag-handle') + .trigger('mousemove', offsetLeft, offsetTop, { force: true }) + .trigger('mouseup', { force: true }); + }; + + it('is possible to rearrange parameters', function () { + dragParam('param1', this.paramWidth, 1); + dragParam('param4', -this.paramWidth, 1); + + cy.reload(); + + const expectedOrder = ['Parameter 2', 'Parameter 1', 'Parameter 4', 'Parameter 3']; + cy.get('.parameter-container label') + .each(($label, index) => expect($label).to.have.text(expectedOrder[index])); + }); + }); + + describe('Parameter Settings', () => { + beforeEach(() => { + const queryData = { + name: 'Draggable', + query: "SELECT '{{parameter}}' AS parameter", + options: { + parameters: [ + { name: 'parameter', title: 'Parameter', type: 'text' }, + ], + }, + }; + + createQuery(queryData, false) + .then(({ id }) => cy.visit(`/queries/${id}/source`)); + + cy.getByTestId('ParameterSettings-parameter').click(); + }); + + it('changes the parameter title', () => { + cy.getByTestId('ParameterTitleInput') + .type('{selectall}New Parameter Name'); + cy.getByTestId('SaveParameterSettings') + .click(); + + cy.contains('Query saved'); + cy.reload(); + + cy.getByTestId('ParameterName-parameter') + .contains('label', 'New Parameter Name'); + }); + }); }); diff --git a/client/cypress/integration/visualizations/counter_spec.js b/client/cypress/integration/visualizations/counter_spec.js new file mode 100644 index 0000000000..dadfa68f49 --- /dev/null +++ b/client/cypress/integration/visualizations/counter_spec.js @@ -0,0 +1,207 @@ +/* global cy, Cypress */ + +import { createQuery } from '../../support/redash-api'; + +const SQL = ` + SELECT 27182.8182846 AS a, 20000 AS b, 'lorem' AS c UNION ALL + SELECT 31415.9265359 AS a, 40000 AS b, 'ipsum' AS c +`; + +describe('Counter', () => { + const viewportWidth = Cypress.config('viewportWidth'); + + beforeEach(() => { + cy.login(); + createQuery({ query: SQL }).then(({ id }) => { + cy.visit(`queries/${id}/source`); + cy.getByTestId('ExecuteButton').click(); + }); + }); + + it('creates simple Counter', () => { + cy.clickThrough(` + NewVisualization + VisualizationType + VisualizationType.COUNTER + + Counter.General.ValueColumn + Counter.General.ValueColumn.a + `); + + cy.getByTestId('VisualizationPreview').find('.counter-visualization-container').should('exist'); + + // wait a bit before taking snapshot + cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting + cy.percySnapshot('Visualizations - Counter (with defaults)', { widths: [viewportWidth] }); + }); + + it('creates Counter with custom label', () => { + cy.clickThrough(` + NewVisualization + VisualizationType + VisualizationType.COUNTER + + Counter.General.ValueColumn + Counter.General.ValueColumn.a + `); + + cy.fillInputs({ + 'Counter.General.Label': 'Custom Label', + }); + + cy.getByTestId('VisualizationPreview').find('.counter-visualization-container').should('exist'); + + // wait a bit before taking snapshot + cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting + cy.percySnapshot('Visualizations - Counter (custom label)', { widths: [viewportWidth] }); + }); + + it('creates Counter with non-numeric value', () => { + cy.clickThrough(` + NewVisualization + VisualizationType + VisualizationType.COUNTER + + Counter.General.ValueColumn + Counter.General.ValueColumn.c + + Counter.General.TargetValueColumn + Counter.General.TargetValueColumn.c + `); + + cy.fillInputs({ + 'Counter.General.TargetValueRowNumber': '2', + }); + + cy.getByTestId('VisualizationPreview').find('.counter-visualization-container').should('exist'); + + // wait a bit before taking snapshot + cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting + cy.percySnapshot('Visualizations - Counter (non-numeric value)', { widths: [viewportWidth] }); + }); + + it('creates Counter with target value (trend positive)', () => { + cy.clickThrough(` + NewVisualization + VisualizationType + VisualizationType.COUNTER + + Counter.General.ValueColumn + Counter.General.ValueColumn.a + + Counter.General.TargetValueColumn + Counter.General.TargetValueColumn.b + `); + + cy.getByTestId('VisualizationPreview').find('.counter-visualization-container').should('exist'); + + // wait a bit before taking snapshot + cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting + cy.percySnapshot('Visualizations - Counter (target value + trend positive)', { widths: [viewportWidth] }); + }); + + it('creates Counter with custom row number (trend negative)', () => { + cy.clickThrough(` + NewVisualization + VisualizationType + VisualizationType.COUNTER + + Counter.General.ValueColumn + Counter.General.ValueColumn.a + + Counter.General.TargetValueColumn + Counter.General.TargetValueColumn.b + `); + + cy.fillInputs({ + 'Counter.General.ValueRowNumber': '2', + 'Counter.General.TargetValueRowNumber': '2', + }); + + cy.getByTestId('VisualizationPreview').find('.counter-visualization-container').should('exist'); + + // wait a bit before taking snapshot + cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting + cy.percySnapshot('Visualizations - Counter (row number + trend negative)', { widths: [viewportWidth] }); + }); + + it('creates Counter with count rows', () => { + cy.clickThrough(` + NewVisualization + VisualizationType + VisualizationType.COUNTER + + Counter.General.ValueColumn + Counter.General.ValueColumn.a + + Counter.General.CountRows + `); + + cy.getByTestId('VisualizationPreview').find('.counter-visualization-container').should('exist'); + + // wait a bit before taking snapshot + cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting + cy.percySnapshot('Visualizations - Counter (count rows)', { widths: [viewportWidth] }); + }); + + it('creates Counter with formatting', () => { + cy.clickThrough(` + NewVisualization + VisualizationType + VisualizationType.COUNTER + + Counter.General.ValueColumn + Counter.General.ValueColumn.a + + Counter.General.TargetValueColumn + Counter.General.TargetValueColumn.b + + Counter.EditorTabs.Formatting + `); + + cy.fillInputs({ + 'Counter.Formatting.DecimalPlace': '4', + 'Counter.Formatting.DecimalCharacter': ',', + 'Counter.Formatting.ThousandsSeparator': '`', + 'Counter.Formatting.StringPrefix': '$', + 'Counter.Formatting.StringSuffix': '%', + }); + + cy.getByTestId('VisualizationPreview').find('.counter-visualization-container').should('exist'); + + // wait a bit before taking snapshot + cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting + cy.percySnapshot('Visualizations - Counter (custom formatting)', { widths: [viewportWidth] }); + }); + + it('creates Counter with target value formatting', () => { + cy.clickThrough(` + NewVisualization + VisualizationType + VisualizationType.COUNTER + + Counter.General.ValueColumn + Counter.General.ValueColumn.a + + Counter.General.TargetValueColumn + Counter.General.TargetValueColumn.b + + Counter.EditorTabs.Formatting + Counter.Formatting.FormatTargetValue + `); + + cy.fillInputs({ + 'Counter.Formatting.DecimalPlace': '4', + 'Counter.Formatting.DecimalCharacter': ',', + 'Counter.Formatting.ThousandsSeparator': '`', + 'Counter.Formatting.StringPrefix': '$', + 'Counter.Formatting.StringSuffix': '%', + }); + + cy.getByTestId('VisualizationPreview').find('.counter-visualization-container').should('exist'); + + // wait a bit before taking snapshot + cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting + cy.percySnapshot('Visualizations - Counter (format target value)', { widths: [viewportWidth] }); + }); +}); diff --git a/client/cypress/support/commands.js b/client/cypress/support/commands.js index 37654fccdf..67fd12244d 100644 --- a/client/cypress/support/commands.js +++ b/client/cypress/support/commands.js @@ -50,3 +50,18 @@ Cypress.Commands.add('fillInputs', (elements) => { cy.getByTestId(testId).clear().type(value); }); }); + +Cypress.Commands.add('dragBy', { prevSubject: true }, (subject, offsetLeft, offsetTop, force = false) => { + if (!offsetLeft) { + offsetLeft = 1; + } + if (!offsetTop) { + offsetTop = 1; + } + return cy.wrap(subject) + .trigger('mouseover', { force }) + .trigger('mousedown', 'topLeft', { force }) + .trigger('mousemove', 1, 1, { force }) // must have at least 2 mousemove events for react-grid-layout to trigger onLayoutChange + .trigger('mousemove', offsetLeft, offsetTop, { force }) + .trigger('mouseup', { force }); +}); diff --git a/client/cypress/support/dashboard/index.js b/client/cypress/support/dashboard/index.js index b2a2224a69..cd7b38a0c1 100644 --- a/client/cypress/support/dashboard/index.js +++ b/client/cypress/support/dashboard/index.js @@ -37,24 +37,9 @@ export function shareDashboard() { return cy.getByTestId('SecretAddress').invoke('val'); } -export function dragBy(wrapper, offsetLeft, offsetTop, force = false) { - if (!offsetLeft) { - offsetLeft = 1; - } - if (!offsetTop) { - offsetTop = 1; - } - return wrapper - .trigger('mouseover', { force }) - .trigger('mousedown', 'topLeft', { force }) - .trigger('mousemove', 1, 1, { force }) // must have at least 2 mousemove events for react-grid-layout to trigger onLayoutChange - .trigger('mousemove', offsetLeft, offsetTop, { force }) - .trigger('mouseup', { force }); -} - export function resizeBy(wrapper, offsetLeft = 0, offsetTop = 0) { return wrapper .within(() => { - dragBy(cy.get(RESIZE_HANDLE_SELECTOR), offsetLeft, offsetTop, true); + cy.get(RESIZE_HANDLE_SELECTOR).dragBy(offsetLeft, offsetTop, true); }); } diff --git a/client/jsconfig.json b/client/jsconfig.json new file mode 100644 index 0000000000..fe68d7ac70 --- /dev/null +++ b/client/jsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "baseUrl": "./", + "paths": { + "@/*": ["./app/*"] + } + }, + "exclude": ["dist"] +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index ce0fcd257e..d2fe7fd80e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -43,6 +43,9 @@ services: # The following turns the DB into less durable, but gains significant performance improvements for the tests run (x3 # improvement on my personal machine). We should consider moving this into a dedicated Docker Compose configuration for # tests. + environment: + POSTGRES_USER: 'postgres' + POSTGRES_HOST_AUTH_METHOD: trust ports: - "15432:5432" command: "postgres -c fsync=off -c full_page_writes=off -c synchronous_commit=OFF" diff --git a/migrations/versions/969126bd800f_.py b/migrations/versions/969126bd800f_.py index 051b97e695..4a476ef956 100644 --- a/migrations/versions/969126bd800f_.py +++ b/migrations/versions/969126bd800f_.py @@ -24,15 +24,20 @@ def upgrade(): # Update widgets position data: column_size = 3 print("Updating dashboards position data:") - for dashboard in Dashboard.query: - print(" Updating dashboard: {}".format(dashboard.id)) - layout = simplejson.loads(dashboard.layout) + dashboard_result = db.session.execute("SELECT id, layout FROM dashboards") + for dashboard in dashboard_result: + print(" Updating dashboard: {}".format(dashboard['id'])) + layout = simplejson.loads(dashboard['layout']) print(" Building widgets map:") widgets = {} - for w in dashboard.widgets: - print(" Widget: {}".format(w.id)) - widgets[w.id] = w + widget_result = db.session.execute( + "SELECT id, options, width FROM widgets WHERE dashboard_id=:dashboard_id", + {"dashboard_id" : dashboard['id']}) + for w in widget_result: + print(" Widget: {}".format(w['id'])) + widgets[w['id']] = w + widget_result.close() print(" Iterating over layout:") for row_index, row in enumerate(layout): @@ -47,17 +52,18 @@ def upgrade(): if widget is None: continue - options = simplejson.loads(widget.options) or {} + options = simplejson.loads(widget['options']) or {} options['position'] = { "row": row_index, "col": column_index * column_size, "sizeX": column_size * widget.width } - widget.options = simplejson.dumps(options) - - db.session.add(widget) + db.session.execute( + "UPDATE widgets SET options=:options WHERE id=:id", + {"options" : simplejson.dumps(options), "id" : widget_id}) + dashboard_result.close() db.session.commit() # Remove legacy columns no longer in use. diff --git a/migrations/versions/98af61feea92_add_encrypted_options_to_data_sources.py b/migrations/versions/98af61feea92_add_encrypted_options_to_data_sources.py index 86f1eb47e7..2ca5e9cd75 100644 --- a/migrations/versions/98af61feea92_add_encrypted_options_to_data_sources.py +++ b/migrations/versions/98af61feea92_add_encrypted_options_to_data_sources.py @@ -29,7 +29,7 @@ def upgrade(): data_sources = table( 'data_sources', sa.Column('id', sa.Integer, primary_key=True), - sa.Column('encrypted_options', ConfigurationContainer.as_mutable(EncryptedConfiguration(sa.Text, settings.SECRET_KEY, FernetEngine))), + sa.Column('encrypted_options', ConfigurationContainer.as_mutable(EncryptedConfiguration(sa.Text, settings.DATASOURCE_SECRET_KEY, FernetEngine))), sa.Column('options', ConfigurationContainer.as_mutable(Configuration))) conn = op.get_bind() diff --git a/package-lock.json b/package-lock.json index 00020ef7fe..974b270862 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "redash-client", - "version": "8.0.0-beta", + "version": "8.0.1", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -1652,6 +1652,22 @@ "resize-observer-polyfill": "^1.5.1", "shallowequal": "^1.1.0", "warning": "~4.0.3" + }, + "dependencies": { + "lodash": { + "version": "4.17.15", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", + "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==" + }, + "rc-progress": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/rc-progress/-/rc-progress-2.3.0.tgz", + "integrity": "sha512-hYBKFSsNgD7jsF8j+ZC1J8y5UIC2X/ktCYI/OQhQNSX6mGV1IXnUCjAd9gbLmzmpChPvKyymRNfckScUNiTpFQ==", + "requires": { + "babel-runtime": "6.x", + "prop-types": "^15.5.8" + } + } } }, "any-observable": { @@ -9710,7 +9726,6 @@ "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", - "dev": true, "requires": { "loose-envify": "^1.0.0" } @@ -14602,15 +14617,6 @@ "react-lifecycles-compat": "^3.0.4" } }, - "rc-progress": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/rc-progress/-/rc-progress-2.3.0.tgz", - "integrity": "sha512-hYBKFSsNgD7jsF8j+ZC1J8y5UIC2X/ktCYI/OQhQNSX6mGV1IXnUCjAd9gbLmzmpChPvKyymRNfckScUNiTpFQ==", - "requires": { - "babel-runtime": "6.x", - "prop-types": "^15.5.8" - } - }, "rc-rate": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/rc-rate/-/rc-rate-2.5.0.tgz", @@ -14866,13 +14872,14 @@ } }, "rc-util": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/rc-util/-/rc-util-4.6.0.tgz", - "integrity": "sha512-rbgrzm1/i8mgfwOI4t1CwWK7wGe+OwX+dNa7PVMgxZYPBADGh86eD4OcJO1UKGeajIMDUUKMluaZxvgraQIOmw==", + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/rc-util/-/rc-util-4.8.4.tgz", + "integrity": "sha512-1B2h0/pMXfSUBRAgPdoDIKK5XBuzLBuLI9rLwUEW163SPoDvfb9jmg3ymBPtzne2jWgwtdNw4j0vIq/8Yo849A==", "requires": { "add-dom-event-listener": "^1.1.0", "babel-runtime": "6.x", "prop-types": "^15.5.10", + "react-lifecycles-compat": "^3.0.4", "shallowequal": "^0.2.2" }, "dependencies": { @@ -14982,6 +14989,31 @@ "resize-observer-polyfill": "^1.5.0" } }, + "react-sortable-hoc": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/react-sortable-hoc/-/react-sortable-hoc-1.9.1.tgz", + "integrity": "sha512-2VeofjRav8+eZeE5Nm/+b8mrA94rQ+gBsqhXi8pRBSjOWNqslU3ZEm+0XhSlfoXJY2lkgHipfYAUuJbDtCixRg==", + "requires": { + "@babel/runtime": "^7.2.0", + "invariant": "^2.2.4", + "prop-types": "^15.5.7" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.5.5.tgz", + "integrity": "sha512-28QvEGyQyNkB0/m2B4FU7IEZGK2NUrcMtT6BZEFALTguLk+AUT6ofsHtPk5QyjAdUkpMJ+/Em+quwz4HOt30AQ==", + "requires": { + "regenerator-runtime": "^0.13.2" + } + }, + "regenerator-runtime": { + "version": "0.13.3", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.3.tgz", + "integrity": "sha512-naKIZz2GQ8JWh///G7L3X6LaQUAMp2lvb1rvwwsURe/VXwD6VMfr+/1NuNw3ag8v2kY1aQ/go5SNn79O9JU7yw==" + } + } + }, "react-test-renderer": { "version": "16.8.3", "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-16.8.3.tgz", diff --git a/package.json b/package.json index d011d2573d..458f7f8fa8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "redash-client", - "version": "8.0.0-beta", + "version": "8.0.1", "description": "The frontend part of Redash.", "main": "index.js", "scripts": { @@ -16,7 +16,7 @@ "lint:ci": "npm run lint -- --format junit --output-file /tmp/test-results/eslint/results.xml", "test": "TZ=Africa/Khartoum jest", "test:watch": "jest --watch", - "cypress:install": "npm install --no-save cypress@^3.1.5 @percy/cypress@^0.2.3 atob@2.1.2", + "cypress:install": "npm install --no-save cypress@3.4.1 @percy/cypress@^0.2.3 atob@2.1.2", "cypress": "node client/cypress/cypress.js" }, "repository": { @@ -82,7 +82,9 @@ "react-ace": "^6.1.0", "react-dom": "^16.8.3", "react-grid-layout": "git+https://github.com/getredash/react-grid-layout.git", + "react-sortable-hoc": "^1.9.1", "react2angular": "^3.2.1", + "tinycolor2": "^1.4.1", "ui-select": "^0.19.8" }, "devDependencies": { diff --git a/redash/__init__.py b/redash/__init__.py index 0cd1a5db03..2a5e4e6a57 100644 --- a/redash/__init__.py +++ b/redash/__init__.py @@ -16,7 +16,7 @@ from .query_runner import import_query_runners from .destinations import import_destinations -__version__ = '8.0.0-beta' +__version__ = '8.0.1' if os.environ.get("REMOTE_DEBUG"): diff --git a/redash/authentication/saml_auth.py b/redash/authentication/saml_auth.py index c34b212be7..fd15f7d053 100644 --- a/redash/authentication/saml_auth.py +++ b/redash/authentication/saml_auth.py @@ -113,7 +113,7 @@ def sp_initiated(org_slug=None): redirect_url = None # Select the IdP URL to send the AuthN request to for key, value in info['headers']: - if key is 'Location': + if key == 'Location': redirect_url = value response = redirect(redirect_url, code=302) diff --git a/redash/handlers/authentication.py b/redash/handlers/authentication.py index a698dfb496..dd1eea6451 100644 --- a/redash/handlers/authentication.py +++ b/redash/handlers/authentication.py @@ -225,6 +225,9 @@ def client_config(): } else: client_config = {} + + if current_user.has_permission('admin') and current_org.get_setting('beacon_consent') is None: + client_config['showBeaconConsentMessage'] = True defaults = { 'allowScriptsInUserInput': settings.ALLOW_SCRIPTS_IN_USER_INPUT, @@ -263,18 +266,6 @@ def messages(): return messages -def messages(): - messages = [] - - if not current_user.is_email_verified: - messages.append('email-not-verified') - - if settings.ALLOW_PARAMETERS_IN_EMBEDS: - messages.append('using-deprecated-embed-feature') - - return messages - - @routes.route('/api/config', methods=['GET']) def config(org_slug=None): return json_response({ diff --git a/redash/handlers/dashboards.py b/redash/handlers/dashboards.py index 954e9da4f7..a5da024bbc 100644 --- a/redash/handlers/dashboards.py +++ b/redash/handlers/dashboards.py @@ -8,7 +8,7 @@ order_results as _order_results) from redash.permissions import (can_modify, require_admin_or_owner, require_object_modify_permission, - require_permission) + require_permission, is_public_access_allowed) from redash.security import csp_allows_embeding from redash.serializers import serialize_dashboard from sqlalchemy.orm.exc import StaleDataError @@ -265,6 +265,7 @@ def post(self, dashboard_id): """ dashboard = models.Dashboard.get_by_id_and_org(dashboard_id, self.current_org) require_admin_or_owner(dashboard.user_id) + is_public_access_allowed() api_key = models.ApiKey.create_for_object(dashboard, self.current_user) models.db.session.flush() models.db.session.commit() diff --git a/redash/handlers/embed.py b/redash/handlers/embed.py index 62805f3a5d..342f176603 100644 --- a/redash/handlers/embed.py +++ b/redash/handlers/embed.py @@ -8,6 +8,7 @@ from redash.handlers import routes from redash.handlers.base import (get_object_or_404, org_scoped_rule, record_event) +from redash.permissions import is_public_access_allowed from redash.handlers.static import render_index from redash.security import csp_allows_embeding diff --git a/redash/models/__init__.py b/redash/models/__init__.py index a4671e5824..cc160884c8 100644 --- a/redash/models/__init__.py +++ b/redash/models/__init__.py @@ -791,14 +791,25 @@ def evaluate(self): data = json_loads(self.query_rel.latest_query_data.data) if data['rows'] and self.options['column'] in data['rows'][0]: + operators = { + '>': lambda v, t: v > t, + '>=': lambda v, t: v >= t, + '<': lambda v, t: v < t, + '<=': lambda v, t: v <= t, + '==': lambda v, t: v == t, + '!=': lambda v, t: v != t, + + # backward compatibility + 'greater than': lambda v, t: v > t, + 'less than': lambda v, t: v < t, + 'equals': lambda v, t: v == t, + } + should_trigger = operators.get(self.options['op'], lambda v, t: False) + value = data['rows'][0][self.options['column']] - op = self.options['op'] + threshold = self.options['value'] - if op == 'greater than' and value > self.options['value']: - new_state = self.TRIGGERED_STATE - elif op == 'less than' and value < self.options['value']: - new_state = self.TRIGGERED_STATE - elif op == 'equals' and value == self.options['value']: + if should_trigger(value, threshold): new_state = self.TRIGGERED_STATE else: new_state = self.OK_STATE diff --git a/redash/models/parameterized_query.py b/redash/models/parameterized_query.py index 784e609cc3..c42eeaf581 100644 --- a/redash/models/parameterized_query.py +++ b/redash/models/parameterized_query.py @@ -6,13 +6,15 @@ from funcy import distinct from dateutil.parser import parse +from six import string_types, text_type + def _pluck_name_and_value(default_column, row): row = {k.lower(): v for k, v in row.items()} name_column = "name" if "name" in row.keys() else default_column.lower() value_column = "value" if "value" in row.keys() else default_column.lower() - return {"name": row[name_column], "value": unicode(row[value_column])} + return {"name": row[name_column], "value": text_type(row[value_column])} def _load_result(query_id, org): @@ -107,8 +109,8 @@ def _is_date_range(obj): def _is_value_within_options(value, dropdown_options, allow_list=False): if isinstance(value, list): - return allow_list and set(map(unicode, value)).issubset(set(dropdown_options)) - return unicode(value) in dropdown_options + return allow_list and set(map(text_type, value)).issubset(set(dropdown_options)) + return text_type(value) in dropdown_options class ParameterizedQuery(object): @@ -142,11 +144,11 @@ def _valid(self, name, value): query_id = definition.get('queryId') allow_multiple_values = isinstance(definition.get('multiValuesOptions'), dict) - if isinstance(enum_options, basestring): + if isinstance(enum_options, string_types): enum_options = enum_options.split('\n') validators = { - "text": lambda value: isinstance(value, basestring), + "text": lambda value: isinstance(value, string_types), "number": _is_number, "enum": lambda value: _is_value_within_options(value, enum_options, diff --git a/redash/permissions.py b/redash/permissions.py index d928d918c9..185d90f6c6 100644 --- a/redash/permissions.py +++ b/redash/permissions.py @@ -101,6 +101,10 @@ def require_admin_or_owner(object_owner_id): abort(403, message="You don't have permission to edit this resource.") +def is_public_access_allowed(): + abort(403, message="Creating public dashboards is not allowed.") + + def can_modify(obj, user): return is_admin_or_owner(obj.user_id) or user.has_access(obj, ACCESS_TYPE_MODIFY) diff --git a/redash/query_runner/__init__.py b/redash/query_runner/__init__.py index c1d473cd1c..cdd0943639 100644 --- a/redash/query_runner/__init__.py +++ b/redash/query_runner/__init__.py @@ -3,6 +3,8 @@ from dateutil import parser import requests +from six import text_type + from redash import settings from redash.utils import json_loads @@ -54,6 +56,7 @@ class NotSupported(Exception): class BaseQueryRunner(object): deprecated = False + should_annotate_query = True noop_query = None def __init__(self, configuration): @@ -72,14 +75,26 @@ def type(cls): def enabled(cls): return True - @classmethod - def annotate_query(cls): - return True - @classmethod def configuration_schema(cls): return {} + def annotate_query(self, query, metadata): + if not self.should_annotate_query: + return query + + annotation = u", ".join([u"{}: {}".format(k, v) for k, v in metadata.iteritems()]) + annotated_query = u"/* {} */ {}".format(annotation, query) + return annotated_query + + def annotate_query_with_single_line_comment(self, query, metadata): + if not self.should_annotate_query: + return query + + annotation = u", ".join([u"{}: {}".format(k, v) for k, v in metadata.iteritems()]) + annotated_query = u"-- {} -- \n {}".format(annotation, query) + return annotated_query + def test_connection(self): if self.noop_query is None: raise NotImplementedError() @@ -148,6 +163,7 @@ def _get_tables_stats(self, tables_dict): class BaseHTTPQueryRunner(BaseQueryRunner): + should_annotate_query = False response_error = "Endpoint returned unexpected status code" requires_authentication = False requires_url = True @@ -299,7 +315,7 @@ def guess_type_from_string(string_value): except (ValueError, OverflowError): pass - if unicode(string_value).lower() in ('true', 'false'): + if text_type(string_value).lower() in ('true', 'false'): return TYPE_BOOLEAN try: diff --git a/redash/query_runner/athena.py b/redash/query_runner/athena.py index db13297caa..7cecbc676e 100644 --- a/redash/query_runner/athena.py +++ b/redash/query_runner/athena.py @@ -7,6 +7,7 @@ logger = logging.getLogger(__name__) ANNOTATE_QUERY = parse_boolean(os.environ.get('ATHENA_ANNOTATE_QUERY', 'true')) +ANNOTATE_QUERY_FOR_DML = parse_boolean(os.environ.get('ATHENA_ANNOTATE_QUERY_FOR_DML', 'true')) SHOW_EXTRA_SETTINGS = parse_boolean(os.environ.get('ATHENA_SHOW_EXTRA_SETTINGS', 'true')) ASSUME_ROLE = parse_boolean(os.environ.get('ATHENA_ASSUME_ROLE', 'false')) OPTIONAL_CREDENTIALS = parse_boolean(os.environ.get('ATHENA_OPTIONAL_CREDENTIALS', 'true')) @@ -132,9 +133,13 @@ def configuration_schema(cls): def enabled(cls): return enabled - @classmethod - def annotate_query(cls): - return ANNOTATE_QUERY + def annotate_query(self, query, metadata): + if ANNOTATE_QUERY: + if ANNOTATE_QUERY_FOR_DML: + return super(Athena, self).annotate_query_with_single_line_comment(query, metadata) + else: + return super(Athena, self).annotate_query(query, metadata) + return query @classmethod def type(cls): diff --git a/redash/query_runner/azure_kusto.py b/redash/query_runner/azure_kusto.py new file mode 100644 index 0000000000..d045f54d71 --- /dev/null +++ b/redash/query_runner/azure_kusto.py @@ -0,0 +1,156 @@ +from redash.query_runner import BaseQueryRunner, register +from redash.query_runner import TYPE_STRING, TYPE_DATE, TYPE_DATETIME, TYPE_INTEGER, TYPE_FLOAT, TYPE_BOOLEAN +from redash.utils import json_dumps, json_loads + + +try: + from azure.kusto.data.request import KustoClient, KustoConnectionStringBuilder + from azure.kusto.data.exceptions import KustoServiceError + enabled = True +except ImportError: + enabled = False + +TYPES_MAP = { + 'boolean': TYPE_BOOLEAN, + 'datetime': TYPE_DATETIME, + 'date': TYPE_DATE, + 'dynamic': TYPE_STRING, + 'guid': TYPE_STRING, + 'int': TYPE_INTEGER, + 'long': TYPE_INTEGER, + 'real': TYPE_FLOAT, + 'string': TYPE_STRING, + 'timespan': TYPE_STRING, + 'decimal': TYPE_FLOAT +} + + +class AzureKusto(BaseQueryRunner): + should_annotate_query = False + noop_query = "let noop = datatable (Noop:string)[1]; noop" + + def __init__(self, configuration): + super(AzureKusto, self).__init__(configuration) + self.syntax = 'custom' + + @classmethod + def configuration_schema(cls): + return { + "type": "object", + "properties": { + "cluster": { + "type": "string" + }, + "azure_ad_client_id": { + "type": "string", + "title": "Azure AD Client ID" + }, + "azure_ad_client_secret": { + "type": "string", + "title": "Azure AD Client Secret" + }, + "azure_ad_tenant_id": { + "type": "string", + "title": "Azure AD Tenant Id" + }, + "database": { + "type": "string" + } + }, + "required": [ + "cluster", "azure_ad_client_id", "azure_ad_client_secret", + "azure_ad_tenant_id", "database" + ], + "order": [ + "cluster", "azure_ad_client_id", "azure_ad_client_secret", + "azure_ad_tenant_id", "database" + ], + "secret": ["azure_ad_client_secret"] + } + + @classmethod + def enabled(cls): + return enabled + + @classmethod + def type(cls): + return "azure_kusto" + + @classmethod + def name(cls): + return "Azure Data Explorer (Kusto)" + + def run_query(self, query, user): + + kcsb = KustoConnectionStringBuilder.with_aad_application_key_authentication( + connection_string=self.configuration['cluster'], + aad_app_id=self.configuration['azure_ad_client_id'], + app_key=self.configuration['azure_ad_client_secret'], + authority_id=self.configuration['azure_ad_tenant_id']) + + client = KustoClient(kcsb) + + db = self.configuration['database'] + try: + response = client.execute(db, query) + + result_cols = response.primary_results[0].columns + result_rows = response.primary_results[0].rows + + columns = [] + rows = [] + for c in result_cols: + columns.append({ + 'name': c.column_name, + 'friendly_name': c.column_name, + 'type': TYPES_MAP.get(c.column_type, None) + }) + + # rows must be [{'column1': value, 'column2': value}] + for row in result_rows: + rows.append(row.to_dict()) + + error = None + data = {'columns': columns, 'rows': rows} + json_data = json_dumps(data) + + except KustoServiceError as err: + json_data = None + try: + error = err.args[1][0]['error']['@message'] + except (IndexError, KeyError): + error = err.args[1] + except KeyboardInterrupt: + json_data = None + error = "Query cancelled by user." + + return json_data, error + + def get_schema(self, get_stats=False): + query = ".show database schema as json" + + results, error = self.run_query(query, None) + + if error is not None: + raise Exception("Failed getting schema.") + + results = json_loads(results) + + schema_as_json = json_loads(results['rows'][0]['DatabaseSchema']) + tables_list = schema_as_json['Databases'][self.configuration['database']]['Tables'].values() + + schema = {} + + for table in tables_list: + table_name = table['Name'] + + if table_name not in schema: + schema[table_name] = {'name': table_name, 'columns': []} + + for column in table['OrderedColumns']: + schema[table_name]['columns'].append(column['Name']) + + return schema.values() + + +register(AzureKusto) diff --git a/redash/query_runner/big_query.py b/redash/query_runner/big_query.py index d85574372a..782a600409 100644 --- a/redash/query_runner/big_query.py +++ b/redash/query_runner/big_query.py @@ -83,6 +83,7 @@ def _get_query_results(jobs, project_id, location, job_id, start_index): class BigQuery(BaseQueryRunner): + should_annotate_query = False noop_query = "SELECT 1" @classmethod @@ -133,10 +134,6 @@ def configuration_schema(cls): 'secret': ['jsonKeyFile'] } - @classmethod - def annotate_query(cls): - return False - def _get_bigquery_service(self): scope = [ "https://www.googleapis.com/auth/bigquery", diff --git a/redash/query_runner/couchbase.py b/redash/query_runner/couchbase.py index f6e839fb0b..093cd387a6 100644 --- a/redash/query_runner/couchbase.py +++ b/redash/query_runner/couchbase.py @@ -2,9 +2,11 @@ import logging from dateutil.parser import parse +from six import text_type from redash.query_runner import * from redash.utils import JSONEncoder, json_dumps, json_loads, parse_human_time +from redash.utils.compat import long import json logger = logging.getLogger(__name__) @@ -17,7 +19,7 @@ TYPES_MAP = { str: TYPE_STRING, - unicode: TYPE_STRING, + text_type: TYPE_STRING, int: TYPE_INTEGER, long: TYPE_INTEGER, float: TYPE_FLOAT, @@ -68,7 +70,7 @@ def parse_results(results): class Couchbase(BaseQueryRunner): - + should_annotate_query = False noop_query = 'Select 1' @classmethod @@ -107,10 +109,6 @@ def __init__(self, configuration): def enabled(cls): return True - @classmethod - def annotate_query(cls): - return False - def test_connection(self): result = self.call_service(self.noop_query, '') diff --git a/redash/query_runner/dgraph.py b/redash/query_runner/dgraph.py index 60a12edbd1..d5342e163e 100644 --- a/redash/query_runner/dgraph.py +++ b/redash/query_runner/dgraph.py @@ -29,6 +29,7 @@ def reduce_item(reduced_item, key, value): class Dgraph(BaseQueryRunner): + should_annotate_query = False noop_query = """ { test() { @@ -64,13 +65,7 @@ def type(cls): def enabled(cls): return enabled - @classmethod - def annotate_query(cls): - """Dgraph uses '#' as a comment delimiter, not '/* */'""" - return False - def run_dgraph_query_raw(self, query): - servers = self.configuration.get('servers') client_stub = pydgraph.DgraphClientStub(servers) diff --git a/redash/query_runner/drill.py b/redash/query_runner/drill.py index 780e74072b..5c5ce12db8 100644 --- a/redash/query_runner/drill.py +++ b/redash/query_runner/drill.py @@ -4,6 +4,8 @@ from dateutil import parser +from six import text_type + from redash.query_runner import ( BaseHTTPQueryRunner, register, TYPE_DATETIME, TYPE_INTEGER, TYPE_FLOAT, TYPE_BOOLEAN, @@ -26,12 +28,12 @@ def convert_type(string_value, actual_type): return float(string_value) if actual_type == TYPE_BOOLEAN: - return unicode(string_value).lower() == 'true' + return text_type(string_value).lower() == 'true' if actual_type == TYPE_DATETIME: return parser.parse(string_value) - return unicode(string_value) + return text_type(string_value) # Parse Drill API response and translate it to accepted format diff --git a/redash/query_runner/dynamodb_sql.py b/redash/query_runner/dynamodb_sql.py index 014d1bd5f6..12be5a7f65 100644 --- a/redash/query_runner/dynamodb_sql.py +++ b/redash/query_runner/dynamodb_sql.py @@ -33,6 +33,8 @@ class DynamoDBSQL(BaseSQLQueryRunner): + should_annotate_query = False + @classmethod def configuration_schema(cls): return { @@ -57,10 +59,6 @@ def test_connection(self): engine = self._connect() list(engine.connection.list_tables()) - @classmethod - def annotate_query(cls): - return False - @classmethod def type(cls): return "dynamodb_sql" diff --git a/redash/query_runner/elasticsearch.py b/redash/query_runner/elasticsearch.py index 22e08d3108..2bb338a767 100644 --- a/redash/query_runner/elasticsearch.py +++ b/redash/query_runner/elasticsearch.py @@ -4,9 +4,11 @@ import requests from requests.auth import HTTPBasicAuth +from six import string_types, text_type from redash.query_runner import * from redash.utils import json_dumps, json_loads +from redash.utils.compat import long try: import http.client as http_client @@ -35,7 +37,7 @@ PYTHON_TYPES_MAPPING = { str: TYPE_STRING, - unicode: TYPE_STRING, + text_type: TYPE_STRING, bool: TYPE_BOOLEAN, int: TYPE_INTEGER, long: TYPE_INTEGER, @@ -44,6 +46,7 @@ class BaseElasticSearch(BaseQueryRunner): + should_annotate_query = False DEBUG_ENABLED = False @classmethod @@ -286,15 +289,10 @@ def test_connection(self): class Kibana(BaseElasticSearch): - @classmethod def enabled(cls): return True - @classmethod - def annotate_query(cls): - return False - def _execute_simple_query(self, url, auth, _from, mappings, result_fields, result_columns, result_rows): url += "&from={0}".format(_from) r = requests.get(url, auth=self.auth) @@ -345,7 +343,7 @@ def run_query(self, query, user): result_columns = [] result_rows = [] - if isinstance(query_data, str) or isinstance(query_data, unicode): + if isinstance(query_data, string_types): _from = 0 while True: query_size = size if limit >= (_from + size) else (limit - _from) @@ -377,15 +375,10 @@ def run_query(self, query, user): class ElasticSearch(BaseElasticSearch): - @classmethod def enabled(cls): return True - @classmethod - def annotate_query(cls): - return False - @classmethod def name(cls): return 'Elasticsearch' diff --git a/redash/query_runner/google_analytics.py b/redash/query_runner/google_analytics.py index 71be522015..479403d6de 100644 --- a/redash/query_runner/google_analytics.py +++ b/redash/query_runner/google_analytics.py @@ -78,9 +78,7 @@ def parse_ga_response(response): class GoogleAnalytics(BaseSQLQueryRunner): - @classmethod - def annotate_query(cls): - return False + should_annotate_query = False @classmethod def type(cls): diff --git a/redash/query_runner/google_spreadsheets.py b/redash/query_runner/google_spreadsheets.py index 5b144f4459..5c369d3534 100644 --- a/redash/query_runner/google_spreadsheets.py +++ b/redash/query_runner/google_spreadsheets.py @@ -139,14 +139,12 @@ def request(self, *args, **kwargs): class GoogleSpreadsheet(BaseQueryRunner): + should_annotate_query = False + def __init__(self, configuration): super(GoogleSpreadsheet, self).__init__(configuration) self.syntax = 'custom' - @classmethod - def annotate_query(cls): - return False - @classmethod def name(cls): return "Google Sheets" diff --git a/redash/query_runner/graphite.py b/redash/query_runner/graphite.py index 1fb5ec1503..711584c70d 100644 --- a/redash/query_runner/graphite.py +++ b/redash/query_runner/graphite.py @@ -26,6 +26,8 @@ def _transform_result(response): class Graphite(BaseQueryRunner): + should_annotate_query = False + @classmethod def configuration_schema(cls): return { @@ -49,10 +51,6 @@ def configuration_schema(cls): 'secret': ['password'] } - @classmethod - def annotate_query(cls): - return False - def __init__(self, configuration): super(Graphite, self).__init__(configuration) self.syntax = 'custom' diff --git a/redash/query_runner/hive_ds.py b/redash/query_runner/hive_ds.py index 2107d0d0b9..6b10e23ebc 100644 --- a/redash/query_runner/hive_ds.py +++ b/redash/query_runner/hive_ds.py @@ -9,6 +9,7 @@ try: from pyhive import hive + from pyhive.exc import DatabaseError from thrift.transport import THttpClient enabled = True except ImportError: @@ -36,6 +37,7 @@ class Hive(BaseSQLQueryRunner): + should_annotate_query = False noop_query = "SELECT 1" @classmethod @@ -60,10 +62,6 @@ def configuration_schema(cls): "required": ["host"] } - @classmethod - def annotate_query(cls): - return False - @classmethod def type(cls): return "hive" @@ -132,6 +130,12 @@ def run_query(self, query, user): connection.cancel() error = "Query cancelled by user." json_data = None + except DatabaseError as e: + try: + error = e.args[0].status.errorMessage + except AttributeError: + error = str(e) + json_data = None finally: if connection: connection.close() diff --git a/redash/query_runner/influx_db.py b/redash/query_runner/influx_db.py index 47f3a4201f..bec53c8d27 100644 --- a/redash/query_runner/influx_db.py +++ b/redash/query_runner/influx_db.py @@ -48,6 +48,7 @@ def _transform_result(results): class InfluxDB(BaseQueryRunner): + should_annotate_query = False noop_query = "show measurements limit 1" @classmethod @@ -66,10 +67,6 @@ def configuration_schema(cls): def enabled(cls): return enabled - @classmethod - def annotate_query(cls): - return False - @classmethod def type(cls): return "influxdb" diff --git a/redash/query_runner/jql.py b/redash/query_runner/jql.py index d24ee0b9f8..76e707e3a3 100644 --- a/redash/query_runner/jql.py +++ b/redash/query_runner/jql.py @@ -150,10 +150,6 @@ class JiraJQL(BaseHTTPQueryRunner): def name(cls): return "JIRA (JQL)" - @classmethod - def annotate_query(cls): - return False - def __init__(self, configuration): super(JiraJQL, self).__init__(configuration) self.syntax = 'json' diff --git a/redash/query_runner/json_ds.py b/redash/query_runner/json_ds.py index 4b16ebc0d7..9cf3226ed8 100644 --- a/redash/query_runner/json_ds.py +++ b/redash/query_runner/json_ds.py @@ -5,7 +5,9 @@ import datetime from urlparse import urlparse from funcy import compact, project +from six import text_type from redash.utils import json_dumps +from redash.utils.compat import long from redash.query_runner import (BaseHTTPQueryRunner, register, TYPE_BOOLEAN, TYPE_DATETIME, TYPE_FLOAT, TYPE_INTEGER, TYPE_STRING) @@ -25,19 +27,19 @@ def parse_query(query): return params except ValueError as e: logging.exception(e) - error = unicode(e) + error = text_type(e) raise QueryParseError(error) def is_private_address(url): hostname = urlparse(url).hostname ip_address = socket.gethostbyname(hostname) - return ipaddress.ip_address(unicode(ip_address)).is_private + return ipaddress.ip_address(text_type(ip_address)).is_private TYPES_MAP = { str: TYPE_STRING, - unicode: TYPE_STRING, + text_type: TYPE_STRING, int: TYPE_INTEGER, long: TYPE_INTEGER, float: TYPE_FLOAT, @@ -157,10 +159,6 @@ def configuration_schema(cls): 'order': ['username', 'password'] } - @classmethod - def annotate_query(cls): - return False - def __init__(self, configuration): super(JSON, self).__init__(configuration) self.syntax = 'yaml' diff --git a/redash/query_runner/memsql_ds.py b/redash/query_runner/memsql_ds.py index bbec2836d4..917e4962cb 100644 --- a/redash/query_runner/memsql_ds.py +++ b/redash/query_runner/memsql_ds.py @@ -37,6 +37,7 @@ class MemSQL(BaseSQLQueryRunner): + should_annotate_query = False noop_query = 'SELECT 1' @classmethod @@ -62,10 +63,6 @@ def configuration_schema(cls): "secret": ["password"] } - @classmethod - def annotate_query(cls): - return False - @classmethod def type(cls): return "memsql" diff --git a/redash/query_runner/mongodb.py b/redash/query_runner/mongodb.py index c6fbdc9760..b6dad02747 100644 --- a/redash/query_runner/mongodb.py +++ b/redash/query_runner/mongodb.py @@ -3,9 +3,11 @@ import re from dateutil.parser import parse +from six import string_types, text_type from redash.query_runner import * from redash.utils import JSONEncoder, json_dumps, json_loads, parse_human_time +from redash.utils.compat import long logger = logging.getLogger(__name__) @@ -24,7 +26,7 @@ TYPES_MAP = { str: TYPE_STRING, - unicode: TYPE_STRING, + text_type: TYPE_STRING, int: TYPE_INTEGER, long: TYPE_INTEGER, float: TYPE_FLOAT, @@ -56,7 +58,7 @@ def parse_oids(oids): def datetime_parser(dct): for k, v in dct.iteritems(): - if isinstance(v, basestring): + if isinstance(v, string_types): m = date_regex.findall(v) if len(m) > 0: dct[k] = parse(m[0], yearfirst=True) @@ -119,6 +121,8 @@ def parse_results(results): class MongoDB(BaseQueryRunner): + should_annotate_query = False + @classmethod def configuration_schema(cls): return { @@ -144,10 +148,6 @@ def configuration_schema(cls): def enabled(cls): return enabled - @classmethod - def annotate_query(cls): - return False - def __init__(self, configuration): super(MongoDB, self).__init__(configuration) diff --git a/redash/query_runner/mssql.py b/redash/query_runner/mssql.py index c4b4fea1e0..4349acebf3 100644 --- a/redash/query_runner/mssql.py +++ b/redash/query_runner/mssql.py @@ -26,6 +26,7 @@ class SqlServer(BaseSQLQueryRunner): + should_annotate_query = False noop_query = "SELECT 1" @classmethod @@ -78,10 +79,6 @@ def name(cls): def type(cls): return "mssql" - @classmethod - def annotate_query(cls): - return False - def _get_tables(self, schema): query = """ SELECT table_schema, table_name, column_name diff --git a/redash/query_runner/mssql_odbc.py b/redash/query_runner/mssql_odbc.py index a729e037c7..7736c56fba 100644 --- a/redash/query_runner/mssql_odbc.py +++ b/redash/query_runner/mssql_odbc.py @@ -16,6 +16,7 @@ class SQLServerODBC(BaseSQLQueryRunner): + should_annotate_query = False noop_query = "SELECT 1" @classmethod @@ -68,10 +69,6 @@ def name(cls): def type(cls): return "mssql_odbc" - @classmethod - def annotate_query(cls): - return False - def _get_tables(self, schema): query = """ SELECT table_schema, table_name, column_name diff --git a/redash/query_runner/mysql.py b/redash/query_runner/mysql.py index b6592eb790..907fb15b54 100644 --- a/redash/query_runner/mysql.py +++ b/redash/query_runner/mysql.py @@ -2,10 +2,16 @@ import os import threading -from redash.query_runner import * +from redash.query_runner import TYPE_FLOAT, TYPE_INTEGER, TYPE_DATETIME, TYPE_STRING, TYPE_DATE, BaseSQLQueryRunner, InterruptException, register from redash.settings import parse_boolean from redash.utils import json_dumps, json_loads +try: + import MySQLdb + enabled = True +except ImportError: + enabled = False + logger = logging.getLogger(__name__) types_map = { 0: TYPE_FLOAT, @@ -37,7 +43,8 @@ class Mysql(BaseSQLQueryRunner): @classmethod def configuration_schema(cls): - show_ssl_settings = parse_boolean(os.environ.get('MYSQL_SHOW_SSL_SETTINGS', 'true')) + show_ssl_settings = parse_boolean( + os.environ.get('MYSQL_SHOW_SSL_SETTINGS', 'true')) schema = { 'type': 'object', @@ -74,8 +81,10 @@ def configuration_schema(cls): 'title': 'Use SSL' }, 'ssl_cacert': { - 'type': 'string', - 'title': 'Path to CA certificate file to verify peer against (SSL)' + 'type': + 'string', + 'title': + 'Path to CA certificate file to verify peer against (SSL)' }, 'ssl_cert': { 'type': 'string', @@ -95,12 +104,26 @@ def name(cls): @classmethod def enabled(cls): - try: - import MySQLdb - except ImportError: - return False + return enabled + + def _connection(self): + params = dict(host=self.configuration.get('host', ''), + user=self.configuration.get('user', ''), + passwd=self.configuration.get('passwd', ''), + db=self.configuration['db'], + port=self.configuration.get('port', 3306), + charset='utf8', + use_unicode=True, + connect_timeout=60) + + ssl_options = self._get_ssl_parameters() - return True + if ssl_options: + params['ssl'] = ssl_options + + connection = MySQLdb.connect(**params) + + return connection def _get_tables(self, schema): query = """ @@ -120,7 +143,8 @@ def _get_tables(self, schema): for row in results['rows']: if row['table_schema'] != self.configuration['db']: - table_name = u'{}.{}'.format(row['table_schema'], row['table_name']) + table_name = u'{}.{}'.format(row['table_schema'], + row['table_name']) else: table_name = row['table_name'] @@ -132,23 +156,15 @@ def _get_tables(self, schema): return schema.values() def run_query(self, query, user): - import MySQLdb - ev = threading.Event() thread_id = "" r = Result() t = None try: - connection = MySQLdb.connect(host=self.configuration.get('host', ''), - user=self.configuration.get('user', ''), - passwd=self.configuration.get('passwd', ''), - db=self.configuration['db'], - port=self.configuration.get('port', 3306), - charset='utf8', use_unicode=True, - ssl=self._get_ssl_parameters(), - connect_timeout=60) + connection = self._connection() thread_id = connection.thread_id() - t = threading.Thread(target=self._run_query, args=(query, user, connection, r, ev)) + t = threading.Thread(target=self._run_query, + args=(query, user, connection, r, ev)) t.start() while not ev.wait(1): pass @@ -163,8 +179,6 @@ def run_query(self, query, user): return r.json_data, r.error def _run_query(self, query, user, connection, r, ev): - import MySQLdb - try: cursor = connection.cursor() logger.debug("MySQL running query: %s", query) @@ -180,8 +194,12 @@ def _run_query(self, query, user, connection, r, ev): # TODO - very similar to pg.py if desc is not None: - columns = self.fetch_columns([(i[0], types_map.get(i[1], None)) for i in desc]) - rows = [dict(zip((c['name'] for c in columns), row)) for row in data] + columns = self.fetch_columns([(i[0], types_map.get(i[1], None)) + for i in desc]) + rows = [ + dict(zip((c['name'] for c in columns), row)) + for row in data + ] data = {'columns': columns, 'rows': rows} r.json_data = json_dumps(data) @@ -202,12 +220,13 @@ def _run_query(self, query, user, connection, r, ev): connection.close() def _get_ssl_parameters(self): + if not self.configuration.get('use_ssl'): + return None + ssl_params = {} if self.configuration.get('use_ssl'): - config_map = dict(ssl_cacert='ca', - ssl_cert='cert', - ssl_key='key') + config_map = dict(ssl_cacert='ca', ssl_cert='cert', ssl_key='key') for key, cfg in config_map.items(): val = self.configuration.get(key) if val: @@ -216,20 +235,12 @@ def _get_ssl_parameters(self): return ssl_params def _cancel(self, thread_id): - import MySQLdb connection = None cursor = None error = None try: - connection = MySQLdb.connect(host=self.configuration.get('host', ''), - user=self.configuration.get('user', ''), - passwd=self.configuration.get('passwd', ''), - db=self.configuration['db'], - port=self.configuration.get('port', 3306), - charset='utf8', use_unicode=True, - ssl=self._get_ssl_parameters(), - connect_timeout=60) + connection = self._connection() cursor = connection.cursor() query = "KILL %d" % (thread_id) logging.debug(query) @@ -289,10 +300,11 @@ def configuration_schema(cls): def _get_ssl_parameters(self): if self.configuration.get('use_ssl'): - ca_path = os.path.join(os.path.dirname(__file__), './files/rds-combined-ca-bundle.pem') + ca_path = os.path.join(os.path.dirname(__file__), + './files/rds-combined-ca-bundle.pem') return {'ca': ca_path} - return {} + return None register(Mysql) diff --git a/redash/query_runner/pg.py b/redash/query_runner/pg.py index df5dacfba1..e9e4cc5431 100644 --- a/redash/query_runner/pg.py +++ b/redash/query_runner/pg.py @@ -38,10 +38,8 @@ def default(self, o): items = [ o._bounds[0], - str(o._lower), - ', ', - str(o._upper), - o._bounds[1] + str(o._lower), ', ', + str(o._upper), o._bounds[1] ] return ''.join(items) @@ -92,9 +90,9 @@ def configuration_schema(cls): "title": "Database Name" }, "sslmode": { - "type": "string", - "title": "SSL Mode", - "default": "prefer" + "type": "string", + "title": "SSL Mode", + "default": "prefer" } }, "order": ['host', 'port', 'user', 'password'], @@ -116,7 +114,8 @@ def _get_definitions(self, schema, query): for row in results['rows']: if row['table_schema'] != 'public': - table_name = u'{}.{}'.format(row['table_schema'], row['table_name']) + table_name = u'{}.{}'.format(row['table_schema'], + row['table_name']) else: table_name = row['table_name'] @@ -168,13 +167,14 @@ def _get_tables(self, schema): return schema.values() def _get_connection(self): - connection = psycopg2.connect(user=self.configuration.get('user'), - password=self.configuration.get('password'), - host=self.configuration.get('host'), - port=self.configuration.get('port'), - dbname=self.configuration.get('dbname'), - sslmode=self.configuration.get('sslmode'), - async_=True) + connection = psycopg2.connect( + user=self.configuration.get('user'), + password=self.configuration.get('password'), + host=self.configuration.get('host'), + port=self.configuration.get('port'), + dbname=self.configuration.get('dbname'), + sslmode=self.configuration.get('sslmode'), + async_=True) return connection @@ -189,12 +189,18 @@ def run_query(self, query, user): _wait(connection) if cursor.description is not None: - columns = self.fetch_columns([(i[0], types_map.get(i[1], None)) for i in cursor.description]) - rows = [dict(zip((c['name'] for c in columns), row)) for row in cursor] + columns = self.fetch_columns([(i[0], types_map.get(i[1], None)) + for i in cursor.description]) + rows = [ + dict(zip((c['name'] for c in columns), row)) + for row in cursor + ] data = {'columns': columns, 'rows': rows} error = None - json_data = json_dumps(data, ignore_nan=True, cls=PostgreSQLJSONEncoder) + json_data = json_dumps(data, + ignore_nan=True, + cls=PostgreSQLJSONEncoder) else: error = 'Query completed but it returned no data.' json_data = None @@ -220,22 +226,23 @@ def type(cls): return "redshift" def _get_connection(self): - sslrootcert_path = os.path.join(os.path.dirname(__file__), './files/redshift-ca-bundle.crt') - - connection = psycopg2.connect(user=self.configuration.get('user'), - password=self.configuration.get('password'), - host=self.configuration.get('host'), - port=self.configuration.get('port'), - dbname=self.configuration.get('dbname'), - sslmode=self.configuration.get('sslmode', 'prefer'), - sslrootcert=sslrootcert_path, - async_=True) + sslrootcert_path = os.path.join(os.path.dirname(__file__), + './files/redshift-ca-bundle.crt') + + connection = psycopg2.connect( + user=self.configuration.get('user'), + password=self.configuration.get('password'), + host=self.configuration.get('host'), + port=self.configuration.get('port'), + dbname=self.configuration.get('dbname'), + sslmode=self.configuration.get('sslmode', 'prefer'), + sslrootcert=sslrootcert_path, + async_=True) return connection @classmethod def configuration_schema(cls): - return { "type": "object", "properties": { @@ -256,15 +263,39 @@ def configuration_schema(cls): "title": "Database Name" }, "sslmode": { - "type": "string", - "title": "SSL Mode", - "default": "prefer" - } + "type": "string", + "title": "SSL Mode", + "default": "prefer" + }, + "adhoc_query_group": { + "type": "string", + "title": "Query Group for Adhoc Queries", + "default": "default" + }, + "scheduled_query_group": { + "type": "string", + "title": "Query Group for Scheduled Queries", + "default": "default" + }, }, - "order": ['host', 'port', 'user', 'password'], + "order": ['host', 'port', 'user', 'password', 'dbname', 'sslmode', 'adhoc_query_group', 'scheduled_query_group'], "required": ["dbname", "user", "password", "host", "port"], "secret": ["password"] } + + def annotate_query(self, query, metadata): + annotated = super(Redshift, self).annotate_query(query, metadata) + + if metadata.get('Scheduled', False): + query_group = self.configuration.get('scheduled_query_group') + else: + query_group = self.configuration.get('adhoc_query_group') + + if query_group: + set_query_group = 'set query_group to {};'.format(query_group) + annotated = '{}\n{}'.format(set_query_group, annotated) + + return annotated def _get_tables(self, schema): # Use svv_columns to include internal & external (Spectrum) tables and views data for Redshift @@ -300,7 +331,6 @@ def _get_tables(self, schema): class CockroachDB(PostgreSQL): - @classmethod def type(cls): return "cockroach" diff --git a/redash/query_runner/prometheus.py b/redash/query_runner/prometheus.py index 088291df86..6279d90a69 100644 --- a/redash/query_runner/prometheus.py +++ b/redash/query_runner/prometheus.py @@ -64,6 +64,7 @@ def convert_query_range(payload): class Prometheus(BaseQueryRunner): + should_annotate_query = False @classmethod def configuration_schema(cls): @@ -78,10 +79,6 @@ def configuration_schema(cls): "required": ["url"] } - @classmethod - def annotate_query(cls): - return False - def test_connection(self): resp = requests.get(self.configuration.get("url", None)) return resp.ok diff --git a/redash/query_runner/python.py b/redash/query_runner/python.py index 36209cd0ea..8a516965d3 100644 --- a/redash/query_runner/python.py +++ b/redash/query_runner/python.py @@ -36,6 +36,8 @@ def __call__(self): class Python(BaseQueryRunner): + should_annotate_query = False + safe_builtins = ( 'sorted', 'reversed', 'map', 'reduce', 'any', 'all', 'slice', 'filter', 'len', 'next', 'enumerate', @@ -63,10 +65,6 @@ def configuration_schema(cls): def enabled(cls): return True - @classmethod - def annotate_query(cls): - return False - def __init__(self, configuration): super(Python, self).__init__(configuration) diff --git a/redash/query_runner/qubole.py b/redash/query_runner/qubole.py index d62260cd5f..82276ca139 100644 --- a/redash/query_runner/qubole.py +++ b/redash/query_runner/qubole.py @@ -11,19 +11,26 @@ try: import qds_sdk from qds_sdk.qubole import Qubole as qbol - from qds_sdk.commands import Command, HiveCommand, PrestoCommand + from qds_sdk.commands import Command, HiveCommand + from qds_sdk.commands import SqlCommand, PrestoCommand enabled = True except ImportError: enabled = False class Qubole(BaseQueryRunner): + should_annotate_query = False @classmethod def configuration_schema(cls): return { "type": "object", "properties": { + "query_type": { + "type": "string", + "title": "Query Type (quantum / presto / hive)", + "default": "hive" + }, "endpoint": { "type": "string", "title": "API Endpoint", @@ -37,38 +44,47 @@ def configuration_schema(cls): "type": "string", "title": "Cluster Label", "default": "default" - }, - "query_type": { - "type": "string", - "title": "Query Type (hive or presto)", - "default": "hive" } }, - "order": ["endpoint", "token", "cluster"], - "required": ["endpoint", "token", "cluster"], + "order": ["query_type", "endpoint", "token", "cluster"], + "required": ["endpoint", "token"], "secret": ["token"] } @classmethod - def enabled(cls): - return enabled + def type(cls): + return "qubole" @classmethod - def annotate_query(cls): - return False + def name(cls): + return "Qubole" + + @classmethod + def enabled(cls): + return enabled def test_connection(self): headers = self._get_header() - r = requests.head("%s/api/latest/users" % self.configuration['endpoint'], headers=headers) + r = requests.head("%s/api/latest/users" % self.configuration.get('endpoint'), headers=headers) r.status_code == 200 def run_query(self, query, user): - qbol.configure(api_token=self.configuration['token'], - api_url='%s/api' % self.configuration['endpoint']) + qbol.configure(api_token=self.configuration.get('token'), + api_url='%s/api' % self.configuration.get('endpoint')) try: - cls = PrestoCommand if(self.configuration['query_type'] == 'presto') else HiveCommand - cmd = cls.create(query=query, label=self.configuration['cluster']) + query_type = self.configuration.get('query_type', 'hive') + + if query_type == 'quantum': + cmd = SqlCommand.create(query=query) + elif query_type == 'hive': + cmd = HiveCommand.create(query=query, label=self.configuration.get('cluster')) + elif query_type == 'presto': + cmd = PrestoCommand.create(query=query, label=self.configuration.get('cluster')) + else: + raise Exception("Invalid Query Type:%s.\ + It must be : hive / presto / quantum." % self.configuration.get('query_type')) + logging.info("Qubole command created with Id: %s and Status: %s", cmd.id, cmd.status) while not Command.is_done(cmd.status): @@ -106,7 +122,7 @@ def get_schema(self, get_stats=False): try: headers = self._get_header() content = requests.get("%s/api/latest/hive?describe=true&per_page=10000" % - self.configuration['endpoint'], headers=headers) + self.configuration.get('endpoint'), headers=headers) data = content.json() for schema in data['schemas']: @@ -127,7 +143,7 @@ def get_schema(self, get_stats=False): def _get_header(self): return {"Content-type": "application/json", "Accept": "application/json", - "X-AUTH-TOKEN": self.configuration['token']} + "X-AUTH-TOKEN": self.configuration.get('token')} register(Qubole) diff --git a/redash/query_runner/query_results.py b/redash/query_runner/query_results.py index 910df7c9c1..97e174e398 100644 --- a/redash/query_runner/query_results.py +++ b/redash/query_runner/query_results.py @@ -3,7 +3,7 @@ import sqlite3 from redash import models -from redash.permissions import has_access, not_view_only +from redash.permissions import has_access, view_only from redash.query_runner import BaseQueryRunner, TYPE_STRING, guess_type, register from redash.utils import json_dumps, json_loads @@ -24,7 +24,8 @@ def extract_query_ids(query): def extract_cached_query_ids(query): - queries = re.findall(r'(?:join|from)\s+cached_query_(\d+)', query, re.IGNORECASE) + queries = re.findall(r'(?:join|from)\s+cached_query_(\d+)', query, + re.IGNORECASE) return [int(q) for q in queries] @@ -34,9 +35,11 @@ def _load_query(user, query_id): if user.org_id != query.org_id: raise PermissionError("Query id {} not found.".format(query.id)) - if not has_access(query.data_source, user, not_view_only): - raise PermissionError(u"You are not allowed to execute queries on {} data source (used for query id {}).".format( - query.data_source.name, query.id)) + # TODO: this duplicates some of the logic we already have in the redash.handlers.query_results. + # We should merge it so it's consistent. + if not has_access(query.data_source, user, view_only): + raise PermissionError(u"You do not have access to query id {}.".format( + query.id)) return query @@ -47,16 +50,22 @@ def get_query_results(user, query_id, bring_from_cache): if query.latest_query_data_id is not None: results = query.latest_query_data.data else: - raise Exception("No cached result available for query {}.".format(query.id)) + raise Exception("No cached result available for query {}.".format( + query.id)) else: - results, error = query.data_source.query_runner.run_query(query.query_text, user) + results, error = query.data_source.query_runner.run_query( + query.query_text, user) if error: - raise Exception("Failed loading results for query id {}.".format(query.id)) + raise Exception("Failed loading results for query id {}.".format( + query.id)) return json_loads(results) -def create_tables_from_query_ids(user, connection, query_ids, cached_query_ids=[]): +def create_tables_from_query_ids(user, + connection, + query_ids, + cached_query_ids=[]): for query_id in set(cached_query_ids): results = get_query_results(user, query_id, True) table_name = 'cached_query_{query_id}'.format(query_id=query_id) @@ -81,8 +90,7 @@ def flatten(value): def create_table(connection, table_name, query_results): try: - columns = [column['name'] - for column in query_results['columns']] + columns = [column['name'] for column in query_results['columns']] safe_columns = [fix_column_name(column) for column in columns] column_list = ", ".join(safe_columns) @@ -91,7 +99,8 @@ def create_table(connection, table_name, query_results): logger.debug("CREATE TABLE query: %s", create_table) connection.execute(create_table) except sqlite3.OperationalError as exc: - raise CreateTableError(u"Error creating table {}: {}".format(table_name, exc.message)) + raise CreateTableError(u"Error creating table {}: {}".format( + table_name, exc.message)) insert_template = u"insert into {table_name} ({column_list}) values ({place_holders})".format( table_name=table_name, @@ -104,19 +113,12 @@ def create_table(connection, table_name, query_results): class Results(BaseQueryRunner): + should_annotate_query = False noop_query = 'SELECT 1' @classmethod def configuration_schema(cls): - return { - "type": "object", - "properties": { - } - } - - @classmethod - def annotate_query(cls): - return False + return {"type": "object", "properties": {}} @classmethod def name(cls): @@ -127,7 +129,8 @@ def run_query(self, query, user): query_ids = extract_query_ids(query) cached_query_ids = extract_cached_query_ids(query) - create_tables_from_query_ids(user, connection, query_ids, cached_query_ids) + create_tables_from_query_ids(user, connection, query_ids, + cached_query_ids) cursor = connection.cursor() @@ -135,8 +138,8 @@ def run_query(self, query, user): cursor.execute(query) if cursor.description is not None: - columns = self.fetch_columns( - [(i[0], None) for i in cursor.description]) + columns = self.fetch_columns([(i[0], None) + for i in cursor.description]) rows = [] column_names = [c['name'] for c in columns] diff --git a/redash/query_runner/salesforce.py b/redash/query_runner/salesforce.py index b1187bef58..8cc72910ff 100644 --- a/redash/query_runner/salesforce.py +++ b/redash/query_runner/salesforce.py @@ -50,15 +50,12 @@ class Salesforce(BaseQueryRunner): - + should_annotate_query = False + @classmethod def enabled(cls): return enabled - @classmethod - def annotate_query(cls): - return False - @classmethod def configuration_schema(cls): return { diff --git a/redash/query_runner/script.py b/redash/query_runner/script.py index 38e3ae62c5..6c529e9e39 100644 --- a/redash/query_runner/script.py +++ b/redash/query_runner/script.py @@ -29,9 +29,7 @@ def run_script(script, shell): class Script(BaseQueryRunner): - @classmethod - def annotate_query(cls): - return False + should_annotate_query = False @classmethod def enabled(cls): diff --git a/redash/query_runner/treasuredata.py b/redash/query_runner/treasuredata.py index 320f4e3457..895becaac3 100644 --- a/redash/query_runner/treasuredata.py +++ b/redash/query_runner/treasuredata.py @@ -34,6 +34,7 @@ class TreasureData(BaseQueryRunner): + should_annotate_query = False noop_query = "SELECT 1" @classmethod @@ -67,10 +68,6 @@ def configuration_schema(cls): def enabled(cls): return enabled - @classmethod - def annotate_query(cls): - return False - @classmethod def type(cls): return "treasuredata" diff --git a/redash/query_runner/uptycs.py b/redash/query_runner/uptycs.py index 9e6e7ff989..c2573a26d0 100644 --- a/redash/query_runner/uptycs.py +++ b/redash/query_runner/uptycs.py @@ -10,6 +10,7 @@ class Uptycs(BaseSQLQueryRunner): + should_annotate_query = False noop_query = "SELECT 1" @classmethod @@ -40,10 +41,6 @@ def configuration_schema(cls): "secret": ["secret", "key"] } - @classmethod - def annotate_query(cls): - return False - def generate_header(self, key, secret): header = {} utcnow = datetime.datetime.utcnow() diff --git a/redash/query_runner/url.py b/redash/query_runner/url.py index d32a20ee01..d53cf1a9f0 100644 --- a/redash/query_runner/url.py +++ b/redash/query_runner/url.py @@ -6,10 +6,6 @@ class Url(BaseHTTPQueryRunner): requires_url = False - @classmethod - def annotate_query(cls): - return False - def test_connection(self): pass diff --git a/redash/query_runner/yandex_metrica.py b/redash/query_runner/yandex_metrica.py index 82f47d8565..d008b8a505 100644 --- a/redash/query_runner/yandex_metrica.py +++ b/redash/query_runner/yandex_metrica.py @@ -62,9 +62,7 @@ def parse_ym_response(response): class YandexMetrica(BaseSQLQueryRunner): - @classmethod - def annotate_query(cls): - return False + should_annotate_query = False @classmethod def type(cls): diff --git a/redash/serializers/query_result.py b/redash/serializers/query_result.py index 6432795c66..ff737e3a28 100644 --- a/redash/serializers/query_result.py +++ b/redash/serializers/query_result.py @@ -70,7 +70,7 @@ def serialize_query_result_to_csv(query_result): query_data = json_loads(query_result.data) - fieldnames, special_columns = _get_column_lists(query_data['columns']) + fieldnames, special_columns = _get_column_lists(query_data['columns'] or []) writer = csv.DictWriter(s, extrasaction="ignore", fieldnames=fieldnames) writer.writer = UnicodeWriter(s) diff --git a/redash/settings/__init__.py b/redash/settings/__init__.py index e2f0323043..1a7154dfe6 100644 --- a/redash/settings/__init__.py +++ b/redash/settings/__init__.py @@ -293,6 +293,7 @@ def email_server_is_configured(): 'redash.query_runner.json_ds', 'redash.query_runner.cass', 'redash.query_runner.dgraph', + 'redash.query_runner.azure_kusto', ] enabled_query_runners = array_from_string(os.environ.get("REDASH_ENABLED_QUERY_RUNNERS", ",".join(default_query_runners))) diff --git a/redash/settings/organization.py b/redash/settings/organization.py index 37c5b73260..853a6cd4ec 100644 --- a/redash/settings/organization.py +++ b/redash/settings/organization.py @@ -35,6 +35,7 @@ os.environ.get('REDASH_SEND_EMAIL_ON_FAILED_SCHEDULED_QUERIES', 'false')) settings = { + "beacon_consent": None, "auth_password_login_enabled": PASSWORD_LOGIN_ENABLED, "auth_saml_enabled": SAML_LOGIN_ENABLED, "auth_saml_entity_id": SAML_ENTITY_ID, diff --git a/redash/tasks/queries.py b/redash/tasks/queries.py index 1ba0c8fc79..0ee9cd0ab0 100644 --- a/redash/tasks/queries.py +++ b/redash/tasks/queries.py @@ -400,16 +400,12 @@ def run(self): return result def _annotate_query(self, query_runner): - if query_runner.annotate_query(): - self.metadata['Task ID'] = self.task.request.id - self.metadata['Query Hash'] = self.query_hash - self.metadata['Queue'] = self.task.request.delivery_info['routing_key'] - - annotation = u", ".join([u"{}: {}".format(k, v) for k, v in self.metadata.iteritems()]) - annotated_query = u"/* {} */ {}".format(annotation, self.query) - else: - annotated_query = self.query - return annotated_query + self.metadata['Task ID'] = self.task.request.id + self.metadata['Query Hash'] = self.query_hash + self.metadata['Queue'] = self.task.request.delivery_info['routing_key'] + self.metadata['Scheduled'] = self.scheduled_query is not None + + return query_runner.annotate_query(self.query, self.metadata) def _log_progress(self, state): logger.info( diff --git a/redash/utils/__init__.py b/redash/utils/__init__.py index 8f14931bbf..5f396e29b9 100644 --- a/redash/utils/__init__.py +++ b/redash/utils/__init__.py @@ -21,6 +21,11 @@ from .human_time import parse_human_time +try: + buffer +except NameError: + buffer = bytes + COMMENTS_REGEX = re.compile("/\*.*?\*/") WRITER_ENCODING = os.environ.get('REDASH_CSV_WRITER_ENCODING', 'utf-8') WRITER_ERRORS = os.environ.get('REDASH_CSV_WRITER_ERRORS', 'strict') diff --git a/redash/utils/compat.py b/redash/utils/compat.py new file mode 100644 index 0000000000..cb4ebfb8ab --- /dev/null +++ b/redash/utils/compat.py @@ -0,0 +1,4 @@ +try: + long = long +except NameError: + long = int diff --git a/redash/utils/sentry.py b/redash/utils/sentry.py index b1ce2e5e5a..1947e1a3a2 100644 --- a/redash/utils/sentry.py +++ b/redash/utils/sentry.py @@ -1,6 +1,8 @@ import sentry_sdk from sentry_sdk.integrations.flask import FlaskIntegration from sentry_sdk.integrations.celery import CeleryIntegration +from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration +from sentry_sdk.integrations.redis import RedisIntegration from redash import settings, __version__ @@ -23,5 +25,5 @@ def init(): release=__version__, before_send=before_send, send_default_pii=True, - integrations=[FlaskIntegration(), CeleryIntegration()] + integrations=[FlaskIntegration(), CeleryIntegration(), SqlalchemyIntegration(), RedisIntegration()] ) diff --git a/redash/version_check.py b/redash/version_check.py index 8d6e5b0bfd..0870460b8b 100644 --- a/redash/version_check.py +++ b/redash/version_check.py @@ -4,23 +4,74 @@ from redash import __version__ as current_version from redash import redis_connection +from redash.models import db, Organization from redash.utils import json_dumps REDIS_KEY = "new_version_available" +def usage_data(): + counts_query = """ + SELECT 'users_count' as name, count(0) as value + FROM users + WHERE disabled_at is null + + UNION ALL + + SELECT 'queries_count' as name, count(0) as value + FROM queries + WHERE is_archived is false + + UNION ALL + + SELECT 'alerts_count' as name, count(0) as value + FROM alerts + + UNION ALL + + SELECT 'dashboards_count' as name, count(0) as value + FROM dashboards + WHERE is_archived is false + + UNION ALL + + SELECT 'widgets_count' as name, count(0) as value + FROM widgets + WHERE visualization_id is not null + + UNION ALL + + SELECT 'textbox_count' as name, count(0) as value + FROM widgets + WHERE visualization_id is null + """ + + data_sources_query = "SELECT type, count(0) FROM data_sources GROUP by 1" + visualizations_query = "SELECT type, count(0) FROM visualizations GROUP by 1" + destinations_query = "SELECT type, count(0) FROM notification_destinations GROUP by 1" + + data = {name: value for (name, value) in db.session.execute(counts_query)} + data['data_sources'] = {name: value for (name, value) in db.session.execute(data_sources_query)} + data['visualization_types'] = {name: value for (name, value) in db.session.execute(visualizations_query)} + data['destination_types'] = {name: value for (name, value) in db.session.execute(destinations_query)} + + return data + + def run_version_check(): logging.info("Performing version check.") logging.info("Current version: %s", current_version) - data = json_dumps({ + data = { 'current_version': current_version - }) - headers = {'content-type': 'application/json'} + } + + if Organization.query.first().get_setting('beacon_consent'): + data['usage'] = usage_data() try: response = requests.post('https://version.redash.io/api/report?channel=stable', - data=data, headers=headers, timeout=3.0) + json=data, timeout=3.0) latest_version = response.json()['release']['version'] _compare_and_update(latest_version) diff --git a/requirements.txt b/requirements.txt index d6a3363880..432d7cfd01 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,12 +21,12 @@ passlib==1.6.2 aniso8601==1.1.0 blinker==1.3 psycopg2==2.7.3.2 -python-dateutil==2.7.5 +python-dateutil==2.8.0 pytz==2016.7 PyYAML==3.12 redis==3.2.1 requests==2.21.0 -six==1.11.0 +six==1.12.0 SQLAlchemy==1.2.12 # We can't upgrade SQLAlchemy-Searchable version as newer versions require PostgreSQL > 9.6, but we target older versions at the moment. SQLAlchemy-Searchable==0.10.6 @@ -43,7 +43,7 @@ RestrictedPython==3.6.0 pysaml2==4.5.0 pycrypto==2.6.1 funcy==1.7.1 -sentry-sdk==0.7.2 +sentry-sdk==0.11.2 semver==2.2.1 xlsxwriter==0.9.3 pystache==0.5.4 @@ -56,7 +56,7 @@ user-agents==1.1.0 python-geoip-geolite2==2015.303 chromelogger==0.4.3 pypd==1.1.0 -disposable-email-domains +disposable-email-domains>=0.0.52 gevent==1.4.0 # Install the dependencies of the bin/bundle-extensions script here. # It has its own requirements file to simplify the frontend client build process diff --git a/requirements_all_ds.txt b/requirements_all_ds.txt index 387b45b3e4..71347cfae8 100644 --- a/requirements_all_ds.txt +++ b/requirements_all_ds.txt @@ -8,11 +8,11 @@ pyhive==0.5.1 pymongo[tls,srv]==3.6.1 vertica-python==0.8.0 td-client==0.8.0 -pymssql==2.1.3 +pymssql==2.1.4 dql==0.5.24 dynamo3==0.4.7 boto3==1.9.115 -botocore==1.12.115 +botocore==1.12.220 sasl>=0.1.3 thrift>=0.8.0 thrift_sasl>=0.1.0 @@ -31,3 +31,6 @@ phoenixdb==0.7 # certifi is needed to support MongoDB and SSL: certifi pydgraph==1.2.0 +azure-kusto-data==0.0.32 +# pandas is a requirement for pymapd but pip will pick versions that are python3 only +pandas==0.24.0 diff --git a/requirements_dev.txt b/requirements_dev.txt index 7e6b60a9e5..aa9a55479b 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -6,7 +6,7 @@ mock==2.0.0 # PyMongo and Athena dependencies are needed for some of the unit tests: # (this is not perfect and we should resolve this in a different way) pymongo[tls,srv]==3.6.1 -botocore==1.12.115 +botocore==1.12.220 PyAthena>=1.5.0 ptvsd==4.2.3 freezegun==0.3.11 diff --git a/tests/query_runner/test_query_results.py b/tests/query_runner/test_query_results.py index db047e587f..8c3da3e387 100644 --- a/tests/query_runner/test_query_results.py +++ b/tests/query_runner/test_query_results.py @@ -3,7 +3,9 @@ import pytest -from redash.query_runner.query_results import (CreateTableError, PermissionError, _load_query, create_table, extract_cached_query_ids, extract_query_ids, fix_column_name) +from redash.query_runner.query_results import ( + CreateTableError, PermissionError, _load_query, create_table, + extract_cached_query_ids, extract_query_ids, fix_column_name) from tests import BaseTestCase @@ -28,40 +30,86 @@ def test_finds_queries_with_whitespace_characters(self): class TestCreateTable(TestCase): def test_creates_table_with_colons_in_column_name(self): connection = sqlite3.connect(':memory:') - results = {'columns': [{'name': 'ga:newUsers'}, { - 'name': 'test2'}], 'rows': [{'ga:newUsers': 123, 'test2': 2}]} + results = { + 'columns': [{ + 'name': 'ga:newUsers' + }, { + 'name': 'test2' + }], + 'rows': [{ + 'ga:newUsers': 123, + 'test2': 2 + }] + } table_name = 'query_123' create_table(connection, table_name, results) connection.execute('SELECT 1 FROM query_123') def test_creates_table_with_double_quotes_in_column_name(self): connection = sqlite3.connect(':memory:') - results = {'columns': [{'name': 'ga:newUsers'}, { - 'name': '"test2"'}], 'rows': [{'ga:newUsers': 123, '"test2"': 2}]} + results = { + 'columns': [{ + 'name': 'ga:newUsers' + }, { + 'name': '"test2"' + }], + 'rows': [{ + 'ga:newUsers': 123, + '"test2"': 2 + }] + } table_name = 'query_123' create_table(connection, table_name, results) connection.execute('SELECT 1 FROM query_123') def test_creates_table(self): connection = sqlite3.connect(':memory:') - results = {'columns': [{'name': 'test1'}, - {'name': 'test2'}], 'rows': []} + results = { + 'columns': [{ + 'name': 'test1' + }, { + 'name': 'test2' + }], + 'rows': [] + } table_name = 'query_123' create_table(connection, table_name, results) connection.execute('SELECT 1 FROM query_123') def test_creates_table_with_missing_columns(self): connection = sqlite3.connect(':memory:') - results = {'columns': [{'name': 'test1'}, {'name': 'test2'}], 'rows': [ - {'test1': 1, 'test2': 2}, {'test1': 3}]} + results = { + 'columns': [{ + 'name': 'test1' + }, { + 'name': 'test2' + }], + 'rows': [{ + 'test1': 1, + 'test2': 2 + }, { + 'test1': 3 + }] + } table_name = 'query_123' create_table(connection, table_name, results) connection.execute('SELECT 1 FROM query_123') def test_creates_table_with_spaces_in_column_name(self): connection = sqlite3.connect(':memory:') - results = {'columns': [{'name': 'two words'}, {'name': 'test2'}], 'rows': [ - {'two words': 1, 'test2': 2}, {'test1': 3}]} + results = { + 'columns': [{ + 'name': 'two words' + }, { + 'name': 'test2' + }], + 'rows': [{ + 'two words': 1, + 'test2': 2 + }, { + 'test1': 3 + }] + } table_name = 'query_123' create_table(connection, table_name, results) connection.execute('SELECT 1 FROM query_123') @@ -69,8 +117,15 @@ def test_creates_table_with_spaces_in_column_name(self): def test_creates_table_with_dashes_in_column_name(self): connection = sqlite3.connect(':memory:') results = { - 'columns': [{'name': 'two-words'}, {'name': 'test2'}], - 'rows': [{'two-words': 1, 'test2': 2}] + 'columns': [{ + 'name': 'two-words' + }, { + 'name': 'test2' + }], + 'rows': [{ + 'two-words': 1, + 'test2': 2 + }] } table_name = 'query_123' create_table(connection, table_name, results) @@ -79,8 +134,17 @@ def test_creates_table_with_dashes_in_column_name(self): def test_creates_table_with_non_ascii_in_column_name(self): connection = sqlite3.connect(':memory:') - results = {'columns': [{'name': u'\xe4'}, {'name': 'test2'}], 'rows': [ - {u'\xe4': 1, 'test2': 2}]} + results = { + 'columns': [{ + 'name': u'\xe4' + }, { + 'name': 'test2' + }], + 'rows': [{ + u'\xe4': 1, + 'test2': 2 + }] + } table_name = 'query_123' create_table(connection, table_name, results) connection.execute('SELECT 1 FROM query_123') @@ -95,8 +159,14 @@ def test_shows_meaningful_error_on_failure_to_create_table(self): def test_loads_results(self): connection = sqlite3.connect(':memory:') rows = [{'test1': 1, 'test2': 'test'}, {'test1': 2, 'test2': 'test2'}] - results = {'columns': [{'name': 'test1'}, - {'name': 'test2'}], 'rows': rows} + results = { + 'columns': [{ + 'name': 'test1' + }, { + 'name': 'test2' + }], + 'rows': rows + } table_name = 'query_123' create_table(connection, table_name, results) self.assertEquals( @@ -104,9 +174,15 @@ def test_loads_results(self): def test_loads_list_and_dict_results(self): connection = sqlite3.connect(':memory:') - rows = [{'test1': [1,2,3]}, {'test2': {'a': 'b'}}] - results = {'columns': [{'name': 'test1'}, - {'name': 'test2'}], 'rows': rows} + rows = [{'test1': [1, 2, 3]}, {'test2': {'a': 'b'}}] + results = { + 'columns': [{ + 'name': 'test1' + }, { + 'name': 'test2' + }], + 'rows': rows + } table_name = 'query_123' create_table(connection, table_name, results) self.assertEquals( @@ -135,6 +211,15 @@ def test_returns_query(self): loaded = _load_query(user, query.id) self.assertEquals(query, loaded) + def test_returns_query_when_user_has_view_only_access(self): + ds = self.factory.create_data_source( + group=self.factory.org.default_group, view_only=True) + query = self.factory.create_query(data_source=ds) + user = self.factory.create_user() + + loaded = _load_query(user, query.id) + self.assertEquals(query, loaded) + class TestExtractCachedQueryIds(TestCase): def test_works_with_simple_query(self): diff --git a/tests/test_utils.py b/tests/test_utils.py index 9117ccc9dd..493d3fcaef 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -4,6 +4,11 @@ from redash.utils import (build_url, collect_parameters_from_request, filter_none, json_dumps, generate_token) +try: + buffer +except NameError: + buffer = bytes + DummyRequest = namedtuple('DummyRequest', ['host', 'scheme'])