From d415506f5eb206cd41cf499d9b9c706d226f5e88 Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Mon, 25 Feb 2019 17:06:16 +0200 Subject: [PATCH 01/41] Migrate visualizations registry/renderer/editor to React (base for following visualizations migration) --- client/app/assets/less/ant.less | 66 ++++- client/app/assets/less/redash/query.less | 2 +- client/app/components/Filters.jsx | 122 +++++++++ client/app/components/dashboards/widget.html | 6 +- client/app/components/dashboards/widget.js | 1 + client/app/components/filters.html | 44 ---- client/app/components/filters.js | 30 --- client/app/lib/utils.js | 48 +++- client/app/pages/dashboards/dashboard.html | 5 +- client/app/pages/dashboards/dashboard.js | 38 +-- client/app/pages/dashboards/dashboard.less | 8 +- .../dashboards/public-dashboard-page.html | 4 +- .../pages/dashboards/public-dashboard-page.js | 8 +- client/app/pages/queries/query.html | 3 +- client/app/pages/queries/view.js | 29 ++- client/app/services/dashboard.js | 30 +++ client/app/services/query-result.js | 84 +----- client/app/services/widget.js | 11 +- .../EditVisualizationDialog.jsx | 211 +++++++++++++++ .../visualizations/VisualizationRenderer.jsx | 89 +++++++ client/app/visualizations/_index.js | 148 +++++++++++ client/app/visualizations/box-plot/index.js | 2 +- client/app/visualizations/chart/index.js | 2 +- client/app/visualizations/choropleth/index.js | 2 +- client/app/visualizations/cohort/index.js | 2 +- .../counter/counter-editor.html | 38 +-- client/app/visualizations/counter/index.js | 242 +++++++++--------- .../edit-visualization-dialog.js | 2 +- client/app/visualizations/funnel/index.js | 2 +- client/app/visualizations/index.js | 200 +++++---------- client/app/visualizations/map/index.js | 2 +- client/app/visualizations/pivot/index.js | 2 +- client/app/visualizations/sankey/index.js | 2 +- client/app/visualizations/sunburst/index.js | 2 +- client/app/visualizations/table/index.js | 154 +++++------ .../visualizations/table/table-editor.html | 22 +- client/app/visualizations/table/table.html | 2 +- client/app/visualizations/word-cloud/index.js | 2 +- package-lock.json | 68 ++++- package.json | 1 + 40 files changed, 1116 insertions(+), 620 deletions(-) create mode 100644 client/app/components/Filters.jsx delete mode 100644 client/app/components/filters.html delete mode 100644 client/app/components/filters.js create mode 100644 client/app/visualizations/EditVisualizationDialog.jsx create mode 100644 client/app/visualizations/VisualizationRenderer.jsx create mode 100644 client/app/visualizations/_index.js diff --git a/client/app/assets/less/ant.less b/client/app/assets/less/ant.less index a4b8eb2cb0..21d61ddc3d 100644 --- a/client/app/assets/less/ant.less +++ b/client/app/assets/less/ant.less @@ -210,20 +210,60 @@ } } -// styling for short modals (no lines) -.@{dialog-prefix-cls}.shortModal { - .@{dialog-prefix-cls} { - &-header, &-footer { - border: none; - padding: 16px; - } - &-body { - padding: 10px 16px; +.@{dialog-prefix-cls} { + // styling for short modals (no lines) + &.shortModal { + .@{dialog-prefix-cls} { + &-header, &-footer { + border: none; + padding: 16px; + } + + &-body { + padding: 10px 16px; + } + + &-close-x { + width: 46px; + height: 46px; + line-height: 46px; + } } - &-close-x { - width: 46px; - height: 46px; - line-height: 46px; + } + + // fullscreen modals + &-fullscreen { + .@{dialog-prefix-cls} { + position: absolute; + left: 15px; + top: 15px; + right: 15px; + bottom: 15px; + width: auto !important; + height: auto !important; + max-width: none; + max-height: none; + margin: 0; + padding: 0; + + .@{dialog-prefix-cls}-content { + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; + width: auto; + height: auto; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + } + + .@{dialog-prefix-cls}-body { + flex: 1 1 auto; + overflow: auto; + } } } } diff --git a/client/app/assets/less/redash/query.less b/client/app/assets/less/redash/query.less index 1cb76bff2e..577b10fd10 100644 --- a/client/app/assets/less/redash/query.less +++ b/client/app/assets/less/redash/query.less @@ -216,7 +216,7 @@ edit-in-place p.editable:hover { .widget-wrapper { .body-container { - filters { + .filters-wrapper { display: block; padding-left: 15px; } diff --git a/client/app/components/Filters.jsx b/client/app/components/Filters.jsx new file mode 100644 index 0000000000..a151ce0917 --- /dev/null +++ b/client/app/components/Filters.jsx @@ -0,0 +1,122 @@ +import { isArray, map, find, includes, every, some } from 'lodash'; +import moment from 'moment'; +import React from 'react'; +import PropTypes from 'prop-types'; +import { react2angular } from 'react2angular'; +import Select from 'antd/lib/select'; + +const ALL_VALUES = '###Redash::Filters::SelectAll###'; +const NONE_VALUES = '###Redash::Filters::Clear###'; + +export const FilterType = PropTypes.shape({ + name: PropTypes.string.isRequired, + friendlyName: PropTypes.string.isRequired, + multiple: PropTypes.bool, + current: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.arrayOf(PropTypes.any), + ]).isRequired, + values: PropTypes.arrayOf(PropTypes.any).isRequired, +}); + +export const FiltersType = PropTypes.arrayOf(FilterType); + +function createFilterChangeHandler(filters, onChange) { + return (filter, value) => { + if (filter.multiple && includes(value, ALL_VALUES)) { + value = [...filter.values]; + } + if (filter.multiple && includes(value, NONE_VALUES)) { + value = []; + } + const allFilters = map(filters, f => (f.name === filter.name ? { ...filter, current: value } : f)); + const changedFilter = find(allFilters, f => f.name === filter.name); + onChange(allFilters, changedFilter); + }; +} + +export function filterData(rows, filters = []) { + if (!isArray(rows)) { + return []; + } + + let result = rows; + + if (isArray(filters) && (filters.length > 0)) { + // "every" field's value should match "some" of corresponding filter's values + result = result.filter(row => every( + filters, + (filter) => { + const rowValue = row[filter.name]; + const filterValues = isArray(filter.current) ? filter.current : [filter.current]; + return some(filterValues, (filterValue) => { + if (moment.isMoment(rowValue)) { + return rowValue.isSame(filterValue); + } + // We compare with either the value or the String representation of the value, + // because Select2 casts true/false to "true"/"false". + return (filterValue === rowValue) || (String(rowValue) === filterValue); + }); + }, + )); + } + + return result; +} + +export function Filters({ filters, onChange }) { + if (filters.length === 0) { + return null; + } + + onChange = createFilterChangeHandler(filters, onChange); + + return ( +
+
+
+ {map(filters, (filter) => { + const options = map(filter.values, value => ( + {value} + )); + + return ( +
+ + +
+ ); + })} +
+
+
+ ); +} + +Filters.propTypes = { + filters: FiltersType.isRequired, + onChange: PropTypes.func, // (name, value) => void +}; + +Filters.defaultProps = { + onChange: () => {}, +}; + +export default function init(ngModule) { + ngModule.component('filters', react2angular(Filters)); +} + +init.init = true; diff --git a/client/app/components/dashboards/widget.html b/client/app/components/dashboards/widget.html index 94954841f7..37ad307bb5 100644 --- a/client/app/components/dashboards/widget.html +++ b/client/app/components/dashboards/widget.html @@ -46,7 +46,11 @@
Error running query: {{$ctrl.widget.getQueryResult().getError()}}
- +
diff --git a/client/app/components/dashboards/widget.js b/client/app/components/dashboards/widget.js index b200ce15d6..03f7735b20 100644 --- a/client/app/components/dashboards/widget.js +++ b/client/app/components/dashboards/widget.js @@ -150,6 +150,7 @@ export default function init(ngModule) { widget: '<', public: '<', dashboard: '<', + filters: '<', deleted: '&onDelete', }, }); diff --git a/client/app/components/filters.html b/client/app/components/filters.html deleted file mode 100644 index 9393e923e8..0000000000 --- a/client/app/components/filters.html +++ /dev/null @@ -1,44 +0,0 @@ -
-
-
- - - - {{$select.selected | filterValue:filter}} - - {{value | filterValue:filter }} - - - - - {{$item | filterValue:filter}} - - - Select All - - - Clear - - - {{value | filterValue:filter }} - - - -
-
-
diff --git a/client/app/components/filters.js b/client/app/components/filters.js deleted file mode 100644 index f83194abdb..0000000000 --- a/client/app/components/filters.js +++ /dev/null @@ -1,30 +0,0 @@ -import template from './filters.html'; - -const FiltersComponent = { - template, - bindings: { - onChange: '&', - filters: '<', - }, - controller() { - 'ngInject'; - - this.filterChangeListener = (filter, modal) => { - this.onChange({ filter, $modal: modal }); - }; - - this.itemGroup = (item) => { - if (item === '*' || item === '-') { - return ''; - } - - return 'Values'; - }; - }, -}; - -export default function init(ngModule) { - ngModule.component('filters', FiltersComponent); -} - -init.init = true; diff --git a/client/app/lib/utils.js b/client/app/lib/utils.js index 170b4bdd5e..6fb52035b2 100644 --- a/client/app/lib/utils.js +++ b/client/app/lib/utils.js @@ -1,4 +1,4 @@ -import { isFunction, each, extend } from 'lodash'; +import { isFunction, isObject, cloneDeep, each, extend } from 'lodash'; export function routesToAngularRoutes(routes, template) { const result = {}; @@ -29,3 +29,49 @@ export function cancelEvent(handler) { } return doCancelEvent; } + +// ANGULAR_REMOVE_ME +export function cleanAngularProps(value) { + // remove all props that start with '$$' - that's what `angular.toJson` does + const omitAngularProps = (obj) => { + each(obj, (v, k) => { + if (('' + k).startsWith('$$')) { + delete obj[k]; + } else { + obj[k] = isObject(v) ? omitAngularProps(v) : v; + } + }); + return obj; + }; + + const result = cloneDeep(value); + return isObject(result) ? omitAngularProps(result) : result; +} + +export function createPromiseHandler(toPromise, onResolved, onRejected = null) { + let lastValue = null; + let isCancelled = false; + + function handle(value) { + if (value !== lastValue) { + lastValue = value; + toPromise(value) + .then((result) => { + if (!isCancelled && (lastValue === value) && isFunction(onResolved)) { + onResolved(result); + } + }) + .catch((error) => { + if (!isCancelled && (lastValue === value) && isFunction(onRejected)) { + onRejected(error); + } + }); + } + } + + handle.cancel = () => { + isCancelled = true; + }; + + return handle; +} diff --git a/client/app/pages/dashboards/dashboard.html b/client/app/pages/dashboards/dashboard.html index 73c883d603..17a73e8277 100644 --- a/client/app/pages/dashboards/dashboard.html +++ b/client/app/pages/dashboards/dashboard.html @@ -87,7 +87,7 @@

- +
@@ -98,7 +98,8 @@

ng-repeat="widget in $ctrl.dashboard.widgets track by widget.id" gridstack-item="widget.options.position" gridstack-item-id="{{ widget.id }}">
- +

diff --git a/client/app/pages/dashboards/dashboard.js b/client/app/pages/dashboards/dashboard.js index 1cecdf6a30..e74635f637 100644 --- a/client/app/pages/dashboards/dashboard.js +++ b/client/app/pages/dashboards/dashboard.js @@ -2,6 +2,7 @@ import * as _ from 'lodash'; import PromiseRejectionError from '@/lib/promise-rejection-error'; import getTags from '@/services/getTags'; import { policy } from '@/services/policy'; +import { collectDashboardFilters } from '@/services/dashboard'; import { durationHumanize } from '@/filters'; import template from './dashboard.html'; import ShareDashboardDialog from './ShareDashboardDialog'; @@ -77,6 +78,7 @@ function DashboardCtrl( this.showPermissionsControl = clientConfig.showPermissionsControl; this.globalParameters = []; this.isDashboardOwner = false; + this.filters = []; this.refreshRates = clientConfig.dashboardRefreshIntervals.map(interval => ({ name: durationHumanize(interval), @@ -116,38 +118,10 @@ function DashboardCtrl( })); $q.all(queryResultPromises).then((queryResults) => { - const filters = {}; - queryResults.forEach((queryResult) => { - const queryFilters = queryResult.getFilters(); - queryFilters.forEach((queryFilter) => { - const hasQueryStringValue = _.has($location.search(), queryFilter.name); - - if (!(hasQueryStringValue || dashboard.dashboard_filters_enabled)) { - // If dashboard filters not enabled, or no query string value given, - // skip filters linking. - return; - } - - if (hasQueryStringValue) { - queryFilter.current = $location.search()[queryFilter.name]; - } - - if (!_.has(filters, queryFilter.name)) { - const filter = _.extend({}, queryFilter); - filters[filter.name] = filter; - filters[filter.name].originFilters = []; - } - - // TODO: merge values. - filters[queryFilter.name].originFilters.push(queryFilter); - }); - }); - - this.filters = _.values(filters); - this.filtersOnChange = (filter) => { - _.each(filter.originFilters, (originFilter) => { - originFilter.current = filter.current; - }); + this.filters = collectDashboardFilters(dashboard, queryResults, $location.search()); + this.filtersOnChange = (allFilters) => { + this.filters = allFilters; + $scope.$applyAsync(); }; }); }; diff --git a/client/app/pages/dashboards/dashboard.less b/client/app/pages/dashboards/dashboard.less index bb685b4fbf..93773b8b7d 100644 --- a/client/app/pages/dashboards/dashboard.less +++ b/client/app/pages/dashboards/dashboard.less @@ -45,14 +45,14 @@ right: 0; bottom: 0; - > filters { - flex-grow: 0; - } - > div { flex-grow: 1; position: relative; } + + > .filters-wrapper { + flex-grow: 0; + } } .sunburst-visualization-container, diff --git a/client/app/pages/dashboards/public-dashboard-page.html b/client/app/pages/dashboards/public-dashboard-page.html index 4377410336..950aa10706 100644 --- a/client/app/pages/dashboards/public-dashboard-page.html +++ b/client/app/pages/dashboards/public-dashboard-page.html @@ -2,7 +2,7 @@
- +
@@ -11,7 +11,7 @@ ng-repeat="widget in $ctrl.dashboard.widgets" gridstack-item="widget.options.position" gridstack-item-id="{{ widget.id }}">
- +
diff --git a/client/app/pages/dashboards/public-dashboard-page.js b/client/app/pages/dashboards/public-dashboard-page.js index 7cac64741d..5b769168e2 100644 --- a/client/app/pages/dashboards/public-dashboard-page.js +++ b/client/app/pages/dashboards/public-dashboard-page.js @@ -12,7 +12,7 @@ const PublicDashboardPage = { bindings: { dashboard: '<', }, - controller($timeout, $location, $http, $route, dashboardGridOptions, Dashboard) { + controller($scope, $timeout, $location, $http, $route, dashboardGridOptions, Dashboard) { 'ngInject'; this.dashboardGridOptions = Object.assign({}, dashboardGridOptions, { @@ -32,6 +32,12 @@ const PublicDashboardPage = { this.dashboard = data; this.dashboard.widgets = Dashboard.prepareDashboardWidgets(this.dashboard.widgets); + this.filters = []; // TODO: implement (@/services/dashboard.js:collectDashboardFilters) + this.filtersOnChange = (allFilters) => { + this.filters = allFilters; + $scope.$applyAsync(); + }; + $timeout(refresh, refreshRate * 1000.0); }); }; diff --git a/client/app/pages/queries/query.html b/client/app/pages/queries/query.html index 67a27e0487..8cc58487b2 100644 --- a/client/app/pages/queries/query.html +++ b/client/app/pages/queries/query.html @@ -239,8 +239,7 @@

- - +
diff --git a/client/app/pages/queries/view.js b/client/app/pages/queries/view.js index 276feab73b..f03cc50e07 100644 --- a/client/app/pages/queries/view.js +++ b/client/app/pages/queries/view.js @@ -4,6 +4,8 @@ import getTags from '@/services/getTags'; import { policy } from '@/services/policy'; import Notifications from '@/services/notifications'; import ScheduleDialog from '@/components/queries/ScheduleDialog'; +import { Visualization } from '@/visualizations'; +import EditVisualizationDialog from '@/visualizations/EditVisualizationDialog'; import template from './query.html'; const DEFAULT_TAB = 'table'; @@ -25,7 +27,6 @@ function QueryViewCtrl( currentUser, Query, DataSource, - Visualization, ) { function getQueryResult(maxAge, selectedQueryText) { if (maxAge === undefined) { @@ -138,6 +139,14 @@ function QueryViewCtrl( $scope.query = $route.current.locals.query; $scope.showPermissionsControl = clientConfig.showPermissionsControl; + $scope.defaultVis = { + type: 'TABLE', + name: 'Table', + options: { + itemsPerPage: 50, + }, + }; + const shortcuts = { 'mod+enter': $scope.executeQuery, }; @@ -426,18 +435,14 @@ function QueryViewCtrl( } $scope.openVisualizationEditor = (visId) => { - const visualization = getVisualization(visId); - function openModal() { - $uibModal.open({ - windowClass: 'modal-xl', - component: 'editVisualizationDialog', - resolve: { - query: $scope.query, - visualization, - queryResult: $scope.queryResult, - onNewSuccess: () => $scope.setVisualizationTab, - }, + EditVisualizationDialog.showModal({ + query: $scope.query, + visualization: getVisualization(visId), + queryResult: $scope.queryResult, + }).result.then((visualization) => { + $scope.setVisualizationTab(visualization); + $scope.$applyAsync(); }); } diff --git a/client/app/services/dashboard.js b/client/app/services/dashboard.js index 5e4bbec2b4..f9a1bf578e 100644 --- a/client/app/services/dashboard.js +++ b/client/app/services/dashboard.js @@ -2,6 +2,36 @@ import _ from 'lodash'; export let Dashboard = null; // eslint-disable-line import/no-mutable-exports +export function collectDashboardFilters(dashboard, queryResults, urlParams) { + const filters = {}; + queryResults.forEach((queryResult) => { + const queryFilters = queryResult.getFilters(); + queryFilters.forEach((queryFilter) => { + const hasQueryStringValue = _.has(urlParams, queryFilter.name); + + if (!(hasQueryStringValue || dashboard.dashboard_filters_enabled)) { + // If dashboard filters not enabled, or no query string value given, + // skip filters linking. + return; + } + + if (hasQueryStringValue) { + queryFilter.current = urlParams[queryFilter.name]; + } + + if (!_.has(filters, queryFilter.name)) { + const filter = { ...queryFilter }; + filters[filter.name] = filter; + } + + // TODO: merge values. -- maybe - intersect?? + // filters[queryFilter.name].originFilters.push(queryFilter); + }); + }); + + return _.values(filters); +} + function prepareWidgetsForDashboard(widgets) { // Default height for auto-height widgets. // Compute biggest widget size and choose between it and some magic number. diff --git a/client/app/services/query-result.js b/client/app/services/query-result.js index 06fa301c9b..80bf1b1f92 100644 --- a/client/app/services/query-result.js +++ b/client/app/services/query-result.js @@ -1,13 +1,10 @@ import debug from 'debug'; import moment from 'moment'; -import { sortBy, uniqBy, values, some, each, isArray, isNumber, isString, includes, forOwn } from 'lodash'; +import { sortBy, uniqBy, values, each, isNumber, isString, includes, extend, forOwn } from 'lodash'; const logger = debug('redash:services:QueryResult'); const filterTypes = ['filter', 'multi-filter', 'multiFilter']; -const ALL_VALUES = '*'; -const NONE_VALUES = '-'; - function getColumnNameWithoutType(column) { let typeSplit; if (column.indexOf('::') !== -1) { @@ -82,8 +79,6 @@ function QueryResultService($resource, $timeout, $q, QueryResultError) { this.job = {}; this.query_result = {}; this.status = 'waiting'; - this.filters = undefined; - this.filterFreeze = undefined; this.updatedAt = moment(); @@ -96,12 +91,10 @@ function QueryResultService($resource, $timeout, $q, QueryResultError) { } update(props) { - Object.assign(this, props); + extend(this, props); if ('query_result' in props) { this.status = 'done'; - this.filters = undefined; - this.filterFreeze = undefined; const columnTypes = {}; @@ -208,59 +201,7 @@ function QueryResultService($resource, $timeout, $q, QueryResultError) { } getData() { - if (!this.query_result.data) { - return null; - } - - function filterValues(filters) { - if (!filters) { - return null; - } - - return filters.reduce((str, filter) => str + filter.current, ''); - } - - const filters = this.getFilters(); - const filterFreeze = filterValues(filters); - - if (this.filterFreeze !== filterFreeze) { - this.filterFreeze = filterFreeze; - - if (filters) { - filters.forEach((filter) => { - if (filter.multiple && includes(filter.current, ALL_VALUES)) { - filter.current = filter.values.slice(2); - } - - if (filter.multiple && includes(filter.current, NONE_VALUES)) { - filter.current = []; - } - }); - - this.filteredData = this.query_result.data.rows.filter(row => filters.reduce((memo, filter) => { - if (!isArray(filter.current)) { - filter.current = [filter.current]; - } - - return ( - memo && - some(filter.current, (v) => { - const value = row[filter.name]; - if (moment.isMoment(value)) { - return value.isSame(v); - } - // We compare with either the value or the String representation of the value, - // because Select2 casts true/false to "true"/"false". - return v === value || String(value) === v; - }) - ); - }, true)); - } else { - this.filteredData = this.query_result.data.rows; - } - } - - return this.filteredData; + return this.query_result.data ? this.query_result.data.rows : null; } isEmpty() { @@ -373,16 +314,8 @@ function QueryResultService($resource, $timeout, $q, QueryResultError) { } getFilters() { - if (!this.filters) { - this.prepareFilters(); - } - - return this.filters; - } - - prepareFilters() { if (!this.getColumns()) { - return; + return []; } const filters = []; @@ -416,13 +349,6 @@ function QueryResultService($resource, $timeout, $q, QueryResultError) { }); }); - filters.forEach((filter) => { - if (filter.multiple) { - filter.values.unshift(ALL_VALUES); - filter.values.unshift(NONE_VALUES); - } - }); - filters.forEach((filter) => { filter.values = uniqBy(filter.values, (v) => { if (moment.isMoment(v)) { @@ -432,7 +358,7 @@ function QueryResultService($resource, $timeout, $q, QueryResultError) { }); }); - this.filters = filters; + return filters; } toPromise() { diff --git a/client/app/services/widget.js b/client/app/services/widget.js index 9bd4221e96..1a131bbf90 100644 --- a/client/app/services/widget.js +++ b/client/app/services/widget.js @@ -1,9 +1,10 @@ import moment from 'moment'; import { each, pick, extend, isObject, truncate, keys, difference, filter, map } from 'lodash'; +import { registeredVisualizations } from '@/visualizations'; export let Widget = null; // eslint-disable-line import/no-mutable-exports -function calculatePositionOptions(Visualization, dashboardGridOptions, widget) { +function calculatePositionOptions(dashboardGridOptions, widget) { widget.width = 1; // Backward compatibility, user on back-end const visualizationOptions = { @@ -16,9 +17,9 @@ function calculatePositionOptions(Visualization, dashboardGridOptions, widget) { maxSizeY: dashboardGridOptions.maxSizeY, }; - const visualization = widget.visualization ? Visualization.visualizations[widget.visualization.type] : null; + const visualization = widget.visualization ? registeredVisualizations[widget.visualization.type] : null; if (isObject(visualization)) { - const options = extend({}, visualization.defaultOptions); + const options = extend({}, visualization.getOptions({}, { columns: [], rows: [] })); if (Object.prototype.hasOwnProperty.call(options, 'autoHeight')) { visualizationOptions.autoHeight = options.autoHeight; @@ -69,7 +70,7 @@ export const ParameterMappingType = { StaticValue: 'static-value', }; -function WidgetFactory($http, $location, Query, Visualization, dashboardGridOptions) { +function WidgetFactory($http, $location, Query, dashboardGridOptions) { class WidgetService { static MappingType = ParameterMappingType; @@ -79,7 +80,7 @@ function WidgetFactory($http, $location, Query, Visualization, dashboardGridOpti this[k] = v; }); - const visualizationOptions = calculatePositionOptions(Visualization, dashboardGridOptions, this); + const visualizationOptions = calculatePositionOptions(dashboardGridOptions, this); this.options = this.options || {}; this.options.position = extend( diff --git a/client/app/visualizations/EditVisualizationDialog.jsx b/client/app/visualizations/EditVisualizationDialog.jsx new file mode 100644 index 0000000000..d5851b7173 --- /dev/null +++ b/client/app/visualizations/EditVisualizationDialog.jsx @@ -0,0 +1,211 @@ +import { isEqual, extend, map, findIndex, cloneDeep } from 'lodash'; +import React from 'react'; +import PropTypes from 'prop-types'; +import Modal from 'antd/lib/modal'; +import Select from 'antd/lib/select'; +import Input from 'antd/lib/input'; +import { wrap as wrapDialog, DialogPropType } from '@/components/DialogWrapper'; +import { Visualization, registeredVisualizations, getDefaultVisualization, newVisualization } from './index'; + +import { toastr } from '@/services/ng'; +import recordEvent from '@/services/recordEvent'; + +// ANGULAR_REMOVE_ME Remove when all visualizations will be migrated to React +import { cleanAngularProps } from '@/lib/utils'; + +class EditVisualizationDialog extends React.Component { + static propTypes = { + dialog: DialogPropType.isRequired, + query: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types + visualization: PropTypes.object, // eslint-disable-line react/forbid-prop-types + queryResult: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types + }; + + static defaultProps = { + visualization: null, + }; + + constructor(props) { + super(props); + + const { visualization, queryResult } = this.props; + + const isNew = !visualization; + + const config = isNew ? getDefaultVisualization() + : registeredVisualizations[visualization.type]; + + // it's safe to use queryResult right here because while query is running - + // all UI to access this dialog is hidden/disabled + const data = { + columns: queryResult.getColumns(), + rows: queryResult.getData(), + }; + + const options = config.getOptions(isNew ? {} : visualization.options, data); + + this.state = { + isNew, // eslint-disable-line + data, + + type: config.type, + canChangeType: isNew, // cannot change type when editing existing visualization + name: isNew ? config.name : visualization.name, + nameChanged: false, + originalOptions: cloneDeep(options), + options, + + saveInProgress: false, + }; + } + + setVisualizationType(type) { + this.setState(({ isNew, name, nameChanged, data }) => { + const { visualization } = this.props; + const config = registeredVisualizations[type]; + const options = config.getOptions(isNew ? {} : visualization.options, data); + return { + type, + name: nameChanged ? name : config.name, + options, + }; + }); + } + + setVisualizationName(name) { + this.setState({ + name, + nameChanged: true, + }); + } + + setVisualizationOptions = (options) => { + this.setState({ options: extend({}, options) }); + }; + + dismiss() { + const { nameChanged, options, originalOptions } = this.state; + + const optionsChanged = !isEqual(cleanAngularProps(options), originalOptions); + if (nameChanged || optionsChanged) { + Modal.confirm({ + title: 'Visualization Editor', + content: 'Are you sure you want to close the editor without saving?', + okText: 'Yes', + cancelText: 'No', + onOk: () => { + this.props.dialog.dismiss(); + }, + }); + } else { + this.props.dialog.dismiss(); + } + } + + save() { + const { query } = this.props; + const { type, name, options } = this.state; + + const visualization = { + ...extend({}, this.props.visualization), + ...newVisualization(type), + }; + + visualization.name = name; + visualization.options = options; + visualization.query_id = query.id; + + if (visualization.id) { + recordEvent('update', 'visualization', visualization.id, { type: visualization.type }); + } else { + recordEvent('create', 'visualization', null, { type: visualization.type }); + } + + this.setState({ saveInProgress: true }); + + Visualization.save(visualization).$promise + .then((result) => { + toastr.success('Visualization saved'); + + const index = findIndex(query.visualizations, v => v.id === result.id); + if (index > -1) { + query.visualizations[index] = result; + } else { + // new visualization + query.visualizations.push(result); + } + this.props.dialog.close(result); + }) + .catch(() => { + toastr.error('Visualization could not be saved'); + this.setState({ saveInProgress: false }); + }); + } + + render() { + const { dialog } = this.props; + const { type, name, data, options, canChangeType, saveInProgress } = this.state; + + const { Renderer, Editor, getOptions } = registeredVisualizations[type]; + + const previewOptions = getOptions(options, data); + + return ( + this.save()} + onCancel={() => this.dismiss()} + > +
+
+
+ + +
+
+ + this.setVisualizationName(event.target.value)} + /> +
+
+ +
+
+
+ + +
+
+
+ ); + } +} + +export default wrapDialog(EditVisualizationDialog); diff --git a/client/app/visualizations/VisualizationRenderer.jsx b/client/app/visualizations/VisualizationRenderer.jsx new file mode 100644 index 0000000000..7f1893f310 --- /dev/null +++ b/client/app/visualizations/VisualizationRenderer.jsx @@ -0,0 +1,89 @@ +import { isArray } from 'lodash'; +import React from 'react'; +import PropTypes from 'prop-types'; +import { react2angular } from 'react2angular'; +import { Filters, FiltersType, filterData } from '@/components/Filters'; +import { createPromiseHandler } from '@/lib/utils'; +import { registeredVisualizations, VisualizationType } from './index'; + +function chooseFilters(globalFilters, localFilters) { + return isArray(globalFilters) && (globalFilters.length > 0) ? globalFilters : localFilters; +} + +export class VisualizationRenderer extends React.Component { + static propTypes = { + visualization: VisualizationType.isRequired, + queryResult: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types + filters: FiltersType, + }; + + static defaultProps = { + filters: [], + }; + + state = { + allRows: [], // eslint-disable-line + data: { columns: [], rows: [] }, + filters: this.props.filters, // use global filters by default, if available + }; + + handleQueryResult = createPromiseHandler( + queryResult => queryResult.toPromise(), + () => { + const { queryResult } = this.props; + const columns = queryResult ? queryResult.getColumns() : []; + const rows = queryResult ? queryResult.getData() : []; + this.setState({ + allRows: rows, // eslint-disable-line + data: { columns, rows }, + }); + this.applyFilters( + // If global filters available, use them, otherwise get new local filters from query + chooseFilters(this.props.filters, queryResult.getFilters()), + ); + }, + ); + + componentDidUpdate(prevProps) { + if (this.props.filters !== prevProps.filters) { + // When global filters changed - apply them instead of local + this.applyFilters(this.props.filters); + } + } + + componentWillUnmount() { + this.handleQueryResult.cancel(); + } + + applyFilters = (filters) => { + this.setState(({ allRows, data }) => ({ + filters, + data: { + columns: data.columns, + rows: filterData(allRows, filters), + }, + })); + }; + + render() { + const { visualization, queryResult } = this.props; + const { data, filters } = this.state; + const { Renderer, getOptions } = registeredVisualizations[visualization.type]; + const options = getOptions(visualization.options, data); + + this.handleQueryResult(queryResult); + + return ( + + + + + ); + } +} + +export default function init(ngModule) { + ngModule.component('visualizationRenderer', react2angular(VisualizationRenderer)); +} + +init.init = true; diff --git a/client/app/visualizations/_index.js b/client/app/visualizations/_index.js new file mode 100644 index 0000000000..e8a826de57 --- /dev/null +++ b/client/app/visualizations/_index.js @@ -0,0 +1,148 @@ +import moment from 'moment'; +import { isArray, reduce } from 'lodash'; + +function VisualizationProvider() { + this.visualizations = {}; + // this.visualizationTypes = {}; + this.visualizationTypes = []; + const defaultConfig = { + defaultOptions: {}, + skipTypes: false, + editorTemplate: null, + }; + + this.registerVisualization = (config) => { + const visualization = Object.assign({}, defaultConfig, config); + + // TODO: this is prone to errors; better refactor. + if (this.defaultVisualization === undefined && !visualization.name.match(/Deprecated/)) { + this.defaultVisualization = visualization; + } + + this.visualizations[config.type] = visualization; + + if (!config.skipTypes) { + this.visualizationTypes.push({ name: config.name, type: config.type }); + } + }; + + this.getSwitchTemplate = (property) => { + const pattern = /(<[a-zA-Z0-9-]*?)( |>)/; + + let mergedTemplates = reduce( + this.visualizations, + (templates, visualization) => { + if (visualization[property]) { + const ngSwitch = `$1 ng-switch-when="${visualization.type}" $2`; + const template = visualization[property].replace(pattern, ngSwitch); + + return `${templates}\n${template}`; + } + + return templates; + }, + '', + ); + + mergedTemplates = `
${mergedTemplates}
`; + + return mergedTemplates; + }; + + this.$get = ($resource) => { + const Visualization = $resource('api/visualizations/:id', { id: '@id' }); + Visualization.visualizations = this.visualizations; + Visualization.visualizationTypes = this.visualizationTypes; + Visualization.renderVisualizationsTemplate = this.getSwitchTemplate('renderTemplate'); + Visualization.editorTemplate = this.getSwitchTemplate('editorTemplate'); + Visualization.defaultVisualization = this.defaultVisualization; + + return Visualization; + }; +} + +function VisualizationName(Visualization) { + return { + restrict: 'E', + scope: { + visualization: '=', + }, + template: '{{name}}', + replace: false, + link(scope) { + if (Visualization.visualizations[scope.visualization.type]) { + const defaultName = Visualization.visualizations[scope.visualization.type].name; + if (defaultName !== scope.visualization.name) { + scope.name = scope.visualization.name; + } + } + }, + }; +} + +function VisualizationRenderer(Visualization) { + return { + restrict: 'E', + scope: { + visualization: '=', + queryResult: '=', + }, + // TODO: using switch here (and in the options editor) might introduce errors and bad + // performance wise. It's better to eventually show the correct template based on the + // visualization type and not make the browser render all of them. + template: `\n${Visualization.renderVisualizationsTemplate}`, + replace: false, + link(scope) { + scope.$watch('queryResult && queryResult.getFilters()', (filters) => { + if (filters) { + scope.filters = filters; + } + }); + }, + }; +} + +function VisualizationOptionsEditor(Visualization) { + return { + restrict: 'E', + template: Visualization.editorTemplate, + replace: false, + scope: { + visualization: '=', + query: '=', + queryResult: '=', + }, + }; +} + +function FilterValueFilter(clientConfig) { + return (value, filter) => { + let firstValue = value; + if (isArray(value)) { + firstValue = value[0]; + } + + // TODO: deduplicate code with table.js: + if (filter.column.type === 'date') { + if (firstValue && moment.isMoment(firstValue)) { + return firstValue.format(clientConfig.dateFormat); + } + } else if (filter.column.type === 'datetime') { + if (firstValue && moment.isMoment(firstValue)) { + return firstValue.format(clientConfig.dateTimeFormat); + } + } + + return firstValue; + }; +} + +export default function init(ngModule) { + ngModule.provider('Visualization', VisualizationProvider); + ngModule.directive('visualizationRenderer', VisualizationRenderer); + ngModule.directive('visualizationOptionsEditor', VisualizationOptionsEditor); + ngModule.directive('visualizationName', VisualizationName); + ngModule.filter('filterValue', FilterValueFilter); +} + +// init.init = true; diff --git a/client/app/visualizations/box-plot/index.js b/client/app/visualizations/box-plot/index.js index 59c266929a..96c47c3c60 100644 --- a/client/app/visualizations/box-plot/index.js +++ b/client/app/visualizations/box-plot/index.js @@ -191,4 +191,4 @@ export default function init(ngModule) { }); } -init.init = true; +// init.init = true; diff --git a/client/app/visualizations/chart/index.js b/client/app/visualizations/chart/index.js index 4343bfce95..3c0c480889 100644 --- a/client/app/visualizations/chart/index.js +++ b/client/app/visualizations/chart/index.js @@ -368,4 +368,4 @@ export default function init(ngModule) { }); } -init.init = true; +// init.init = true; diff --git a/client/app/visualizations/choropleth/index.js b/client/app/visualizations/choropleth/index.js index ff45d47e59..46809525ca 100644 --- a/client/app/visualizations/choropleth/index.js +++ b/client/app/visualizations/choropleth/index.js @@ -310,4 +310,4 @@ export default function init(ngModule) { }); } -init.init = true; +// init.init = true; diff --git a/client/app/visualizations/cohort/index.js b/client/app/visualizations/cohort/index.js index 3125820b96..db2915a5b4 100644 --- a/client/app/visualizations/cohort/index.js +++ b/client/app/visualizations/cohort/index.js @@ -236,4 +236,4 @@ export default function init(ngModule) { }); } -init.init = true; +// init.init = true; diff --git a/client/app/visualizations/counter/counter-editor.html b/client/app/visualizations/counter/counter-editor.html index d6f5cb75de..82ecd199a6 100644 --- a/client/app/visualizations/counter/counter-editor.html +++ b/client/app/visualizations/counter/counter-editor.html @@ -1,84 +1,86 @@
-
+
- +
- +
- +
-
-
+
- +
-
+
- +
- +
- +
- +
- +
diff --git a/client/app/visualizations/counter/index.js b/client/app/visualizations/counter/index.js index 15fa98e107..e3a832a7f4 100644 --- a/client/app/visualizations/counter/index.js +++ b/client/app/visualizations/counter/index.js @@ -1,9 +1,23 @@ import numberFormat from 'underscore.string/numberFormat'; import { isNumber } from 'lodash'; +import { angular2react } from 'angular2react'; +import { registerVisualization } from '@/visualizations'; import counterTemplate from './counter.html'; import counterEditorTemplate from './counter-editor.html'; +const DEFAULT_OPTIONS = { + counterColName: 'counter', + rowNumber: 1, + targetRowNumber: 1, + stringDecimal: 0, + stringDecChar: '.', + stringThouSep: ',', + defaultColumns: 2, + defaultRows: 5, +}; + +// TODO: Need to review this function, it does not properly handle edge cases. function getRowNumber(index, size) { if (index >= 0) { return index - 1; @@ -16,134 +30,134 @@ function getRowNumber(index, size) { return size + index; } -function CounterRenderer($timeout) { - return { - restrict: 'E', - template: counterTemplate, - link($scope, $element) { - $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 refreshData = () => { - const queryData = $scope.queryResult.getData(); - if (queryData) { - const rowNumber = getRowNumber($scope.visualization.options.rowNumber, queryData.length); - const targetRowNumber = getRowNumber($scope.visualization.options.targetRowNumber, queryData.length); - const counterColName = $scope.visualization.options.counterColName; - const targetColName = $scope.visualization.options.targetColName; - const counterLabel = $scope.visualization.options.counterLabel; - - if (counterLabel) { - $scope.counterLabel = counterLabel; - } else { - $scope.counterLabel = $scope.visualization.name; - } +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 + }; - if ($scope.visualization.options.countRow) { - $scope.counterValue = queryData.length; - } else if (counterColName) { - $scope.counterValue = queryData[rowNumber][counterColName]; - } - if (targetColName) { - $scope.targetValue = queryData[targetRowNumber][targetColName]; - - if ($scope.targetValue) { - $scope.delta = $scope.counterValue - $scope.targetValue; - $scope.trendPositive = $scope.delta >= 0; - } - } else { - $scope.targetValue = null; + 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]; + } + if (targetColName) { + $scope.targetValue = data[targetRowNumber][targetColName]; + + if ($scope.targetValue) { + $scope.delta = $scope.counterValue - $scope.targetValue; + $scope.trendPositive = $scope.delta >= 0; } + } else { + $scope.targetValue = null; + } - $scope.isNumber = isNumber($scope.counterValue); - if ($scope.isNumber) { - $scope.stringPrefix = $scope.visualization.options.stringPrefix; - $scope.stringSuffix = $scope.visualization.options.stringSuffix; - - const stringDecimal = $scope.visualization.options.stringDecimal; - const stringDecChar = $scope.visualization.options.stringDecChar; - const stringThouSep = $scope.visualization.options.stringThouSep; - if (stringDecimal || stringDecChar || stringThouSep) { - $scope.counterValue = numberFormat($scope.counterValue, stringDecimal, stringDecChar, stringThouSep); - $scope.isNumber = false; - } - } else { - $scope.stringPrefix = null; - $scope.stringSuffix = null; + $scope.isNumber = isNumber($scope.counterValue); + if ($scope.isNumber) { + $scope.stringPrefix = options.stringPrefix; + $scope.stringSuffix = options.stringSuffix; + + const stringDecimal = options.stringDecimal; + const stringDecChar = options.stringDecChar; + const stringThouSep = options.stringThouSep; + if (stringDecimal || stringDecChar || stringThouSep) { + $scope.counterValue = numberFormat($scope.counterValue, stringDecimal, stringDecChar, stringThouSep); + $scope.isNumber = false; } + } else { + $scope.stringPrefix = null; + $scope.stringSuffix = null; } + } - $timeout(() => { - $scope.handleResize(); - }); - }; + $timeout(() => { + $scope.handleResize(); + }); + }; - $scope.$watch('visualization.options', refreshData, true); - $scope.$watch('queryResult && queryResult.getData()', refreshData); - }, - }; -} + $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; + }; -function CounterEditor() { - return { - restrict: 'E', - template: counterEditorTemplate, - link(scope) { - scope.currentTab = 'general'; - scope.changeTab = (tab) => { - scope.currentTab = tab; - }; - scope.isValueNumber = () => { - const queryData = scope.queryResult.getData(); - if (queryData) { - const rowNumber = getRowNumber(scope.visualization.options.rowNumber, queryData.length); - const counterColName = scope.visualization.options.counterColName; - - if (scope.visualization.options.countRow) { - scope.counterValue = queryData.length; - } else if (counterColName) { - scope.counterValue = queryData[rowNumber][counterColName]; - } + 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(scope.counterValue); - }; - }, - }; -} + } -export default function init(ngModule) { - ngModule.directive('counterEditor', CounterEditor); - ngModule.directive('counterRenderer', CounterRenderer); - - ngModule.config((VisualizationProvider) => { - const renderTemplate = - ''; - - const editTemplate = ''; - const defaultOptions = { - counterColName: 'counter', - rowNumber: 1, - targetRowNumber: 1, - stringDecimal: 0, - stringDecChar: '.', - stringThouSep: ',', - defaultColumns: 2, - defaultRows: 5, + return isNumber(this.counterValue); }; - VisualizationProvider.registerVisualization({ + $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', - renderTemplate, - editorTemplate: editTemplate, - defaultOptions, + getOptions: options => ({ ...DEFAULT_OPTIONS, ...options }), + Renderer: angular2react('counterRenderer', CounterRenderer, $injector), + Editor: angular2react('counterEditor', CounterEditor, $injector), }); }); } diff --git a/client/app/visualizations/edit-visualization-dialog.js b/client/app/visualizations/edit-visualization-dialog.js index c3d61b6b03..227cc983b8 100644 --- a/client/app/visualizations/edit-visualization-dialog.js +++ b/client/app/visualizations/edit-visualization-dialog.js @@ -95,4 +95,4 @@ export default function init(ngModule) { ngModule.component('editVisualizationDialog', EditVisualizationDialog); } -init.init = true; +// init.init = true; diff --git a/client/app/visualizations/funnel/index.js b/client/app/visualizations/funnel/index.js index 4b38f7cf28..42dbac9a63 100644 --- a/client/app/visualizations/funnel/index.js +++ b/client/app/visualizations/funnel/index.js @@ -238,4 +238,4 @@ export default function init(ngModule) { }); } -init.init = true; +// init.init = true; diff --git a/client/app/visualizations/index.js b/client/app/visualizations/index.js index 99f2e2e550..4c26572eee 100644 --- a/client/app/visualizations/index.js +++ b/client/app/visualizations/index.js @@ -1,148 +1,76 @@ -import moment from 'moment'; -import { isArray, reduce } from 'lodash'; - -function VisualizationProvider() { - this.visualizations = {}; - // this.visualizationTypes = {}; - this.visualizationTypes = []; - const defaultConfig = { - defaultOptions: {}, - skipTypes: false, - editorTemplate: null, - }; - - this.registerVisualization = (config) => { - const visualization = Object.assign({}, defaultConfig, config); - - // TODO: this is prone to errors; better refactor. - if (this.defaultVisualization === undefined && !visualization.name.match(/Deprecated/)) { - this.defaultVisualization = visualization; - } - - this.visualizations[config.type] = visualization; - - if (!config.skipTypes) { - this.visualizationTypes.push({ name: config.name, type: config.type }); - } - }; - - this.getSwitchTemplate = (property) => { - const pattern = /(<[a-zA-Z0-9-]*?)( |>)/; - - let mergedTemplates = reduce( - this.visualizations, - (templates, visualization) => { - if (visualization[property]) { - const ngSwitch = `$1 ng-switch-when="${visualization.type}" $2`; - const template = visualization[property].replace(pattern, ngSwitch); - - return `${templates}\n${template}`; - } - - return templates; - }, - '', - ); - - mergedTemplates = `
${mergedTemplates}
`; - - return mergedTemplates; - }; - - this.$get = ($resource) => { - const Visualization = $resource('api/visualizations/:id', { id: '@id' }); - Visualization.visualizations = this.visualizations; - Visualization.visualizationTypes = this.visualizationTypes; - Visualization.renderVisualizationsTemplate = this.getSwitchTemplate('renderTemplate'); - Visualization.editorTemplate = this.getSwitchTemplate('editorTemplate'); - Visualization.defaultVisualization = this.defaultVisualization; - - return Visualization; - }; -} - -function VisualizationName(Visualization) { - return { - restrict: 'E', - scope: { - visualization: '=', - }, - template: '{{name}}', - replace: false, - link(scope) { - if (Visualization.visualizations[scope.visualization.type]) { - const defaultName = Visualization.visualizations[scope.visualization.type].name; - if (defaultName !== scope.visualization.name) { - scope.name = scope.visualization.name; - } - } - }, - }; +import { find } from 'lodash'; +import PropTypes from 'prop-types'; + +export let Visualization = null; // eslint-disable-line import/no-mutable-exports + +export const registeredVisualizations = {}; + +// for `registerVisualization` +export const VisualizationConfig = PropTypes.shape({ + type: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + getOptions: PropTypes.func.isRequired, // (existingOptions: object, data: { columns[], rows[] }) => object + isDeprecated: PropTypes.bool, + Renderer: PropTypes.func.isRequired, + Editor: PropTypes.func, +}); + +export const VisualizationType = PropTypes.shape({ + type: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + options: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types +}); + +// For each visualization's renderer +export const RendererPropTypes = { + visualizationName: PropTypes.string, + data: PropTypes.shape({ + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + rows: PropTypes.arrayOf(PropTypes.object).isRequired, + }).isRequired, + options: PropTypes.object.isRequired, + onOptionsChange: PropTypes.func, +}; + +// For each visualization's editor +export const EditorPropTypes = { + visualizationName: PropTypes.string, + data: PropTypes.shape({ + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + rows: PropTypes.arrayOf(PropTypes.object).isRequired, + }).isRequired, + options: PropTypes.object.isRequired, + onOptionsChange: PropTypes.func, +}; + +export function registerVisualization(config) { + config = { ...config }; // clone + + if (registeredVisualizations[config.type]) { + throw new Error(`Visualization ${config.type} already registered.`); + } + + registeredVisualizations[config.type] = config; } -function VisualizationRenderer(Visualization) { - return { - restrict: 'E', - scope: { - visualization: '=', - queryResult: '=', - }, - // TODO: using switch here (and in the options editor) might introduce errors and bad - // performance wise. It's better to eventually show the correct template based on the - // visualization type and not make the browser render all of them. - template: `\n${Visualization.renderVisualizationsTemplate}`, - replace: false, - link(scope) { - scope.$watch('queryResult && queryResult.getFilters()', (filters) => { - if (filters) { - scope.filters = filters; - } - }); - }, - }; +export function getDefaultVisualization() { + return find(registeredVisualizations, visualization => !visualization.name.match(/Deprecated/)); } -function VisualizationOptionsEditor(Visualization) { +export function newVisualization(type = null) { + const visualization = type ? registeredVisualizations[type] : getDefaultVisualization(); return { - restrict: 'E', - template: Visualization.editorTemplate, - replace: false, - scope: { - visualization: '=', - query: '=', - queryResult: '=', - }, - }; -} - -function FilterValueFilter(clientConfig) { - return (value, filter) => { - let firstValue = value; - if (isArray(value)) { - firstValue = value[0]; - } - - // TODO: deduplicate code with table.js: - if (filter.column.type === 'date') { - if (firstValue && moment.isMoment(firstValue)) { - return firstValue.format(clientConfig.dateFormat); - } - } else if (filter.column.type === 'datetime') { - if (firstValue && moment.isMoment(firstValue)) { - return firstValue.format(clientConfig.dateTimeFormat); - } - } - - return firstValue; + type: visualization.type, + name: visualization.name, + description: '', + options: {}, }; } export default function init(ngModule) { - ngModule.provider('Visualization', VisualizationProvider); - ngModule.directive('visualizationRenderer', VisualizationRenderer); - ngModule.directive('visualizationOptionsEditor', VisualizationOptionsEditor); - ngModule.directive('visualizationName', VisualizationName); - ngModule.filter('filterValue', FilterValueFilter); + ngModule.run(($resource) => { + Visualization = $resource('api/visualizations/:id', { id: '@id' }); + }); } init.init = true; diff --git a/client/app/visualizations/map/index.js b/client/app/visualizations/map/index.js index e7b799bfe8..96643f2dbf 100644 --- a/client/app/visualizations/map/index.js +++ b/client/app/visualizations/map/index.js @@ -308,4 +308,4 @@ export default function init(ngModule) { }); } -init.init = true; +// init.init = true; diff --git a/client/app/visualizations/pivot/index.js b/client/app/visualizations/pivot/index.js index fa1b38b526..1fd9ecb0d7 100644 --- a/client/app/visualizations/pivot/index.js +++ b/client/app/visualizations/pivot/index.js @@ -102,4 +102,4 @@ export default function init(ngModule) { }); } -init.init = true; +// init.init = true; diff --git a/client/app/visualizations/sankey/index.js b/client/app/visualizations/sankey/index.js index 6ca87ccbc3..e015e7faa9 100644 --- a/client/app/visualizations/sankey/index.js +++ b/client/app/visualizations/sankey/index.js @@ -284,4 +284,4 @@ export default function init(ngModule) { }); } -init.init = true; +// init.init = true; diff --git a/client/app/visualizations/sunburst/index.js b/client/app/visualizations/sunburst/index.js index 78327984c9..11a6ea1c4a 100644 --- a/client/app/visualizations/sunburst/index.js +++ b/client/app/visualizations/sunburst/index.js @@ -56,4 +56,4 @@ export default function init(ngModule) { }); } -init.init = true; +// init.init = true; diff --git a/client/app/visualizations/table/index.js b/client/app/visualizations/table/index.js index 2d494d0361..8e3a0ec2d6 100644 --- a/client/app/visualizations/table/index.js +++ b/client/app/visualizations/table/index.js @@ -1,6 +1,9 @@ import _ from 'lodash'; +import { angular2react } from 'angular2react'; import { getColumnCleanName } from '@/services/query-result'; +import { clientConfig } from '@/services/auth'; import { createFormatter } from '@/lib/value-format'; +import { registerVisualization } from '@/visualizations'; import template from './table.html'; import editorTemplate from './table-editor.html'; import './table-editor.less'; @@ -53,7 +56,7 @@ function getDefaultColumnsOptions(columns) { })); } -function getDefaultFormatOptions(column, clientConfig) { +function getDefaultFormatOptions(column) { const dateTimeFormat = { date: clientConfig.dateFormat || 'DD/MM/YYYY', datetime: clientConfig.dateTimeFormat || 'DD/MM/YYYY HH:mm', @@ -120,10 +123,10 @@ function getColumnsOptions(columns, visualizationColumns) { return _.sortBy(options, 'order'); } -function getColumnsToDisplay(columns, options, clientConfig) { +function getColumnsToDisplay(columns, options) { columns = _.fromPairs(_.map(columns, col => [col.name, col])); let result = _.map(options, col => _.extend( - getDefaultFormatOptions(col, clientConfig), + getDefaultFormatOptions(col), col, columns[col.name], )); @@ -135,99 +138,74 @@ function getColumnsToDisplay(columns, options, clientConfig) { return _.sortBy(_.filter(result, 'visible'), 'order'); } -function GridRenderer(clientConfig) { - return { - restrict: 'E', - scope: { - queryResult: '=', - options: '=', - }, - template, - replace: false, - controller($scope) { - $scope.gridColumns = []; - $scope.gridRows = []; - - function update() { - if ($scope.queryResult.getData() == null) { - $scope.gridColumns = []; - $scope.filters = []; - } else { - $scope.filters = $scope.queryResult.getFilters(); - $scope.gridRows = $scope.queryResult.getData(); - const columns = $scope.queryResult.getColumns(); - const columnsOptions = getColumnsOptions(columns, _.extend({}, $scope.options).columns); - $scope.gridColumns = getColumnsToDisplay(columns, columnsOptions, clientConfig); - } +const GridRenderer = { + bindings: { + data: '<', + options: '<', + }, + template, + controller($scope) { + const update = () => { + this.gridColumns = []; + this.gridRows = []; + if (this.data) { + this.gridColumns = getColumnsToDisplay(this.data.columns, this.options.columns); + this.gridRows = this.data.rows; } + }; + update(); - $scope.$watch('queryResult && queryResult.getData()', (queryResult) => { - if (queryResult) { - update(); - } - }); - - $scope.$watch('options', (newValue, oldValue) => { - if (newValue !== oldValue) { - update(); - } - }, true); - }, - }; -} + $scope.$watch('$ctrl.data', update); + $scope.$watch('$ctrl.options', update, true); + }, +}; -function GridEditor(clientConfig) { - return { - restrict: 'E', - template: editorTemplate, - link: ($scope) => { - $scope.allowedItemsPerPage = ALLOWED_ITEM_PER_PAGE; - $scope.displayAsOptions = DISPLAY_AS_OPTIONS; - - $scope.currentTab = 'columns'; - $scope.setCurrentTab = (tab) => { - $scope.currentTab = tab; - }; - - $scope.$watch('visualization', () => { - if ($scope.visualization) { - // For existing visualization - set default options - $scope.visualization.options = _.extend({}, DEFAULT_OPTIONS, $scope.visualization.options); - } - }); - - $scope.$watch('queryResult && queryResult.getData()', (queryResult) => { - if (queryResult) { - const columns = $scope.queryResult.getData() !== null ? $scope.queryResult.getColumns() : []; - $scope.visualization.options.columns = _.map( - getColumnsOptions(columns, $scope.visualization.options.columns), - col => _.extend(getDefaultFormatOptions(col, clientConfig), col), - ); - } - }); - - $scope.templateHint = ` - All columns can be referenced using {{ column_name }} syntax. - Use {{ @ }} to reference current (this) column. - This syntax is applicable to URL, Title and Size options. - `; - }, - }; -} +const GridEditor = { + bindings: { + data: '<', + options: '<', + onOptionsChange: '<', + }, + template: editorTemplate, + controller($scope) { + this.allowedItemsPerPage = ALLOWED_ITEM_PER_PAGE; + this.displayAsOptions = DISPLAY_AS_OPTIONS; + + this.currentTab = 'columns'; + this.setCurrentTab = (tab) => { + this.currentTab = tab; + }; + + $scope.$watch('$ctrl.options', (options) => { + this.onOptionsChange(options); + }, true); + + this.templateHint = ` + All columns can be referenced using {{ column_name }} syntax. + Use {{ @ }} to reference current (this) column. + This syntax is applicable to URL, Title and Size options. + `; + }, +}; export default function init(ngModule) { - ngModule.directive('gridRenderer', GridRenderer); - ngModule.directive('gridEditor', GridEditor); - - ngModule.config((VisualizationProvider) => { - const defaultOptions = DEFAULT_OPTIONS; + ngModule.component('gridRenderer', GridRenderer); + ngModule.component('gridEditor', GridEditor); - VisualizationProvider.registerVisualization({ + ngModule.run(($injector) => { + registerVisualization({ type: 'TABLE', name: 'Table', - renderTemplate: '', - editorTemplate: '', - defaultOptions, + getOptions: (options, { columns }) => { + options = { ...DEFAULT_OPTIONS, ...options }; + options.columns = _.map( + getColumnsOptions(columns, options.columns), + col => ({ ...getDefaultFormatOptions(col), ...col }), + ); + return options; + }, + Renderer: angular2react('gridRenderer', GridRenderer, $injector), + Editor: angular2react('gridEditor', GridEditor, $injector), }); }); } diff --git a/client/app/visualizations/table/table-editor.html b/client/app/visualizations/table/table-editor.html index 35c364ee38..46add78a8b 100644 --- a/client/app/visualizations/table/table-editor.html +++ b/client/app/visualizations/table/table-editor.html @@ -1,25 +1,25 @@
-
+
-
-
-
+
+
@@ -46,7 +46,7 @@
-
diff --git a/client/app/visualizations/table/table.html b/client/app/visualizations/table/table.html index 6b8eb6db95..96f4a331f1 100644 --- a/client/app/visualizations/table/table.html +++ b/client/app/visualizations/table/table.html @@ -1 +1 @@ - + diff --git a/client/app/visualizations/word-cloud/index.js b/client/app/visualizations/word-cloud/index.js index aa328b6a41..479204a1fa 100644 --- a/client/app/visualizations/word-cloud/index.js +++ b/client/app/visualizations/word-cloud/index.js @@ -111,4 +111,4 @@ export default function init(ngModule) { }); } -init.init = true; +// init.init = true; diff --git a/package-lock.json b/package-lock.json index 07d52769e3..314a997c7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -881,6 +881,15 @@ "resolved": "https://registry.npmjs.org/angular-vs-repeat/-/angular-vs-repeat-1.1.7.tgz", "integrity": "sha1-7kD97lsYqC/NEoKhrPuzlCyM0Hc=" }, + "angular2react": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/angular2react/-/angular2react-3.0.2.tgz", + "integrity": "sha1-EkniFEaXXcgsDk2o7QnAzUWBCiU=", + "requires": { + "lodash.kebabcase": "^4.1.1", + "ngimport": "^1.0.0" + } + }, "ansi-colors": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-3.1.0.tgz", @@ -3766,6 +3775,7 @@ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.5.tgz", "integrity": "sha1-k4NwpXtKUd6ix3wV1cX9+JUWQAk=", "dev": true, + "optional": true, "requires": { "delayed-stream": "~1.0.0" } @@ -7023,7 +7033,8 @@ }, "ansi-regex": { "version": "2.1.1", - "bundled": true + "bundled": true, + "optional": true }, "aproba": { "version": "1.2.0", @@ -7041,11 +7052,13 @@ }, "balanced-match": { "version": "1.0.0", - "bundled": true + "bundled": true, + "optional": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, + "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -7058,15 +7071,18 @@ }, "code-point-at": { "version": "1.1.0", - "bundled": true + "bundled": true, + "optional": true }, "concat-map": { "version": "0.0.1", - "bundled": true + "bundled": true, + "optional": true }, "console-control-strings": { "version": "1.1.0", - "bundled": true + "bundled": true, + "optional": true }, "core-util-is": { "version": "1.0.2", @@ -7169,7 +7185,8 @@ }, "inherits": { "version": "2.0.3", - "bundled": true + "bundled": true, + "optional": true }, "ini": { "version": "1.3.5", @@ -7179,6 +7196,7 @@ "is-fullwidth-code-point": { "version": "1.0.0", "bundled": true, + "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -7191,17 +7209,20 @@ "minimatch": { "version": "3.0.4", "bundled": true, + "optional": true, "requires": { "brace-expansion": "^1.1.7" } }, "minimist": { "version": "0.0.8", - "bundled": true + "bundled": true, + "optional": true }, "minipass": { "version": "2.2.4", "bundled": true, + "optional": true, "requires": { "safe-buffer": "^5.1.1", "yallist": "^3.0.0" @@ -7218,6 +7239,7 @@ "mkdirp": { "version": "0.5.1", "bundled": true, + "optional": true, "requires": { "minimist": "0.0.8" } @@ -7290,7 +7312,8 @@ }, "number-is-nan": { "version": "1.0.1", - "bundled": true + "bundled": true, + "optional": true }, "object-assign": { "version": "4.1.1", @@ -7300,6 +7323,7 @@ "once": { "version": "1.4.0", "bundled": true, + "optional": true, "requires": { "wrappy": "1" } @@ -7375,7 +7399,8 @@ }, "safe-buffer": { "version": "5.1.1", - "bundled": true + "bundled": true, + "optional": true }, "safer-buffer": { "version": "2.1.2", @@ -7405,6 +7430,7 @@ "string-width": { "version": "1.0.2", "bundled": true, + "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -7422,6 +7448,7 @@ "strip-ansi": { "version": "3.0.1", "bundled": true, + "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -7460,11 +7487,13 @@ }, "wrappy": { "version": "1.0.2", - "bundled": true + "bundled": true, + "optional": true }, "yallist": { "version": "3.0.2", - "bundled": true + "bundled": true, + "optional": true } } }, @@ -11193,6 +11222,7 @@ "resolved": "https://registry.npmjs.org/boom/-/boom-2.10.1.tgz", "integrity": "sha1-OciRjO/1eZ+D+UkqhI9iWt0Mdm8=", "dev": true, + "optional": true, "requires": { "hoek": "2.x.x" } @@ -11254,7 +11284,8 @@ "version": "2.16.3", "resolved": "https://registry.npmjs.org/hoek/-/hoek-2.16.3.tgz", "integrity": "sha1-ILt0A9POo5jpHcRxCo/xuCdKJe0=", - "dev": true + "dev": true, + "optional": true }, "http-signature": { "version": "1.1.1", @@ -11759,6 +11790,11 @@ "integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA=", "dev": true }, + "lodash.kebabcase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz", + "integrity": "sha1-hImxyw0p/4gZXM7KRI/21swpXDY=" + }, "lodash.keys": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/lodash.keys/-/lodash.keys-3.1.2.tgz", @@ -12910,6 +12946,14 @@ "lodash": "^4.17.4" } }, + "ngimport": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ngimport/-/ngimport-1.0.0.tgz", + "integrity": "sha1-LDvn7eaVmaDHmvOuyNZBO/IPT/E=", + "requires": { + "@types/angular": "^1.6.34" + } + }, "nice-try": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", diff --git a/package.json b/package.json index a89d966c94..2204c6e76c 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "angular-ui-ace": "^0.2.3", "angular-ui-bootstrap": "^2.5.0", "angular-vs-repeat": "^1.1.7", + "angular2react": "^3.0.2", "antd": "^3.12.3", "bootstrap": "^3.3.7", "brace": "^0.11.0", From 8e0d8ff10824cbbee4de1b60a377440b08cdd804 Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Thu, 28 Feb 2019 15:13:57 +0200 Subject: [PATCH 02/41] Word Cloud --- client/app/visualizations/word-cloud/index.js | 166 +++++++++--------- .../word-cloud/word-cloud-editor.html | 2 +- 2 files changed, 88 insertions(+), 80 deletions(-) diff --git a/client/app/visualizations/word-cloud/index.js b/client/app/visualizations/word-cloud/index.js index 479204a1fa..aaa20271a0 100644 --- a/client/app/visualizations/word-cloud/index.js +++ b/client/app/visualizations/word-cloud/index.js @@ -1,10 +1,15 @@ import d3 from 'd3'; -import angular from 'angular'; import cloud from 'd3-cloud'; import { each } from 'lodash'; +import { angular2react } from 'angular2react'; +import { registerVisualization } from '@/visualizations'; import editorTemplate from './word-cloud-editor.html'; +const DEFAULT_OPTIONS = { + defaultRows: 8, +}; + function findWordFrequencies(data, columnName) { const wordsHash = {}; @@ -22,93 +27,96 @@ function findWordFrequencies(data, columnName) { return wordsHash; } -function wordCloudRenderer() { - return { - restrict: 'E', - link($scope, elem) { - function reloadCloud() { - if (!angular.isDefined($scope.queryResult)) return; - - const data = $scope.queryResult.getData(); - let wordsHash = {}; - - if ($scope.visualization.options.column) { - wordsHash = findWordFrequencies(data, $scope.visualization.options.column); - } - - const wordList = []; - each(wordsHash, (v, key) => { - wordList.push({ text: key, size: 10 + Math.pow(v, 2) }); - }); - - const fill = d3.scale.category20(); - const layout = cloud() - .size([500, 500]) - .words(wordList) - .padding(5) - .rotate(() => Math.floor(Math.random() * 2) * 90) - .font('Impact') - .fontSize(d => d.size); - - function draw(words) { - d3.select(elem[0].parentNode) - .select('svg') - .remove(); - - d3.select(elem[0].parentNode) - .append('svg') - .attr('width', layout.size()[0]) - .attr('height', layout.size()[1]) - .append('g') - .attr('transform', `translate(${layout.size()[0] / 2},${layout.size()[1] / 2})`) - .selectAll('text') - .data(words) - .enter() - .append('text') - .style('font-size', d => `${d.size}px`) - .style('font-family', 'Impact') - .style('fill', (d, i) => fill(i)) - .attr('text-anchor', 'middle') - .attr('transform', d => `translate(${[d.x, d.y]})rotate(${d.rotate})`) - .text(d => d.text); - } - - layout.on('end', draw); - - layout.start(); +const WordCloudRenderer = { + restrict: 'E', + bindings: { + data: '<', + options: '<', + }, + controller($scope, $element) { + const update = () => { + const data = this.data.rows; + const options = this.options; + + let wordsHash = {}; + if (options.column) { + wordsHash = findWordFrequencies(data, options.column); } - $scope.$watch('queryResult && queryResult.getData()', reloadCloud); - $scope.$watch('visualization.options.column', reloadCloud); - }, - }; -} + const wordList = []; + each(wordsHash, (v, key) => { + wordList.push({ text: key, size: 10 + Math.pow(v, 2) }); + }); + + const fill = d3.scale.category20(); + const layout = cloud() + .size([500, 500]) + .words(wordList) + .padding(5) + .rotate(() => Math.floor(Math.random() * 2) * 90) + .font('Impact') + .fontSize(d => d.size); + + function draw(words) { + d3.select($element[0].parentNode) + .select('svg') + .remove(); + + d3.select($element[0].parentNode) + .append('svg') + .attr('width', layout.size()[0]) + .attr('height', layout.size()[1]) + .append('g') + .attr('transform', `translate(${layout.size()[0] / 2},${layout.size()[1] / 2})`) + .selectAll('text') + .data(words) + .enter() + .append('text') + .style('font-size', d => `${d.size}px`) + .style('font-family', 'Impact') + .style('fill', (d, i) => fill(i)) + .attr('text-anchor', 'middle') + .attr('transform', d => `translate(${[d.x, d.y]})rotate(${d.rotate})`) + .text(d => d.text); + } -function wordCloudEditor() { - return { - restrict: 'E', - template: editorTemplate, - }; -} + layout.on('end', draw); + + layout.start(); + }; + + $scope.$watch('$ctrl.data', update); + $scope.$watch('$ctrl.options', update, true); + }, +}; + +const WordCloudEditor = { + template: editorTemplate, + bindings: { + data: '<', + options: '<', + onOptionsChange: '<', + }, + controller($scope) { + $scope.$watch('$ctrl.options', (options) => { + this.onOptionsChange(options); + }, true); + }, +}; export default function init(ngModule) { - ngModule.directive('wordCloudEditor', wordCloudEditor); - ngModule.directive('wordCloudRenderer', wordCloudRenderer); - - const defaultOptions = { - defaultRows: 8, - }; + ngModule.component('wordCloudRenderer', WordCloudRenderer); + ngModule.component('wordCloudEditor', WordCloudEditor); - ngModule.config((VisualizationProvider) => { - VisualizationProvider.registerVisualization({ + ngModule.run(($injector) => { + registerVisualization({ type: 'WORD_CLOUD', name: 'Word Cloud', - renderTemplate: - '', - editorTemplate: '', - defaultOptions, + getOptions: options => ({ ...DEFAULT_OPTIONS, ...options }), + Renderer: angular2react('wordCloudRenderer', WordCloudRenderer, $injector), + Editor: angular2react('wordCloudEditor', WordCloudEditor, $injector), }); }); } -// init.init = true; +init.init = true; diff --git a/client/app/visualizations/word-cloud/word-cloud-editor.html b/client/app/visualizations/word-cloud/word-cloud-editor.html index 91b4c3211f..ffe73929a5 100644 --- a/client/app/visualizations/word-cloud/word-cloud-editor.html +++ b/client/app/visualizations/word-cloud/word-cloud-editor.html @@ -2,7 +2,7 @@
- +
From 1a773f49e58d65d1b8f04163c4c1903959ef37ee Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Thu, 28 Feb 2019 15:53:54 +0200 Subject: [PATCH 03/41] Sunburst --- client/app/lib/visualizations/sunburst.js | 9 +- client/app/visualizations/sunburst/index.js | 93 +++++++++++---------- 2 files changed, 51 insertions(+), 51 deletions(-) diff --git a/client/app/lib/visualizations/sunburst.js b/client/app/lib/visualizations/sunburst.js index 3b4a1296cd..613eae15af 100644 --- a/client/app/lib/visualizations/sunburst.js +++ b/client/app/lib/visualizations/sunburst.js @@ -366,15 +366,14 @@ function Sunburst(scope, element) { } function refreshData() { - const queryData = scope.queryResult.getData(); - if (queryData) { - render(queryData); + if (scope.$ctrl.data) { + render(scope.$ctrl.data.rows); } } refreshData(); - this.watches.push(scope.$watch('visualization.options', refreshData, true)); - this.watches.push(scope.$watch('queryResult && queryResult.getData()', refreshData)); + this.watches.push(scope.$watch('$ctrl.data', refreshData)); + this.watches.push(scope.$watch('$ctrl.options', refreshData, true)); } Sunburst.prototype.remove = function remove() { diff --git a/client/app/visualizations/sunburst/index.js b/client/app/visualizations/sunburst/index.js index 11a6ea1c4a..df8f388a71 100644 --- a/client/app/visualizations/sunburst/index.js +++ b/client/app/visualizations/sunburst/index.js @@ -1,59 +1,60 @@ import { debounce } from 'lodash'; import Sunburst from '@/lib/visualizations/sunburst'; -import editorTemplate from './sunburst-sequence-editor.html'; +import { angular2react } from 'angular2react'; +import { registerVisualization } from '@/visualizations'; -function sunburstSequenceRenderer() { - return { - restrict: 'E', - template: '
', - link(scope, element) { - const container = element[0].querySelector('.sunburst-visualization-container'); - let sunburst = new Sunburst(scope, container); - - function resize() { - sunburst.remove(); - sunburst = new Sunburst(scope, container); - } - - scope.handleResize = debounce(resize, 50); - - scope.$watch('visualization.options.height', (oldValue, newValue) => { - if (oldValue !== newValue) { - resize(); - } - }); - }, - }; -} +import editorTemplate from './sunburst-sequence-editor.html'; -function sunburstSequenceEditor() { - return { - restrict: 'E', - template: editorTemplate, - }; -} +const DEFAULT_OPTIONS = { + defaultRows: 7, +}; + +const SunburstSequenceRenderer = { + template: '
', + bindings: { + data: '<', + options: '<', + }, + controller($scope, $element) { + const container = $element[0].querySelector('.sunburst-visualization-container'); + let sunburst = new Sunburst($scope, container); + + function update() { + sunburst.remove(); + sunburst = new Sunburst($scope, container); + } + + $scope.handleResize = debounce(update, 50); + }, +}; + +const SunburstSequenceEditor = { + template: editorTemplate, + bindings: { + data: '<', + options: '<', + onOptionsChange: '<', + }, + controller($scope) { + $scope.$watch('$ctrl.options', (options) => { + this.onOptionsChange(options); + }, true); + }, +}; export default function init(ngModule) { - ngModule.directive('sunburstSequenceRenderer', sunburstSequenceRenderer); - ngModule.directive('sunburstSequenceEditor', sunburstSequenceEditor); - - ngModule.config((VisualizationProvider) => { - const renderTemplate = - ''; - - const editTemplate = ''; - const defaultOptions = { - defaultRows: 7, - }; + ngModule.component('sunburstSequenceRenderer', SunburstSequenceRenderer); + ngModule.component('sunburstSequenceEditor', SunburstSequenceEditor); - VisualizationProvider.registerVisualization({ + ngModule.run(($injector) => { + registerVisualization({ type: 'SUNBURST_SEQUENCE', name: 'Sunburst Sequence', - renderTemplate, - editorTemplate: editTemplate, - defaultOptions, + getOptions: options => ({ ...DEFAULT_OPTIONS, ...options }), + Renderer: angular2react('sunburstSequenceRenderer', SunburstSequenceRenderer, $injector), + Editor: angular2react('sunburstSequenceEditor', SunburstSequenceEditor, $injector), }); }); } -// init.init = true; +init.init = true; From f9e97d39f75c9e6bdf72e569666c33446580d0cf Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Thu, 28 Feb 2019 16:01:40 +0200 Subject: [PATCH 04/41] Sankey --- client/app/visualizations/sankey/index.js | 106 ++++++++++++---------- 1 file changed, 59 insertions(+), 47 deletions(-) diff --git a/client/app/visualizations/sankey/index.js b/client/app/visualizations/sankey/index.js index e015e7faa9..fb7927adbb 100644 --- a/client/app/visualizations/sankey/index.js +++ b/client/app/visualizations/sankey/index.js @@ -1,10 +1,16 @@ import angular from 'angular'; import _ from 'lodash'; import d3 from 'd3'; +import { angular2react } from 'angular2react'; +import { registerVisualization } from '@/visualizations'; import d3sankey from '@/lib/visualizations/d3sankey'; import editorTemplate from './sankey-editor.html'; +const DEFAULT_OPTIONS = { + defaultRows: 7, +}; + function getConnectedNodes(node) { // source link = this node is the source, I need the targets const nodes = []; @@ -227,61 +233,67 @@ function createSankey(element, data) { .attr('text-anchor', 'start'); } -function sankeyRenderer() { - return { - restrict: 'E', - template: '
', - link(scope, element) { - const container = element[0].querySelector('.sankey-visualization-container'); - - function refreshData() { - const queryData = scope.queryResult.getData(); - if (queryData) { - // do the render logic. - angular.element(container).empty(); - createSankey(container, queryData); - } +const SankeyRenderer = { + template: '
', + bindings: { + data: '<', + options: '<', + }, + controller($scope, $element) { + const container = $element[0].querySelector('.sankey-visualization-container'); + + const update = () => { + if (this.data) { + // do the render logic. + angular.element(container).empty(); + createSankey(container, this.data.rows); } + }; - scope.handleResize = _.debounce(refreshData, 50); - scope.$watch('queryResult && queryResult.getData()', refreshData); - scope.$watch('visualization.options.height', (oldValue, newValue) => { - if (oldValue !== newValue) { - refreshData(); - } - }); - }, - }; -} - -function sankeyEditor() { - return { - restrict: 'E', - template: editorTemplate, - }; -} + $scope.handleResize = _.debounce(update, 50); + + $scope.$watch('$ctrl.data', update); + $scope.$watch('$ctrl.options', update, true); + }, +}; + +const SankeyEditor = { + template: editorTemplate, + bindings: { + data: '<', + options: '<', + onOptionsChange: '<', + }, + controller($scope) { + $scope.$watch('$ctrl.options', (options) => { + this.onOptionsChange(options); + }, true); + }, +}; export default function init(ngModule) { - ngModule.directive('sankeyRenderer', sankeyRenderer); - ngModule.directive('sankeyEditor', sankeyEditor); - - ngModule.config((VisualizationProvider) => { - const renderTemplate = - ''; + ngModule.component('sankeyRenderer', SankeyRenderer); + ngModule.component('sankeyEditor', SankeyEditor); - const editTemplate = ''; - const defaultOptions = { - defaultRows: 7, - }; - - VisualizationProvider.registerVisualization({ + ngModule.run(($injector) => { + registerVisualization({ type: 'SANKEY', name: 'Sankey', - renderTemplate, - editorTemplate: editTemplate, - defaultOptions, + getOptions: options => ({ ...DEFAULT_OPTIONS, ...options }), + Renderer: angular2react('sankeyRenderer', SankeyRenderer, $injector), + Editor: angular2react('sankeyEditor', SankeyEditor, $injector), }); }); + + // ngModule.config((VisualizationProvider) => { + // VisualizationProvider.registerVisualization({ + // type: 'SANKEY', + // name: 'Sankey', + // renderTemplate: '', + // editorTemplate: '', + // defaultOptions, + // }); + // }); } -// init.init = true; +init.init = true; From 1b6a79627a68bc535f79916f3978f01119bd8452 Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Thu, 28 Feb 2019 16:18:46 +0200 Subject: [PATCH 05/41] Cohort --- .../visualizations/cohort/cohort-editor.html | 32 ++-- client/app/visualizations/cohort/index.js | 169 +++++++++--------- 2 files changed, 99 insertions(+), 102 deletions(-) diff --git a/client/app/visualizations/cohort/cohort-editor.html b/client/app/visualizations/cohort/cohort-editor.html index 33b60699ea..9e49b75ec2 100644 --- a/client/app/visualizations/cohort/cohort-editor.html +++ b/client/app/visualizations/cohort/cohort-editor.html @@ -1,16 +1,16 @@ -
+
- @@ -19,35 +19,35 @@
-
-
+
- +
- +
- +
- +
diff --git a/client/app/visualizations/cohort/index.js b/client/app/visualizations/cohort/index.js index db2915a5b4..60a717207d 100644 --- a/client/app/visualizations/cohort/index.js +++ b/client/app/visualizations/cohort/index.js @@ -3,6 +3,8 @@ import _ from 'lodash'; import moment from 'moment'; import 'cornelius/src/cornelius'; import 'cornelius/src/cornelius.css'; +import { angular2react } from 'angular2react'; +import { registerVisualization } from '@/visualizations'; import editorTemplate from './cohort-editor.html'; @@ -135,105 +137,100 @@ function prepareData(rawData, options) { return { data, initialDate }; } -function cohortRenderer() { - return { - restrict: 'E', - scope: { - queryResult: '=', - options: '=', - }, - template: '', - replace: false, - link($scope, element) { - $scope.options = _.extend({}, DEFAULT_OPTIONS, $scope.options); - - function updateCohort() { - element.empty(); - - if ($scope.queryResult.getData() === null) { - return; - } +const CohortRenderer = { + bindings: { + data: '<', + options: '<', + }, + template: '', + replace: false, + controller($scope, $element) { + $scope.options = _.extend({}, DEFAULT_OPTIONS, $scope.options); + + const update = () => { + $element.empty(); + + if (this.data.rows.length === 0) { + return; + } - const columnNames = _.map($scope.queryResult.getColumns(), i => i.name); - if ( - !_.includes(columnNames, $scope.options.dateColumn) || - !_.includes(columnNames, $scope.options.stageColumn) || - !_.includes(columnNames, $scope.options.totalColumn) || - !_.includes(columnNames, $scope.options.valueColumn) - ) { - return; - } + const options = this.options; - const { data, initialDate } = prepareData($scope.queryResult.getData(), $scope.options); - - Cornelius.draw({ - initialDate, - container: element[0], - cohort: data, - title: null, - timeInterval: $scope.options.timeInterval, - labels: { - time: 'Time', - people: 'Users', - weekOf: 'Week of', - }, - }); + const columnNames = _.map(this.data.columns, i => i.name); + if ( + !_.includes(columnNames, options.dateColumn) || + !_.includes(columnNames, options.stageColumn) || + !_.includes(columnNames, options.totalColumn) || + !_.includes(columnNames, options.valueColumn) + ) { + return; } - $scope.$watch('queryResult && queryResult.getData()', updateCohort); - $scope.$watch('options', updateCohort, true); - }, - }; -} + const { data, initialDate } = prepareData(this.data.rows, options); + + Cornelius.draw({ + initialDate, + container: $element[0], + cohort: data, + title: null, + timeInterval: options.timeInterval, + labels: { + time: 'Time', + people: 'Users', + weekOf: 'Week of', + }, + }); + }; -function cohortEditor() { - return { - restrict: 'E', - template: editorTemplate, - link: ($scope) => { - $scope.visualization.options = _.extend({}, DEFAULT_OPTIONS, $scope.visualization.options); - - $scope.currentTab = 'columns'; - $scope.setCurrentTab = (tab) => { - $scope.currentTab = tab; - }; - - function refreshColumns() { - $scope.columns = $scope.queryResult.getColumns(); - $scope.columnNames = _.map($scope.columns, i => i.name); - } + $scope.$watch('$ctrl.data', update); + $scope.$watch('$ctrl.options', update, true); + }, +}; - refreshColumns(); +const CohortEditor = { + template: editorTemplate, + bindings: { + data: '<', + options: '<', + onOptionsChange: '<', + }, + controller($scope) { + this.currentTab = 'columns'; + this.setCurrentTab = (tab) => { + this.currentTab = tab; + }; - $scope.$watch( - () => [$scope.queryResult.getId(), $scope.queryResult.status], - (changed) => { - if (!changed[0] || changed[1] !== 'done') { - return; - } - refreshColumns(); - }, - true, - ); - }, - }; -} + $scope.$watch('$ctrl.options', (options) => { + this.onOptionsChange(options); + }, true); + }, +}; export default function init(ngModule) { - ngModule.directive('cohortRenderer', cohortRenderer); - ngModule.directive('cohortEditor', cohortEditor); - - ngModule.config((VisualizationProvider) => { - const editTemplate = ''; + ngModule.component('cohortRenderer', CohortRenderer); + ngModule.component('cohortEditor', CohortEditor); - VisualizationProvider.registerVisualization({ + ngModule.run(($injector) => { + registerVisualization({ type: 'COHORT', name: 'Cohort', - renderTemplate: '', - editorTemplate: editTemplate, - defaultOptions: DEFAULT_OPTIONS, + getOptions: options => ({ ...DEFAULT_OPTIONS, ...options }), + Renderer: angular2react('cohortRenderer', CohortRenderer, $injector), + Editor: angular2react('cohortEditor', CohortEditor, $injector), }); }); + + // ngModule.config((VisualizationProvider) => { + // const editTemplate = ''; + // + // VisualizationProvider.registerVisualization({ + // type: 'COHORT', + // name: 'Cohort', + // renderTemplate: '', + // editorTemplate: editTemplate, + // defaultOptions: DEFAULT_OPTIONS, + // }); + // }); } -// init.init = true; +init.init = true; From fe43eff917565eeb99e3a313bc45ae8aa9ce6d88 Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Thu, 28 Feb 2019 18:20:48 +0200 Subject: [PATCH 06/41] Funnel --- .../visualizations/funnel/funnel-editor.html | 17 ++- client/app/visualizations/funnel/index.js | 120 +++++++++--------- 2 files changed, 68 insertions(+), 69 deletions(-) diff --git a/client/app/visualizations/funnel/funnel-editor.html b/client/app/visualizations/funnel/funnel-editor.html index cb52635af2..7240fd559e 100644 --- a/client/app/visualizations/funnel/funnel-editor.html +++ b/client/app/visualizations/funnel/funnel-editor.html @@ -5,38 +5,41 @@
- +
- +
- +
- +
- +
-
+
- +
diff --git a/client/app/visualizations/funnel/index.js b/client/app/visualizations/funnel/index.js index 42dbac9a63..0a2da265d9 100644 --- a/client/app/visualizations/funnel/index.js +++ b/client/app/visualizations/funnel/index.js @@ -1,17 +1,20 @@ -import { debounce, sortBy, isNumber, every, difference } from 'lodash'; +import { debounce, sortBy, isFinite, every, difference, merge, map } from 'lodash'; import d3 from 'd3'; import angular from 'angular'; +import { angular2react } from 'angular2react'; +import { registerVisualization } from '@/visualizations'; import { ColorPalette, normalizeValue } from '@/visualizations/chart/plotly/utils'; import editorTemplate from './funnel-editor.html'; import './funnel.less'; -function isNoneNaNNum(val) { - if (!isNumber(val) || isNaN(val)) { - return false; - } - return true; -} +const DEFAULT_OPTIONS = { + stepCol: { colName: '', displayAs: 'Steps' }, + valueCol: { colName: '', displayAs: 'Value' }, + sortKeyCol: { colName: '' }, + autoSort: true, + defaultRows: 10, +}; function normalizePercentage(num) { if (num < 0.01) { @@ -27,7 +30,7 @@ function Funnel(scope, element) { this.element = element; this.watches = []; const vis = d3.select(element); - const options = scope.visualization.options; + const options = scope.$ctrl.options; function drawFunnel(data) { const maxToPrevious = d3.max(data, d => d.pctPrevious); @@ -132,7 +135,7 @@ function Funnel(scope, element) { } // Column validity - if (sortedData[0].value === 0 || !every(sortedData, d => isNoneNaNNum(d.value))) { + if (sortedData[0].value === 0 || !every(sortedData, d => isFinite(d.value))) { return; } const maxVal = d3.max(data, d => d.value); @@ -144,7 +147,7 @@ function Funnel(scope, element) { } function invalidColNames() { - const colNames = scope.queryResult.getColumnNames(); + const colNames = map(scope.$ctrl.data.columns, col => col.name); const colToCheck = [options.stepCol.colName, options.valueCol.colName]; if (!options.autoSort) { colToCheck.push(options.sortKeyCol.colName); @@ -161,7 +164,7 @@ function Funnel(scope, element) { return; } - const queryData = scope.queryResult.getData(); + const queryData = scope.$ctrl.data.rows; const data = prepareData(queryData, options); if (data) { createVisualization(data); // draw funnel @@ -169,8 +172,8 @@ function Funnel(scope, element) { } refresh(); - this.watches.push(scope.$watch('visualization.options', refresh, true)); - this.watches.push(scope.$watch('queryResult && queryResult.getData()', refresh)); + this.watches.push(scope.$watch('$ctrl.data', refresh)); + this.watches.push(scope.$watch('$ctrl.options', refresh, true)); } Funnel.prototype.remove = function remove() { @@ -180,62 +183,55 @@ Funnel.prototype.remove = function remove() { angular.element(this.element).empty('.vis-container'); }; -function funnelRenderer() { - return { - restrict: 'E', - template: '
', - link(scope, element) { - const container = element[0].querySelector('.funnel-visualization-container'); - let funnel = new Funnel(scope, container); - - function resize() { - funnel.remove(); - funnel = new Funnel(scope, container); - } - - scope.handleResize = debounce(resize, 50); - - scope.$watch('visualization.options', (oldValue, newValue) => { - if (oldValue !== newValue) { - resize(); - } - }); - }, - }; -} +const FunnelRenderer = { + template: '
', + bindings: { + data: '<', + options: '<', + }, + controller($scope, $element) { + const container = $element[0].querySelector('.funnel-visualization-container'); + let funnel = new Funnel($scope, container); + + const update = () => { + funnel.remove(); + funnel = new Funnel($scope, container); + }; -function funnelEditor() { - return { - restrict: 'E', - template: editorTemplate, - }; -} + $scope.handleResize = debounce(update, 50); + + $scope.$watch('$ctrl.data', update); + $scope.$watch('$ctrl.options', update, true); + }, +}; + +const FunnelEditor = { + template: editorTemplate, + bindings: { + data: '<', + options: '<', + onOptionsChange: '<', + }, + controller($scope) { + $scope.$watch('$ctrl.options', (options) => { + this.onOptionsChange(options); + }, true); + }, +}; export default function init(ngModule) { - ngModule.directive('funnelRenderer', funnelRenderer); - ngModule.directive('funnelEditor', funnelEditor); - - ngModule.config((VisualizationProvider) => { - const renderTemplate = - ''; - - const editTemplate = ''; - const defaultOptions = { - stepCol: { colName: '', displayAs: 'Steps' }, - valueCol: { colName: '', displayAs: 'Value' }, - sortKeyCol: { colName: '' }, - autoSort: true, - defaultRows: 10, - }; + ngModule.component('funnelRenderer', FunnelRenderer); + ngModule.component('funnelEditor', FunnelEditor); - VisualizationProvider.registerVisualization({ + ngModule.run(($injector) => { + registerVisualization({ type: 'FUNNEL', name: 'Funnel', - renderTemplate, - editorTemplate: editTemplate, - defaultOptions, + getOptions: options => merge({}, DEFAULT_OPTIONS, options), + Renderer: angular2react('funnelRenderer', FunnelRenderer, $injector), + Editor: angular2react('funnelEditor', FunnelEditor, $injector), }); }); } -// init.init = true; +init.init = true; From 46518f0a3c9d01555697838cf69e337179bfa6fe Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Thu, 28 Feb 2019 18:40:29 +0200 Subject: [PATCH 07/41] BoxPlot (Deprecated) --- .../box-plot/box-plot-editor.html | 4 +- client/app/visualizations/box-plot/index.js | 358 +++++++++--------- client/app/visualizations/index.js | 2 +- 3 files changed, 184 insertions(+), 180 deletions(-) diff --git a/client/app/visualizations/box-plot/box-plot-editor.html b/client/app/visualizations/box-plot/box-plot-editor.html index d7dd576b0d..fcd1df4f3c 100644 --- a/client/app/visualizations/box-plot/box-plot-editor.html +++ b/client/app/visualizations/box-plot/box-plot-editor.html @@ -2,14 +2,14 @@
- +
- +
diff --git a/client/app/visualizations/box-plot/index.js b/client/app/visualizations/box-plot/index.js index 96c47c3c60..910e914f83 100644 --- a/client/app/visualizations/box-plot/index.js +++ b/client/app/visualizations/box-plot/index.js @@ -1,194 +1,198 @@ +import { map } from 'lodash'; import d3 from 'd3'; +import { angular2react } from 'angular2react'; import box from '@/lib/visualizations/d3box'; +import { registerVisualization } from '@/visualizations'; import editorTemplate from './box-plot-editor.html'; -function boxPlotRenderer() { - return { - restrict: 'E', - template: '
', - link($scope, elm) { - function calcIqr(k) { - return (d) => { - const q1 = d.quartiles[0]; - const q3 = d.quartiles[2]; - const iqr = (q3 - q1) * k; - - let i = -1; - let j = d.length; - - // eslint-disable-next-line no-plusplus - while (d[++i] < q1 - iqr); - // eslint-disable-next-line no-plusplus - while (d[--j] > q3 + iqr); - return [i, j]; - }; +const DEFAULT_OPTIONS = { + defaultRows: 8, + minRows: 5, +}; + +const BoxPlotRenderer = { + template: '
', + bindings: { + data: '<', + options: '<', + }, + controller($scope, $element) { + function calcIqr(k) { + return (d) => { + const q1 = d.quartiles[0]; + const q3 = d.quartiles[2]; + const iqr = (q3 - q1) * k; + + let i = -1; + let j = d.length; + + // eslint-disable-next-line no-plusplus + while (d[++i] < q1 - iqr); + // eslint-disable-next-line no-plusplus + while (d[--j] > q3 + iqr); + return [i, j]; + }; + } + + const update = () => { + const data = this.data.rows; + const parentWidth = d3.select($element[0].parentNode).node().getBoundingClientRect().width; + const margin = { + top: 10, right: 50, bottom: 40, left: 50, inner: 25, + }; + const width = parentWidth - margin.right - margin.left; + const height = 500 - margin.top - margin.bottom; + + let min = Infinity; + let max = -Infinity; + const mydata = []; + let value = 0; + let d = []; + const xAxisLabel = this.options.xAxisLabel; + const yAxisLabel = this.options.yAxisLabel; + + const columns = map(this.data.columns, col => col.name); + const xscale = d3.scale.ordinal() + .domain(columns) + .rangeBands([0, parentWidth - margin.left - margin.right]); + + let boxWidth; + if (columns.length > 1) { + boxWidth = Math.min(xscale(columns[1]), 120.0); + } else { + boxWidth = 120.0; } - - $scope.$watch('[queryResult && queryResult.getData(), visualization.options]', () => { - if ($scope.queryResult.getData() === null) { - return; - } - - const data = $scope.queryResult.getData(); - const parentWidth = d3.select(elm[0].parentNode).node().getBoundingClientRect().width; - const margin = { - top: 10, right: 50, bottom: 40, left: 50, inner: 25, - }; - const width = parentWidth - margin.right - margin.left; - const height = 500 - margin.top - margin.bottom; - - let min = Infinity; - let max = -Infinity; - const mydata = []; - let value = 0; - let d = []; - const xAxisLabel = $scope.visualization.options.xAxisLabel; - const yAxisLabel = $scope.visualization.options.yAxisLabel; - - const columns = $scope.queryResult.getColumnNames(); - const xscale = d3.scale.ordinal() - .domain(columns) - .rangeBands([0, parentWidth - margin.left - margin.right]); - - let boxWidth; - if (columns.length > 1) { - boxWidth = Math.min(xscale(columns[1]), 120.0); - } else { - boxWidth = 120.0; - } - margin.inner = boxWidth / 3.0; - - columns.forEach((column, i) => { - d = mydata[i] = []; - data.forEach((row) => { - value = row[column]; - d.push(value); - if (value > max) max = Math.ceil(value); - if (value < min) min = Math.floor(value); - }); + margin.inner = boxWidth / 3.0; + + columns.forEach((column, i) => { + d = mydata[i] = []; + data.forEach((row) => { + value = row[column]; + d.push(value); + if (value > max) max = Math.ceil(value); + if (value < min) min = Math.floor(value); }); + }); + + const yscale = d3.scale.linear() + .domain([min * 0.99, max * 1.01]) + .range([height, 0]); + + const chart = box() + .whiskers(calcIqr(1.5)) + .width(boxWidth - 2 * margin.inner) + .height(height) + .domain([min * 0.99, max * 1.01]); + const xAxis = d3.svg.axis() + .scale(xscale) + .orient('bottom'); + + + const yAxis = d3.svg.axis() + .scale(yscale) + .orient('left'); + + const xLines = d3.svg.axis() + .scale(xscale) + .tickSize(height) + .orient('bottom'); + + const yLines = d3.svg.axis() + .scale(yscale) + .tickSize(width) + .orient('right'); + + function barOffset(i) { + return xscale(columns[i]) + (xscale(columns[1]) - margin.inner) / 2.0; + } - const yscale = d3.scale.linear() - .domain([min * 0.99, max * 1.01]) - .range([height, 0]); - - const chart = box() - .whiskers(calcIqr(1.5)) - .width(boxWidth - 2 * margin.inner) - .height(height) - .domain([min * 0.99, max * 1.01]); - const xAxis = d3.svg.axis() - .scale(xscale) - .orient('bottom'); - - - const yAxis = d3.svg.axis() - .scale(yscale) - .orient('left'); - - const xLines = d3.svg.axis() - .scale(xscale) - .tickSize(height) - .orient('bottom'); - - const yLines = d3.svg.axis() - .scale(yscale) - .tickSize(width) - .orient('right'); - - function barOffset(i) { - return xscale(columns[i]) + (xscale(columns[1]) - margin.inner) / 2.0; - } - - d3.select(elm[0]).selectAll('svg').remove(); - - const plot = d3.select(elm[0]) - .append('svg') - .attr('width', parentWidth) - .attr('height', height + margin.bottom + margin.top) - .append('g') - .attr('width', parentWidth - margin.left - margin.right) - .attr('transform', `translate(${margin.left},${margin.top})`); - - d3.select('svg').append('text') - .attr('class', 'box') - .attr('x', parentWidth / 2.0) - .attr('text-anchor', 'middle') - .attr('y', height + margin.bottom) - .text(xAxisLabel); - - d3.select('svg').append('text') - .attr('class', 'box') - .attr('transform', `translate(10,${(height + margin.top + margin.bottom) / 2.0})rotate(-90)`) - .attr('text-anchor', 'middle') - .text(yAxisLabel); - - plot.append('rect') - .attr('class', 'grid-background') - .attr('width', width) - .attr('height', height); - - plot.append('g') - .attr('class', 'grid') - .call(yLines); - - plot.append('g') - .attr('class', 'grid') - .call(xLines); - - plot.append('g') - .attr('class', 'x axis') - .attr('transform', `translate(0,${height})`) - .call(xAxis); - - plot.append('g') - .attr('class', 'y axis') - .call(yAxis); - - plot.selectAll('.box').data(mydata) - .enter().append('g') - .attr('class', 'box') - .attr('width', boxWidth) - .attr('height', height) - .attr('transform', (_, i) => `translate(${barOffset(i)},${0})`) - .call(chart); - }, true); - }, - }; -} + d3.select($element[0]).selectAll('svg').remove(); + + const plot = d3.select($element[0]) + .append('svg') + .attr('width', parentWidth) + .attr('height', height + margin.bottom + margin.top) + .append('g') + .attr('width', parentWidth - margin.left - margin.right) + .attr('transform', `translate(${margin.left},${margin.top})`); + + d3.select('svg').append('text') + .attr('class', 'box') + .attr('x', parentWidth / 2.0) + .attr('text-anchor', 'middle') + .attr('y', height + margin.bottom) + .text(xAxisLabel); + + d3.select('svg').append('text') + .attr('class', 'box') + .attr('transform', `translate(10,${(height + margin.top + margin.bottom) / 2.0})rotate(-90)`) + .attr('text-anchor', 'middle') + .text(yAxisLabel); + + plot.append('rect') + .attr('class', 'grid-background') + .attr('width', width) + .attr('height', height); + + plot.append('g') + .attr('class', 'grid') + .call(yLines); + + plot.append('g') + .attr('class', 'grid') + .call(xLines); + + plot.append('g') + .attr('class', 'x axis') + .attr('transform', `translate(0,${height})`) + .call(xAxis); + + plot.append('g') + .attr('class', 'y axis') + .call(yAxis); + + plot.selectAll('.box').data(mydata) + .enter().append('g') + .attr('class', 'box') + .attr('width', boxWidth) + .attr('height', height) + .attr('transform', (_, i) => `translate(${barOffset(i)},${0})`) + .call(chart); + }; -function boxPlotEditor() { - return { - restrict: 'E', - template: editorTemplate, - }; -} + $scope.$watch('$ctrl.data', update); + $scope.$watch('$ctrl.options', update, true); + }, +}; + +const BoxPlotEditor = { + template: editorTemplate, + bindings: { + data: '<', + options: '<', + onOptionsChange: '<', + }, + controller($scope) { + $scope.$watch('$ctrl.options', (options) => { + this.onOptionsChange(options); + }, true); + }, +}; export default function init(ngModule) { - ngModule.directive('boxplotRenderer', boxPlotRenderer); - ngModule.directive('boxplotEditor', boxPlotEditor); - - ngModule.config((VisualizationProvider) => { - const renderTemplate = - '' + - ''; - - const editTemplate = ''; - - const defaultOptions = { - defaultRows: 8, - minRows: 5, - }; + ngModule.component('boxplotRenderer', BoxPlotRenderer); + ngModule.component('boxplotEditor', BoxPlotEditor); - VisualizationProvider.registerVisualization({ + ngModule.run(($injector) => { + registerVisualization({ type: 'BOXPLOT', name: 'Boxplot (Deprecated)', - defaultOptions, - renderTemplate, - editorTemplate: editTemplate, + isDeprecated: true, + getOptions: options => ({ ...DEFAULT_OPTIONS, ...options }), + Renderer: angular2react('boxplotRenderer', BoxPlotRenderer, $injector), + Editor: angular2react('boxplotEditor', BoxPlotEditor, $injector), }); }); } -// init.init = true; +init.init = true; diff --git a/client/app/visualizations/index.js b/client/app/visualizations/index.js index 4c26572eee..255978e8e5 100644 --- a/client/app/visualizations/index.js +++ b/client/app/visualizations/index.js @@ -54,7 +54,7 @@ export function registerVisualization(config) { } export function getDefaultVisualization() { - return find(registeredVisualizations, visualization => !visualization.name.match(/Deprecated/)); + return find(registeredVisualizations, visualization => !visualization.isDeprecated); } export function newVisualization(type = null) { From 962989e7bcc454389d0e4e3ea91f33011f046b0e Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Fri, 1 Mar 2019 13:44:22 +0200 Subject: [PATCH 08/41] Pivot --- .../less/inc/visualizations/pivot-table.less | 2 +- client/app/assets/less/redash/query.less | 2 +- client/app/pages/dashboards/dashboard.less | 2 +- .../EditVisualizationDialog.jsx | 7 +- client/app/visualizations/pivot/index.js | 166 +++++++++--------- client/app/visualizations/pivot/pivot.less | 8 + .../pivot/pivottable-editor.html | 2 +- 7 files changed, 101 insertions(+), 88 deletions(-) diff --git a/client/app/assets/less/inc/visualizations/pivot-table.less b/client/app/assets/less/inc/visualizations/pivot-table.less index 9fa85ae5ca..7400914f47 100644 --- a/client/app/assets/less/inc/visualizations/pivot-table.less +++ b/client/app/assets/less/inc/visualizations/pivot-table.less @@ -1,3 +1,3 @@ -pivot-table-renderer > table, grid-renderer > div, visualization-renderer > div { +.pivot-table-renderer > table, grid-renderer > div, visualization-renderer > div { overflow: auto; } diff --git a/client/app/assets/less/redash/query.less b/client/app/assets/less/redash/query.less index 577b10fd10..7e98f06f99 100644 --- a/client/app/assets/less/redash/query.less +++ b/client/app/assets/less/redash/query.less @@ -336,7 +336,7 @@ a.label-tag { border-bottom: 1px solid #efefef; } - pivot-table-renderer > table, grid-renderer > div, visualization-renderer > div { + .pivot-table-renderer > table, grid-renderer > div, visualization-renderer > div { overflow: visible; } diff --git a/client/app/pages/dashboards/dashboard.less b/client/app/pages/dashboards/dashboard.less index 93773b8b7d..d3ef297b34 100644 --- a/client/app/pages/dashboards/dashboard.less +++ b/client/app/pages/dashboards/dashboard.less @@ -13,7 +13,7 @@ padding: 0; } - pivot-table-renderer > table, grid-renderer > div, visualization-renderer > div { + .pivot-table-renderer > table, grid-renderer > div, visualization-renderer > div { overflow: visible; } diff --git a/client/app/visualizations/EditVisualizationDialog.jsx b/client/app/visualizations/EditVisualizationDialog.jsx index d5851b7173..2fa8ec9ee0 100644 --- a/client/app/visualizations/EditVisualizationDialog.jsx +++ b/client/app/visualizations/EditVisualizationDialog.jsx @@ -200,7 +200,12 @@ class EditVisualizationDialog extends React.Component {
- +
diff --git a/client/app/visualizations/pivot/index.js b/client/app/visualizations/pivot/index.js index 1fd9ecb0d7..e5dc731417 100644 --- a/client/app/visualizations/pivot/index.js +++ b/client/app/visualizations/pivot/index.js @@ -1,105 +1,105 @@ +import { merge, omit } from 'lodash'; import angular from 'angular'; import $ from 'jquery'; import 'pivottable'; import 'pivottable/dist/pivot.css'; +import { angular2react } from 'angular2react'; +import { registerVisualization } from '@/visualizations'; import editorTemplate from './pivottable-editor.html'; import './pivot.less'; -function pivotTableRenderer() { - return { - restrict: 'E', - scope: { - queryResult: '=', - visualization: '=', - }, - template: '', - replace: false, - link($scope, element) { - function removeControls() { - const hideControls = $scope.visualization.options.controls && $scope.visualization.options.controls.enabled; +const DEFAULT_OPTIONS = { + defaultRows: 10, + defaultColumns: 3, + minColumns: 2, +}; - element[0].querySelectorAll('.pvtAxisContainer, .pvtRenderer, .pvtVals').forEach((control) => { - if (hideControls) { - control.style.display = 'none'; - } else { - control.style.display = ''; +const PivotTableRenderer = { + template: ` +
+ `, + bindings: { + data: '<', + options: '<', + onOptionsChange: '<', + }, + controller($scope, $element) { + const update = () => { + // We need to give the pivot table its own copy of the data, because it changes + // it which interferes with other visualizations. + const data = angular.copy(this.data.rows); + const options = { + renderers: $.pivotUtilities.renderers, + onRefresh: (config) => { + if (this.onOptionsChange) { + config = omit(config, [ + // delete some values which are functions + 'aggregators', + 'renderers', + 'onRefresh', + // delete some bulky de + 'localeStrings', + ]); + this.onOptionsChange(config); } - }); - } + }, + ...this.options, + }; - function updatePivot() { - $scope.$watch('queryResult && queryResult.getData()', (data) => { - if (!data) { - return; - } - - if ($scope.queryResult.getData() !== null) { - // We need to give the pivot table its own copy of the data, because it changes - // it which interferes with other visualizations. - data = angular.copy($scope.queryResult.getData()); - const options = { - renderers: $.pivotUtilities.renderers, - onRefresh(config) { - const configCopy = Object.assign({}, config); - // delete some values which are functions - delete configCopy.aggregators; - delete configCopy.renderers; - delete configCopy.onRefresh; - // delete some bulky default values - delete configCopy.rendererOptions; - delete configCopy.localeStrings; - - if ($scope.visualization) { - $scope.visualization.options = configCopy; - } - }, - }; - - if ($scope.visualization) { - Object.assign(options, $scope.visualization.options); - } - - $(element).pivotUI(data, options, true); - removeControls(); - } - }); - } + $('.pivot-table-renderer', $element).pivotUI(data, options, true); + }; - $scope.$watch('queryResult && queryResult.getData()', updatePivot); - $scope.$watch('visualization.options.controls.enabled', removeControls); - }, - }; -} + $scope.$watch('$ctrl.data', update); + $scope.$watch('$ctrl.options', update, true); + }, +}; -function pivotTableEditor() { - return { - restrict: 'E', - template: editorTemplate, - }; -} +const PivotTableEditor = { + template: editorTemplate, + bindings: { + data: '<', + options: '<', + onOptionsChange: '<', + }, + controller($scope) { + $scope.$watch('$ctrl.options', (options) => { + this.onOptionsChange(options); + }, true); + }, +}; export default function init(ngModule) { - ngModule.directive('pivotTableRenderer', pivotTableRenderer); - ngModule.directive('pivotTableEditor', pivotTableEditor); + ngModule.component('pivotTableRenderer', PivotTableRenderer); + ngModule.component('pivotTableEditor', PivotTableEditor); - ngModule.config((VisualizationProvider) => { - const editTemplate = ''; - const defaultOptions = { - defaultRows: 10, - defaultColumns: 3, - minColumns: 2, - }; - - VisualizationProvider.registerVisualization({ + ngModule.run(($injector) => { + registerVisualization({ type: 'PIVOT', name: 'Pivot Table', - renderTemplate: - '', - editorTemplate: editTemplate, - defaultOptions, + getOptions: options => merge({}, DEFAULT_OPTIONS, options), + Renderer: angular2react('pivotTableRenderer', PivotTableRenderer, $injector), + Editor: angular2react('pivotTableEditor', PivotTableEditor, $injector), }); }); + + // ngModule.config((VisualizationProvider) => { + // const editTemplate = ''; + // const defaultOptions = { + // defaultRows: 10, + // defaultColumns: 3, + // minColumns: 2, + // }; + // + // VisualizationProvider.registerVisualization({ + // type: 'PIVOT', + // name: 'Pivot Table', + // renderTemplate: + // '', + // editorTemplate: editTemplate, + // defaultOptions, + // }); + // }); } -// init.init = true; +init.init = true; diff --git a/client/app/visualizations/pivot/pivot.less b/client/app/visualizations/pivot/pivot.less index 8a787de6e4..3f0a84a2ec 100644 --- a/client/app/visualizations/pivot/pivot.less +++ b/client/app/visualizations/pivot/pivot.less @@ -1,5 +1,13 @@ @redash-gray: rgba(102, 136, 153, 1); +.pivot-table-renderer { + &.hide-controls { + .pvtAxisContainer, .pvtRenderer, .pvtVals { + display: none !important; + } + } +} + .pvtAxisContainer, .pvtVals { border: 1px solid fade(@redash-gray, 15%); background: #fff; diff --git a/client/app/visualizations/pivot/pivottable-editor.html b/client/app/visualizations/pivot/pivottable-editor.html index e0c3313bb0..51ae6d194b 100644 --- a/client/app/visualizations/pivot/pivottable-editor.html +++ b/client/app/visualizations/pivot/pivottable-editor.html @@ -2,7 +2,7 @@
From 669d94415b3a2c95aef0284233c95dc9a93191ca Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Fri, 1 Mar 2019 14:46:03 +0200 Subject: [PATCH 09/41] Map --- client/app/visualizations/map/index.js | 485 +++++++++--------- client/app/visualizations/map/map-editor.html | 38 +- 2 files changed, 267 insertions(+), 256 deletions(-) diff --git a/client/app/visualizations/map/index.js b/client/app/visualizations/map/index.js index 96643f2dbf..9798287286 100644 --- a/client/app/visualizations/map/index.js +++ b/client/app/visualizations/map/index.js @@ -10,13 +10,13 @@ import markerIconRetina from 'leaflet/dist/images/marker-icon-2x.png'; import markerShadow from 'leaflet/dist/images/marker-shadow.png'; import 'leaflet-fullscreen'; import 'leaflet-fullscreen/dist/leaflet.fullscreen.css'; +import { angular2react } from 'angular2react'; +import { registerVisualization } from '@/visualizations'; import template from './map.html'; import editorTemplate from './map-editor.html'; -/* -This is a workaround for an issue with giving Leaflet load the icon on its own. -*/ +// This is a workaround for an issue with giving Leaflet load the icon on its own. L.Icon.Default.mergeOptions({ iconUrl: markerIcon, iconRetinaUrl: markerIconRetina, @@ -25,287 +25,298 @@ L.Icon.Default.mergeOptions({ delete L.Icon.Default.prototype._getIconUrl; -function mapRenderer() { - return { - restrict: 'E', - template, - link($scope, elm) { - const colorScale = d3.scale.category10(); - const map = L.map(elm[0].children[0].children[0], { - scrollWheelZoom: false, - fullscreenControl: true, - }); - const mapControls = L.control.layers().addTo(map); - const layers = {}; - const tileLayer = L.tileLayer('//{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { - attribution: '© OpenStreetMap contributors', - }).addTo(map); - - function getBounds() { - $scope.visualization.options.bounds = map.getBounds(); +const MAP_TILES = [ + { + name: 'OpenStreetMap', + url: '//{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', + }, + { + name: 'OpenStreetMap BW', + url: '//{s}.tiles.wmflabs.org/bw-mapnik/{z}/{x}/{y}.png', + }, + { + name: 'OpenStreetMap DE', + url: '//{s}.tile.openstreetmap.de/tiles/osmde/{z}/{x}/{y}.png', + }, + { + name: 'OpenStreetMap FR', + url: '//{s}.tile.openstreetmap.fr/osmfr/{z}/{x}/{y}.png', + }, + { + name: 'OpenStreetMap Hot', + url: '//{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png', + }, + { + name: 'Thunderforest', + url: '//{s}.tile.thunderforest.com/cycle/{z}/{x}/{y}.png', + }, + { + name: 'Thunderforest Spinal', + url: '//{s}.tile.thunderforest.com/spinal-map/{z}/{x}/{y}.png', + }, + { + name: 'OpenMapSurfer', + url: '//korona.geog.uni-heidelberg.de/tiles/roads/x={x}&y={y}&z={z}', + }, + { + name: 'Stamen Toner', + url: '//stamen-tiles-{s}.a.ssl.fastly.net/toner/{z}/{x}/{y}.png', + }, + { + name: 'Stamen Toner Background', + url: '//stamen-tiles-{s}.a.ssl.fastly.net/toner-background/{z}/{x}/{y}.png', + }, + { + name: 'Stamen Toner Lite', + url: '//stamen-tiles-{s}.a.ssl.fastly.net/toner-lite/{z}/{x}/{y}.png', + }, + { + name: 'OpenTopoMap', + url: '//{s}.tile.opentopomap.org/{z}/{x}/{y}.png', + }, +]; + +const DEFAULT_OPTIONS = { + defaultColumns: 3, + defaultRows: 8, + minColumns: 2, + classify: 'none', + clusterMarkers: true, +}; + +function heatpoint(lat, lon, color) { + const style = { + fillColor: color, + fillOpacity: 0.9, + stroke: false, + }; + + return L.circleMarker([lat, lon], style); +} + +const createMarker = (lat, lon) => L.marker([lat, lon]); + +function createDescription(latCol, lonCol, row) { + const lat = row[latCol]; + const lon = row[lonCol]; + + let description = '
    '; + description += `
  • ${lat}, ${lon}`; + + _.each(row, (v, k) => { + if (!(k === latCol || k === lonCol)) { + description += `
  • ${k}: ${v}
  • `; + } + }); + + return description; +} + +const MapRenderer = { + template, + bindings: { + data: '<', + options: '<', + onOptionsChange: '<', + }, + controller($scope, $element) { + const colorScale = d3.scale.category10(); + const map = L.map($element[0].children[0].children[0], { + scrollWheelZoom: false, + fullscreenControl: true, + }); + const mapControls = L.control.layers().addTo(map); + const layers = {}; + const tileLayer = L.tileLayer('//{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors', + }).addTo(map); + + const getBounds = () => { + this.options.bounds = map.getBounds(); + if (this.onOptionsChange) { + this.onOptionsChange(this.options); } + }; - function setBounds() { - const b = $scope.visualization.options.bounds; + const setBounds = () => { + const b = this.options.bounds; - if (b) { - map.fitBounds([[b._southWest.lat, b._southWest.lng], - [b._northEast.lat, b._northEast.lng]]); - } else if (layers) { - const allMarkers = _.flatten(_.map(_.values(layers), l => l.getLayers())); + if (b) { + map.fitBounds([[b._southWest.lat, b._southWest.lng], + [b._northEast.lat, b._northEast.lng]]); + } else if (layers) { + const allMarkers = _.flatten(_.map(_.values(layers), l => l.getLayers())); + if (allMarkers.length > 0) { // eslint-disable-next-line new-cap const group = new L.featureGroup(allMarkers); map.fitBounds(group.getBounds()); } } + }; + map.on('focus', () => { map.on('moveend', getBounds); }); + map.on('blur', () => { map.off('moveend', getBounds); }); - map.on('focus', () => { map.on('moveend', getBounds); }); - map.on('blur', () => { map.off('moveend', getBounds); }); + const resize = () => { + if (!map) return; + map.invalidateSize(false); + setBounds(); + }; - function resize() { - if (!map) return; - map.invalidateSize(false); - setBounds(); + const removeLayer = (layer) => { + if (layer) { + mapControls.removeLayer(layer); + map.removeLayer(layer); } + }; - const createMarker = (lat, lon) => L.marker([lat, lon]); - - const heatpoint = (lat, lon, color) => { - const style = { - fillColor: color, - fillOpacity: 0.9, - stroke: false, - }; - - return L.circleMarker([lat, lon], style); - }; - - function createDescription(latCol, lonCol, row) { - const lat = row[latCol]; - const lon = row[lonCol]; + const addLayer = (name, points) => { + const latCol = this.options.latColName || 'lat'; + const lonCol = this.options.lonColName || 'lon'; + const classify = this.options.classify; + + let markers; + if (this.options.clusterMarkers) { + const color = this.options.groups[name].color; + const options = {}; + + if (classify) { + options.iconCreateFunction = (cluster) => { + const childCount = cluster.getChildCount(); + + let c = ' marker-cluster-'; + if (childCount < 10) { + c += 'small'; + } else if (childCount < 100) { + c += 'medium'; + } else { + c += 'large'; + } - let description = '
      '; - description += `
    • ${lat}, ${lon}`; + c = ''; - _.each(row, (v, k) => { - if (!(k === latCol || k === lonCol)) { - description += `
    • ${k}: ${v}
    • `; - } - }); + const style = `color: white; background-color: ${color};`; + return L.divIcon({ html: `
      ${childCount}
      `, className: `marker-cluster${c}`, iconSize: new L.Point(40, 40) }); + }; + } - return description; + markers = L.markerClusterGroup(options); + } else { + markers = L.layerGroup(); } - function removeLayer(layer) { - if (layer) { - mapControls.removeLayer(layer); - map.removeLayer(layer); - } - } + // create markers + _.each(points, (row) => { + let marker; - function addLayer(name, points) { - const latCol = $scope.visualization.options.latColName || 'lat'; - const lonCol = $scope.visualization.options.lonColName || 'lon'; - const classify = $scope.visualization.options.classify; - - let markers; - if ($scope.visualization.options.clusterMarkers) { - const color = $scope.visualization.options.groups[name].color; - const options = {}; - - if (classify) { - options.iconCreateFunction = (cluster) => { - const childCount = cluster.getChildCount(); - - let c = ' marker-cluster-'; - if (childCount < 10) { - c += 'small'; - } else if (childCount < 100) { - c += 'medium'; - } else { - c += 'large'; - } - - c = ''; - - const style = `color: white; background-color: ${color};`; - return L.divIcon({ html: `
      ${childCount}
      `, className: `marker-cluster${c}`, iconSize: new L.Point(40, 40) }); - }; - } + const lat = row[latCol]; + const lon = row[lonCol]; + + if (lat === null || lon === null) return; - markers = L.markerClusterGroup(options); + if (classify && classify !== 'none') { + const groupColor = this.options.groups[name].color; + marker = heatpoint(lat, lon, groupColor); } else { - markers = L.layerGroup(); + marker = createMarker(lat, lon); } - // create markers - _.each(points, (row) => { - let marker; + marker.bindPopup(createDescription(latCol, lonCol, row)); + markers.addLayer(marker); + }); - const lat = row[latCol]; - const lon = row[lonCol]; + markers.addTo(map); - if (lat === null || lon === null) return; + layers[name] = markers; + mapControls.addOverlay(markers, name); + }; - if (classify && classify !== 'none') { - const groupColor = $scope.visualization.options.groups[name].color; - marker = heatpoint(lat, lon, groupColor); - } else { - marker = createMarker(lat, lon); - } + const render = () => { + const classify = this.options.classify; - marker.bindPopup(createDescription(latCol, lonCol, row)); - markers.addLayer(marker); - }); + tileLayer.setUrl(this.options.mapTileUrl || '//{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'); - markers.addTo(map); - - layers[name] = markers; - mapControls.addOverlay(markers, name); + if (this.options.clusterMarkers === undefined) { + this.options.clusterMarkers = true; } - function render() { - const queryData = $scope.queryResult.getData(); - const classify = $scope.visualization.options.classify; - - $scope.visualization.options.mapTileUrl = $scope.visualization.options.mapTileUrl || '//{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'; - - tileLayer.setUrl($scope.visualization.options.mapTileUrl); - - if ($scope.visualization.options.clusterMarkers === undefined) { - $scope.visualization.options.clusterMarkers = true; + if (this.data) { + let pointGroups; + if (classify && classify !== 'none') { + pointGroups = _.groupBy(this.data.rows, classify); + } else { + pointGroups = { All: this.data.rows }; } - if (queryData) { - let pointGroups; - if (classify && classify !== 'none') { - pointGroups = _.groupBy(queryData, classify); - } else { - pointGroups = { All: queryData }; + const groupNames = _.keys(pointGroups); + const options = _.map(groupNames, (group) => { + if (this.options.groups && this.options.groups[group]) { + return this.options.groups[group]; } + return { color: colorScale(group) }; + }); - const groupNames = _.keys(pointGroups); - const options = _.map(groupNames, (group) => { - if ($scope.visualization.options.groups && $scope.visualization.options.groups[group]) { - return $scope.visualization.options.groups[group]; - } - return { color: colorScale(group) }; - }); - - $scope.visualization.options.groups = _.zipObject(groupNames, options); + this.options.groups = _.zipObject(groupNames, options); - _.each(layers, (v) => { - removeLayer(v); - }); + _.each(layers, (v) => { + removeLayer(v); + }); - _.each(pointGroups, (v, k) => { - addLayer(k, v); - }); + _.each(pointGroups, (v, k) => { + addLayer(k, v); + }); - setBounds(); - } + setBounds(); } + }; + + $scope.handleResize = resize; + + $scope.$watch('$ctrl.data', render); + $scope.$watch('$ctrl.options', render, true); + }, +}; + +const MapEditor = { + template: editorTemplate, + bindings: { + data: '<', + options: '<', + onOptionsChange: '<', + }, + controller($scope) { + this.currentTab = 'general'; + this.setCurrentTab = (tab) => { + this.currentTab = tab; + }; - $scope.handleResize = () => { - resize(); - }; + this.mapTiles = MAP_TILES; - $scope.$watch('queryResult && queryResult.getData()', render); - $scope.$watch('visualization.options', render, true); - }, - }; -} + $scope.$watch('$ctrl.data.columns', () => { + this.columns = this.data.columns; + this.columnNames = _.map(this.columns, c => c.name); + this.classifyColumns = [...this.columnNames, 'none']; + }); -function mapEditor() { - return { - restrict: 'E', - template: editorTemplate, - link($scope) { - $scope.currentTab = 'general'; - $scope.columns = $scope.queryResult.getColumns(); - $scope.columnNames = _.map($scope.columns, i => i.name); - $scope.classify_columns = $scope.columnNames.concat('none'); - $scope.mapTiles = [ - { - name: 'OpenStreetMap', - url: '//{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', - }, - { - name: 'OpenStreetMap BW', - url: '//{s}.tiles.wmflabs.org/bw-mapnik/{z}/{x}/{y}.png', - }, - { - name: 'OpenStreetMap DE', - url: '//{s}.tile.openstreetmap.de/tiles/osmde/{z}/{x}/{y}.png', - }, - { - name: 'OpenStreetMap FR', - url: '//{s}.tile.openstreetmap.fr/osmfr/{z}/{x}/{y}.png', - }, - { - name: 'OpenStreetMap Hot', - url: '//{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png', - }, - { - name: 'Thunderforest', - url: '//{s}.tile.thunderforest.com/cycle/{z}/{x}/{y}.png', - }, - { - name: 'Thunderforest Spinal', - url: '//{s}.tile.thunderforest.com/spinal-map/{z}/{x}/{y}.png', - }, - { - name: 'OpenMapSurfer', - url: '//korona.geog.uni-heidelberg.de/tiles/roads/x={x}&y={y}&z={z}', - }, - { - name: 'Stamen Toner', - url: '//stamen-tiles-{s}.a.ssl.fastly.net/toner/{z}/{x}/{y}.png', - }, - { - name: 'Stamen Toner Background', - url: '//stamen-tiles-{s}.a.ssl.fastly.net/toner-background/{z}/{x}/{y}.png', - }, - { - name: 'Stamen Toner Lite', - url: '//stamen-tiles-{s}.a.ssl.fastly.net/toner-lite/{z}/{x}/{y}.png', - }, - { - name: 'OpenTopoMap', - url: '//{s}.tile.opentopomap.org/{z}/{x}/{y}.png', - }, - ]; - }, - }; -} + $scope.$watch('$ctrl.options', (options) => { + this.onOptionsChange(options); + }, true); + }, +}; export default function init(ngModule) { - ngModule.directive('mapRenderer', mapRenderer); - ngModule.directive('mapEditor', mapEditor); - ngModule.config((VisualizationProvider) => { - const renderTemplate = - '' + - ''; - - const editTemplate = ''; - const defaultOptions = { - defaultColumns: 3, - defaultRows: 8, - minColumns: 2, - classify: 'none', - clusterMarkers: true, - }; + ngModule.component('mapRenderer', MapRenderer); + ngModule.component('mapEditor', MapEditor); - VisualizationProvider.registerVisualization({ + ngModule.run(($injector) => { + registerVisualization({ type: 'MAP', name: 'Map (Markers)', - renderTemplate, - editorTemplate: editTemplate, - defaultOptions, + getOptions: options => _.merge({}, DEFAULT_OPTIONS, options), + Renderer: angular2react('mapRenderer', MapRenderer, $injector), + Editor: angular2react('mapEditor', MapEditor, $injector), }); }); } -// init.init = true; +init.init = true; diff --git a/client/app/visualizations/map/map-editor.html b/client/app/visualizations/map/map-editor.html index 20ea9661dc..99d5e0e78e 100644 --- a/client/app/visualizations/map/map-editor.html +++ b/client/app/visualizations/map/map-editor.html @@ -1,53 +1,53 @@
      -
      +
      - + {{$select.selected}} - + - +
      - + {{$select.selected}} - + - +
      - + {{$select.selected}} - + - +
      -
      +
      - +
      Name Color
      {{name}} @@ -57,18 +57,18 @@
      -
      +
      - +
      From 578708811edd93e27fe4101b7040059b6f3c4a98 Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Fri, 1 Mar 2019 15:53:36 +0200 Subject: [PATCH 10/41] Choropleth --- .../choropleth/choropleth-editor.html | 102 ++-- .../visualizations/choropleth/choropleth.html | 10 +- client/app/visualizations/choropleth/index.js | 504 +++++++++--------- 3 files changed, 305 insertions(+), 311 deletions(-) diff --git a/client/app/visualizations/choropleth/choropleth-editor.html b/client/app/visualizations/choropleth/choropleth-editor.html index ed6886bfe3..e237b9402f 100644 --- a/client/app/visualizations/choropleth/choropleth-editor.html +++ b/client/app/visualizations/choropleth/choropleth-editor.html @@ -1,29 +1,29 @@
      -
      +
      - +
      - +
      @@ -32,8 +32,8 @@
      - +
      @@ -46,7 +46,7 @@ popover-trigger="'click outsideClick'"> + ng-model="$ctrl.options.valueFormat" ng-model-options="{ allowInvalid: true, debounce: 200 }">
      @@ -54,22 +54,22 @@
      + ng-model="$ctrl.options.noValuePlaceholder" ng-model-options="{ allowInvalid: true, debounce: 200 }">
      - +
      @@ -78,58 +78,58 @@
      + ng-click="$ctrl.options.legend.alignText = 'left'" + ng-class="{active: $ctrl.options.legend.alignText == 'left'}"> + ng-click="$ctrl.options.legend.alignText = 'center'" + ng-class="{active: $ctrl.options.legend.alignText == 'center'}"> + ng-click="$ctrl.options.legend.alignText = 'right'" + ng-class="{active: $ctrl.options.legend.alignText == 'right'}">
      - +
      + ng-model="$ctrl.options.tooltip.template" ng-model-options="{ allowInvalid: true, debounce: 200 }" + ng-disabled="!$ctrl.options.tooltip.enabled">
      - +
      + ng-model="$ctrl.options.popup.template" ng-model-options="{ allowInvalid: true, debounce: 200 }" + ng-disabled="!$ctrl.options.popup.enabled">
      -
      +
      + ng-model="$ctrl.options.steps">
      - +
      @@ -138,12 +138,12 @@
      - + - + @@ -154,12 +154,12 @@
      - + - + @@ -170,12 +170,12 @@
      - + - + @@ -188,12 +188,12 @@
      - + - + @@ -204,12 +204,12 @@
      - + - + @@ -219,17 +219,17 @@
      -
      +
      + ng-model="$ctrl.options.bounds[1][0]" ng-model-options="{ allowInvalid: true, debounce: 200 }">
      + ng-model="$ctrl.options.bounds[1][1]" ng-model-options="{ allowInvalid: true, debounce: 200 }">
      @@ -239,11 +239,11 @@
      + ng-model="$ctrl.options.bounds[0][0]" ng-model-options="{ allowInvalid: true, debounce: 200 }">
      + ng-model="$ctrl.options.bounds[0][1]" ng-model-options="{ allowInvalid: true, debounce: 200 }">
      diff --git a/client/app/visualizations/choropleth/choropleth.html b/client/app/visualizations/choropleth/choropleth.html index 232a884b7f..bd1c8093cd 100644 --- a/client/app/visualizations/choropleth/choropleth.html +++ b/client/app/visualizations/choropleth/choropleth.html @@ -1,11 +1,11 @@
      -
      -
      +
      -
      +
      -
      {{ formatValue(item.limit) }}
      +
      {{ $ctrl.formatValue(item.limit) }}
      diff --git a/client/app/visualizations/choropleth/index.js b/client/app/visualizations/choropleth/index.js index 46809525ca..01b771b0d0 100644 --- a/client/app/visualizations/choropleth/index.js +++ b/client/app/visualizations/choropleth/index.js @@ -4,6 +4,10 @@ import 'leaflet/dist/leaflet.css'; import { formatSimpleTemplate } from '@/lib/value-format'; import 'leaflet-fullscreen'; import 'leaflet-fullscreen/dist/leaflet.fullscreen.css'; +import { angular2react } from 'angular2react'; +import { registerVisualization } from '@/visualizations'; + +import { ColorPalette } from '@/visualizations/chart/plotly/utils'; import { AdditionalColors, @@ -22,6 +26,42 @@ import editorTemplate from './choropleth-editor.html'; import countriesDataUrl from './countries.geo.json'; +export const ChoroplethPalette = _.extend({}, AdditionalColors, ColorPalette); + +const DEFAULT_OPTIONS = { + defaultColumns: 3, + defaultRows: 8, + minColumns: 2, + + countryCodeColumn: '', + countryCodeType: 'iso_a3', + valueColumn: '', + clusteringMode: 'e', + steps: 5, + valueFormat: '0,0.00', + noValuePlaceholder: 'N/A', + colors: { + min: ChoroplethPalette['Light Blue'], + max: ChoroplethPalette['Dark Blue'], + background: ChoroplethPalette.White, + borders: ChoroplethPalette.White, + noValue: ChoroplethPalette['Light Gray'], + }, + legend: { + visible: true, + position: 'bottom-left', + alignText: 'right', + }, + tooltip: { + enabled: true, + template: '{{ @@name }}: {{ @@value }}', + }, + popup: { + enabled: true, + template: 'Country: {{ @@name_long }} ({{ @@iso_a2 }})\n
      \nValue: {{ @@value }}', + }, +}; + const loadCountriesData = _.bind(function loadCountriesData($http, url) { if (!this[url]) { this[url] = $http.get(url).then(response => response.data); @@ -29,285 +69,239 @@ const loadCountriesData = _.bind(function loadCountriesData($http, url) { return this[url]; }, {}); -function choroplethRenderer($sanitize, $http) { - return { - restrict: 'E', - template, - scope: { - queryResult: '=', - options: '=?', - }, - link($scope, $element) { - let countriesData = null; - let map = null; - let choropleth = null; - let updateBoundsLock = false; - - function getBounds() { - if (!updateBoundsLock) { - const bounds = map.getBounds(); - $scope.options.bounds = [ - [bounds._southWest.lat, bounds._southWest.lng], - [bounds._northEast.lat, bounds._northEast.lng], - ]; - $scope.$applyAsync(); +const ChoroplethRenderer = { + template, + bindings: { + data: '<', + options: '<', + onOptionsChange: '<', + }, + controller($scope, $element, $sanitize, $http) { + let countriesData = null; + let map = null; + let choropleth = null; + let updateBoundsLock = false; + + const getBounds = () => { + if (!updateBoundsLock) { + const bounds = map.getBounds(); + this.options.bounds = [ + [bounds._southWest.lat, bounds._southWest.lng], + [bounds._northEast.lat, bounds._northEast.lng], + ]; + if (this.onOptionsChange) { + this.onOptionsChange(this.options); } + $scope.$applyAsync(); } + }; - function setBounds({ disableAnimation = false } = {}) { - if (map && choropleth) { - const bounds = $scope.options.bounds || choropleth.getBounds(); - const options = disableAnimation ? { - animate: false, - duration: 0, - } : null; - map.fitBounds(bounds, options); - } + const setBounds = ({ disableAnimation = false } = {}) => { + if (map && choropleth) { + const bounds = this.options.bounds || choropleth.getBounds(); + const options = disableAnimation ? { + animate: false, + duration: 0, + } : null; + map.fitBounds(bounds, options); } + }; - function render() { - if (map) { - map.remove(); - map = null; - choropleth = null; - } - if (!countriesData) { - return; - } - - $scope.formatValue = createNumberFormatter( - $scope.options.valueFormat, - $scope.options.noValuePlaceholder, - ); - - const data = prepareData( - $scope.queryResult.getData(), - $scope.options.countryCodeColumn, - $scope.options.valueColumn, - ); - - const { limits, colors, legend } = createScale( - countriesData.features, - data, - $scope.options, - ); - - // Update data for legend block - $scope.legendItems = legend; - - choropleth = L.geoJson(countriesData, { - onEachFeature: (feature, layer) => { - const value = getValueForFeature(feature, data, $scope.options.countryCodeType); - const valueFormatted = $scope.formatValue(value); - const featureData = prepareFeatureProperties( - feature, - valueFormatted, - data, - $scope.options.countryCodeType, - ); - const color = getColorByValue(value, limits, colors, $scope.options.colors.noValue); + const render = () => { + if (map) { + map.remove(); + map = null; + choropleth = null; + } + if (!countriesData) { + return; + } + this.formatValue = createNumberFormatter( + this.options.valueFormat, + this.options.noValuePlaceholder, + ); + + const data = prepareData(this.data.rows, this.options.countryCodeColumn, this.options.valueColumn); + + const { limits, colors, legend } = createScale(countriesData.features, data, this.options); + + // Update data for legend block + this.legendItems = legend; + + choropleth = L.geoJson(countriesData, { + onEachFeature: (feature, layer) => { + const value = getValueForFeature(feature, data, this.options.countryCodeType); + const valueFormatted = this.formatValue(value); + const featureData = prepareFeatureProperties( + feature, + valueFormatted, + data, + this.options.countryCodeType, + ); + const color = getColorByValue(value, limits, colors, this.options.colors.noValue); + + layer.setStyle({ + color: this.options.colors.borders, + weight: 1, + fillColor: color, + fillOpacity: 1, + }); + + if (this.options.tooltip.enabled) { + layer.bindTooltip($sanitize(formatSimpleTemplate( + this.options.tooltip.template, + featureData, + ))); + } + + if (this.options.popup.enabled) { + layer.bindPopup($sanitize(formatSimpleTemplate( + this.options.popup.template, + featureData, + ))); + } + + layer.on('mouseover', () => { + layer.setStyle({ + weight: 2, + fillColor: darkenColor(color), + }); + }); + layer.on('mouseout', () => { layer.setStyle({ - color: $scope.options.colors.borders, weight: 1, fillColor: color, - fillOpacity: 1, }); + }); + }, + }); - if ($scope.options.tooltip.enabled) { - layer.bindTooltip($sanitize(formatSimpleTemplate( - $scope.options.tooltip.template, - featureData, - ))); - } - - if ($scope.options.popup.enabled) { - layer.bindPopup($sanitize(formatSimpleTemplate( - $scope.options.popup.template, - featureData, - ))); - } - - layer.on('mouseover', () => { - layer.setStyle({ - weight: 2, - fillColor: darkenColor(color), - }); - }); - layer.on('mouseout', () => { - layer.setStyle({ - weight: 1, - fillColor: color, - }); - }); - }, - }); - - const choroplethBounds = choropleth.getBounds(); - - map = L.map($element[0].children[0].children[0], { - center: choroplethBounds.getCenter(), - zoom: 1, - zoomSnap: 0, - layers: [choropleth], - scrollWheelZoom: false, - maxBounds: choroplethBounds, - maxBoundsViscosity: 1, - attributionControl: false, - fullscreenControl: true, - }); - - map.on('focus', () => { map.on('moveend', getBounds); }); - map.on('blur', () => { map.off('moveend', getBounds); }); + const choroplethBounds = choropleth.getBounds(); + + map = L.map($element[0].children[0].children[0], { + center: choroplethBounds.getCenter(), + zoom: 1, + zoomSnap: 0, + layers: [choropleth], + scrollWheelZoom: false, + maxBounds: choroplethBounds, + maxBoundsViscosity: 1, + attributionControl: false, + fullscreenControl: true, + }); + + map.on('focus', () => { map.on('moveend', getBounds); }); + map.on('blur', () => { map.off('moveend', getBounds); }); + + setBounds({ disableAnimation: true }); + }; + loadCountriesData($http, countriesDataUrl).then((data) => { + if (_.isObject(data)) { + countriesData = data; + render(); + } + }); + + $scope.handleResize = _.debounce(() => { + if (map) { + map.invalidateSize(false); setBounds({ disableAnimation: true }); } + }, 50); + + $scope.$watch('$ctrl.data', render); + $scope.$watch(() => _.omit(this.options, 'bounds'), render, true); + $scope.$watch('$ctrl.options.bounds', () => { + // Prevent infinite digest loop + const savedLock = updateBoundsLock; + updateBoundsLock = true; + setBounds(); + updateBoundsLock = savedLock; + }, true); + }, +}; + +const ChoroplethEditor = { + template: editorTemplate, + bindings: { + data: '<', + options: '<', + onOptionsChange: '<', + }, + controller($scope) { + this.currentTab = 'general'; + this.setCurrentTab = (tab) => { + this.currentTab = tab; + }; - loadCountriesData($http, countriesDataUrl).then((data) => { - if (_.isObject(data)) { - countriesData = data; - render(); - } - }); + this.colors = ChoroplethPalette; - $scope.handleResize = _.debounce(() => { - if (map) { - map.invalidateSize(false); - setBounds({ disableAnimation: true }); - } - }, 50); - - $scope.$watch('queryResult && queryResult.getData()', render); - $scope.$watch(() => _.omit($scope.options, 'bounds'), render, true); - $scope.$watch('options.bounds', () => { - // Prevent infinite digest loop - const savedLock = updateBoundsLock; - updateBoundsLock = true; - setBounds(); - updateBoundsLock = savedLock; - }, true); - }, - }; -} + this.clusteringModes = { + q: 'quantile', + e: 'equidistant', + k: 'k-means', + }; -function choroplethEditor(ChoroplethPalette) { - return { - restrict: 'E', - template: editorTemplate, - scope: { - queryResult: '=', - options: '=?', - }, - link($scope) { - $scope.currentTab = 'general'; - $scope.changeTab = (tab) => { - $scope.currentTab = tab; - }; - - $scope.colors = ChoroplethPalette; - - $scope.clusteringModes = { - q: 'quantile', - e: 'equidistant', - k: 'k-means', - }; - - $scope.legendPositions = { - 'top-left': 'top / left', - 'top-right': 'top / right', - 'bottom-left': 'bottom / left', - 'bottom-right': 'bottom / right', - }; - - $scope.countryCodeTypes = { - name: 'Short name', - name_long: 'Full name', - abbrev: 'Abbreviated name', - iso_a2: 'ISO code (2 letters)', - iso_a3: 'ISO code (3 letters)', - iso_n3: 'ISO code (3 digits)', - }; - - $scope.templateHint = ` -
      All query result columns can be referenced using {{ column_name }} syntax.
      -
      Use special names to access additional properties:
      -
      {{ @@value }} formatted value;
      -
      {{ @@name }} short country name;
      -
      {{ @@name_long }} full country name;
      -
      {{ @@abbrev }} abbreviated country name;
      -
      {{ @@iso_a2 }} two-letter ISO country code;
      -
      {{ @@iso_a3 }} three-letter ISO country code;
      -
      {{ @@iso_n3 }} three-digit ISO country code.
      -
      This syntax is applicable to tooltip and popup templates.
      - `; - - function updateCountryCodeType() { - $scope.options.countryCodeType = inferCountryCodeType( - $scope.queryResult.getData(), - $scope.options.countryCodeColumn, - ) || $scope.options.countryCodeType; - } + this.legendPositions = { + 'top-left': 'top / left', + 'top-right': 'top / right', + 'bottom-left': 'bottom / left', + 'bottom-right': 'bottom / right', + }; - $scope.$watch('options.countryCodeColumn', updateCountryCodeType); - $scope.$watch('queryResult.getData()', updateCountryCodeType); - }, - }; -} + this.countryCodeTypes = { + name: 'Short name', + name_long: 'Full name', + abbrev: 'Abbreviated name', + iso_a2: 'ISO code (2 letters)', + iso_a3: 'ISO code (3 letters)', + iso_n3: 'ISO code (3 digits)', + }; -export default function init(ngModule) { - ngModule.constant('ChoroplethPalette', {}); - ngModule.directive('choroplethRenderer', choroplethRenderer); - ngModule.directive('choroplethEditor', choroplethEditor); - ngModule.config((VisualizationProvider, ColorPalette, ChoroplethPalette) => { - _.extend(ChoroplethPalette, AdditionalColors, ColorPalette); - - const renderTemplate = - ''; - - const editTemplate = ''; - - const defaultOptions = { - defaultColumns: 3, - defaultRows: 8, - minColumns: 2, - - countryCodeColumn: '', - countryCodeType: 'iso_a3', - valueColumn: '', - clusteringMode: 'e', - steps: 5, - valueFormat: '0,0.00', - noValuePlaceholder: 'N/A', - colors: { - min: ChoroplethPalette['Light Blue'], - max: ChoroplethPalette['Dark Blue'], - background: ChoroplethPalette.White, - borders: ChoroplethPalette.White, - noValue: ChoroplethPalette['Light Gray'], - }, - legend: { - visible: true, - position: 'bottom-left', - alignText: 'right', - }, - tooltip: { - enabled: true, - template: '{{ @@name }}: {{ @@value }}', - }, - popup: { - enabled: true, - template: 'Country: {{ @@name_long }} ({{ @@iso_a2 }})\n
      \nValue: {{ @@value }}', - }, + this.templateHint = ` +
      All query result columns can be referenced using {{ column_name }} syntax.
      +
      Use special names to access additional properties:
      +
      {{ @@value }} formatted value;
      +
      {{ @@name }} short country name;
      +
      {{ @@name_long }} full country name;
      +
      {{ @@abbrev }} abbreviated country name;
      +
      {{ @@iso_a2 }} two-letter ISO country code;
      +
      {{ @@iso_a3 }} three-letter ISO country code;
      +
      {{ @@iso_n3 }} three-digit ISO country code.
      +
      This syntax is applicable to tooltip and popup templates.
      + `; + + const updateCountryCodeType = () => { + this.options.countryCodeType = inferCountryCodeType( + this.data ? this.data.rows : [], + this.options.countryCodeColumn, + ) || this.options.countryCodeType; }; - VisualizationProvider.registerVisualization({ + $scope.$watch('$ctrl.options.countryCodeColumn', updateCountryCodeType); + $scope.$watch('$ctrl.data', updateCountryCodeType); + + $scope.$watch('$ctrl.options', (options) => { + this.onOptionsChange(options); + }, true); + }, +}; + +export default function init(ngModule) { + ngModule.component('choroplethRenderer', ChoroplethRenderer); + ngModule.component('choroplethEditor', ChoroplethEditor); + + ngModule.run(($injector) => { + registerVisualization({ type: 'CHOROPLETH', name: 'Map (Choropleth)', - renderTemplate, - editorTemplate: editTemplate, - defaultOptions, + getOptions: options => _.merge({}, DEFAULT_OPTIONS, options), + Renderer: angular2react('choroplethRenderer', ChoroplethRenderer, $injector), + Editor: angular2react('choroplethEditor', ChoroplethEditor, $injector), }); }); } -// init.init = true; +init.init = true; From d5668c340671076c699ebe1337d9898c182169f2 Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Mon, 4 Mar 2019 20:24:34 +0200 Subject: [PATCH 11/41] Chart --- client/app/components/ColorBox.jsx | 19 + client/app/components/color-box.less | 8 + client/app/services/query-result.js | 95 +-- client/app/services/query.js | 5 - client/app/visualizations/ColorPalette.js | 38 ++ .../visualizations/chart/chart-editor.html | 235 ++++---- client/app/visualizations/chart/chart.html | 6 +- .../app/visualizations/chart/getChartData.js | 101 ++++ client/app/visualizations/chart/index.js | 548 ++++++++---------- .../app/visualizations/chart/plotly/index.js | 2 - .../app/visualizations/chart/plotly/utils.js | 31 +- client/app/visualizations/choropleth/index.js | 3 +- client/app/visualizations/funnel/index.js | 3 +- 13 files changed, 551 insertions(+), 543 deletions(-) create mode 100644 client/app/components/ColorBox.jsx create mode 100644 client/app/components/color-box.less create mode 100644 client/app/visualizations/ColorPalette.js create mode 100644 client/app/visualizations/chart/getChartData.js diff --git a/client/app/components/ColorBox.jsx b/client/app/components/ColorBox.jsx new file mode 100644 index 0000000000..fa0498bace --- /dev/null +++ b/client/app/components/ColorBox.jsx @@ -0,0 +1,19 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { react2angular } from 'react2angular'; + +import './color-box.less'; + +export function ColorBox({ color }) { + return ; +} + +ColorBox.propTypes = { + color: PropTypes.string.isRequired, +}; + +export default function init(ngModule) { + ngModule.component('colorBox', react2angular(ColorBox)); +} + +init.init = true; diff --git a/client/app/components/color-box.less b/client/app/components/color-box.less new file mode 100644 index 0000000000..c90ca0bcac --- /dev/null +++ b/client/app/components/color-box.less @@ -0,0 +1,8 @@ +color-box { + span { + width: 12px; + height: 12px; + display: inline-block; + margin-right: 5px; + } +} diff --git a/client/app/services/query-result.js b/client/app/services/query-result.js index 80bf1b1f92..aaa4def2a5 100644 --- a/client/app/services/query-result.js +++ b/client/app/services/query-result.js @@ -1,6 +1,6 @@ import debug from 'debug'; import moment from 'moment'; -import { sortBy, uniqBy, values, each, isNumber, isString, includes, extend, forOwn } from 'lodash'; +import { uniqBy, each, isNumber, isString, includes, extend, forOwn } from 'lodash'; const logger = debug('redash:services:QueryResult'); const filterTypes = ['filter', 'multi-filter', 'multiFilter']; @@ -35,18 +35,6 @@ function getColumnFriendlyName(column) { return getColumnNameWithoutType(column).replace(/(?:^|\s)\S/g, a => a.toUpperCase()); } -function addPointToSeries(point, seriesCollection, seriesName) { - if (seriesCollection[seriesName] === undefined) { - seriesCollection[seriesName] = { - name: seriesName, - type: 'column', - data: [], - }; - } - - seriesCollection[seriesName].data.push(point); -} - function QueryResultService($resource, $timeout, $q, QueryResultError) { const QueryResultResource = $resource('api/query_results/:id', { id: '@id' }, { post: { method: 'POST' } }); const Job = $resource('api/jobs/:id', { id: '@id' }); @@ -208,87 +196,6 @@ function QueryResultService($resource, $timeout, $q, QueryResultError) { return this.getData() === null || this.getData().length === 0; } - getChartData(mapping) { - const series = {}; - - this.getData().forEach((row) => { - let point = { $raw: row }; - let seriesName; - let xValue = 0; - const yValues = {}; - let eValue = null; - let sizeValue = null; - let zValue = null; - - forOwn(row, (v, definition) => { - definition = '' + definition; - const definitionParts = definition.split('::') || definition.split('__'); - const name = definitionParts[0]; - const type = mapping ? mapping[definition] : definitionParts[1]; - let value = v; - - if (type === 'unused') { - return; - } - - if (type === 'x') { - xValue = value; - point[type] = value; - } - if (type === 'y') { - if (value == null) { - value = 0; - } - yValues[name] = value; - point[type] = value; - } - if (type === 'yError') { - eValue = value; - point[type] = value; - } - - if (type === 'series') { - seriesName = String(value); - } - - if (type === 'size') { - point[type] = value; - sizeValue = value; - } - - if (type === 'zVal') { - point[type] = value; - zValue = value; - } - - if (type === 'multiFilter' || type === 'multi-filter') { - seriesName = String(value); - } - }); - - if (seriesName === undefined) { - each(yValues, (yValue, ySeriesName) => { - point = { x: xValue, y: yValue, $raw: point.$raw }; - if (eValue !== null) { - point.yError = eValue; - } - - if (sizeValue !== null) { - point.size = sizeValue; - } - - if (zValue !== null) { - point.zVal = zValue; - } - addPointToSeries(point, series, ySeriesName); - }); - } else { - addPointToSeries(point, series, seriesName); - } - }); - return sortBy(values(series), 'name'); - } - getColumns() { if (this.columns === undefined && this.query_result.data) { this.columns = this.query_result.data.columns; diff --git a/client/app/services/query.js b/client/app/services/query.js index 593026375c..ccda2ce45b 100644 --- a/client/app/services/query.js +++ b/client/app/services/query.js @@ -319,11 +319,6 @@ function QueryResultErrorFactory($q) { getLog() { return null; } - - // eslint-disable-next-line class-methods-use-this - getChartData() { - return null; - } } return QueryResultError; diff --git a/client/app/visualizations/ColorPalette.js b/client/app/visualizations/ColorPalette.js new file mode 100644 index 0000000000..a33937922c --- /dev/null +++ b/client/app/visualizations/ColorPalette.js @@ -0,0 +1,38 @@ +import { values } from 'lodash'; + +// The following colors will be used if you pick "Automatic" color +export const BaseColors = { + Blue: '#356AFF', + Red: '#E92828', + Green: '#3BD973', + Purple: '#604FE9', + Cyan: '#50F5ED', + Orange: '#FB8D3D', + 'Light Blue': '#799CFF', + Lilac: '#B554FF', + 'Light Green': '#8CFFB4', + Brown: '#A55F2A', + Black: '#000000', + Gray: '#494949', + Pink: '#FF7DE3', + 'Dark Blue': '#002FB4', +}; + +// Additional colors for the user to choose from +export const AdditionalColors = { + 'Indian Red': '#981717', + 'Green 2': '#17BF51', + 'Green 3': '#049235', + DarkTurquoise: '#00B6EB', + 'Dark Violet': '#A58AFF', + 'Pink 2': '#C63FA9', +}; + +export const ColorPaletteArray = values(BaseColors); + +const ColorPalette = { + ...BaseColors, + ...AdditionalColors, +}; + +export default ColorPalette; diff --git a/client/app/visualizations/chart/chart-editor.html b/client/app/visualizations/chart/chart-editor.html index 4e08b36a98..a175d763f7 100644 --- a/client/app/visualizations/chart/chart-editor.html +++ b/client/app/visualizations/chart/chart-editor.html @@ -1,37 +1,37 @@
      -
      +
      -
      +
      - +
      {{$select.selected.value.name}}
      - +
      @@ -45,146 +45,149 @@
      - + {{$select.selected}} - + - +
      -
      +
      - + {{$item}} - + - +
      -
      +
      - + {{$select.selected}} - + - +
      -
      +
      - + {{$select.selected}} - + - +
      -
      +
      - + {{$select.selected}} - + - +
      -
      +
      - + {{$select.selected}} - + - +
      -
      +
      -
      +
      -
      +
      -
      +
      - + {{ $select.selected.key }} - +
      -
      +
      -
      +
      -
      -
      +
      - + {{$select.selected.label}} - +
      @@ -192,40 +195,40 @@
      - +
      -
      -
      +
      +

      {{$index == 0 ? 'Left' : 'Right'}} Y Axis

      {{$select.selected | capitalize}} - +
      @@ -243,16 +246,16 @@

      {{$index == 0 ? 'Left' : 'Right'}} Y Axis

      -
      +
      @@ -260,39 +263,43 @@

      {{$index == 0 ? 'Left' : 'Right'}} Y Axis

      -
      +
      - - + + - + - - + + - - -
      zIndexLeft Y AxisRight Y AxisLeft Y AxisRight Y Axis LabelTypeType
      - + - + + - + + - + - + +
      {{$select.selected.value.name}}
      - +
      @@ -306,19 +313,19 @@

      {{$index == 0 ? 'Left' : 'Right'}} Y Axis

      -
      +
      - +
      {{ name }}
      - + - + @@ -329,13 +336,13 @@

      {{$index == 0 ? 'Left' : 'Right'}} Y Axis

      -
      -
      +
      +
      - + {{$select.selected | capitalize}} - +
      @@ -343,13 +350,13 @@

      {{$index == 0 ? 'Left' : 'Right'}} Y Axis

      -
      +
      - + - + @@ -357,13 +364,13 @@

      {{$index == 0 ? 'Left' : 'Right'}} Y Axis

      -
      +
      - + - + @@ -373,19 +380,19 @@

      {{$index == 0 ? 'Left' : 'Right'}} Y Axis

      -
      +
      - +
      {{ name }}
      - + - + @@ -396,10 +403,10 @@

      {{$index == 0 ? 'Left' : 'Right'}} Y Axis

      -
      -
      +
      +
      + Show Data Labels
      @@ -410,7 +417,10 @@

      {{$index == 0 ? 'Left' : 'Right'}} Y Axis

      - +
      @@ -421,7 +431,10 @@

      {{$index == 0 ? 'Left' : 'Right'}} Y Axis

      - +
      @@ -432,16 +445,22 @@

      {{$index == 0 ? 'Left' : 'Right'}} Y Axis

      - +
      - +
      diff --git a/client/app/visualizations/chart/chart.html b/client/app/visualizations/chart/chart.html index c6f420a29f..8c2cbedc34 100644 --- a/client/app/visualizations/chart/chart.html +++ b/client/app/visualizations/chart/chart.html @@ -1,6 +1,6 @@ -
      - +
      +
      - +
      diff --git a/client/app/visualizations/chart/getChartData.js b/client/app/visualizations/chart/getChartData.js new file mode 100644 index 0000000000..8e8f1ebd39 --- /dev/null +++ b/client/app/visualizations/chart/getChartData.js @@ -0,0 +1,101 @@ +import { isNil, each, forOwn, sortBy, values } from 'lodash'; + +function addPointToSeries(point, seriesCollection, seriesName) { + if (seriesCollection[seriesName] === undefined) { + seriesCollection[seriesName] = { + name: seriesName, + type: 'column', + data: [], + }; + } + + seriesCollection[seriesName].data.push(point); +} + +export default function getChartData(data, options) { + const series = {}; + + const mappings = options.columnMapping; + + each(data, (row) => { + let point = { $raw: row }; + let seriesName = null; + let xValue = 0; + const yValues = {}; + let eValue = null; + let sizeValue = null; + let zValue = null; + + forOwn(row, (v, 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; + } + + if (type === 'x') { + xValue = value; + point[type] = value; + } + if (type === 'y') { + if (value == null) { + value = 0; + } + yValues[name] = value; + point[type] = value; + } + if (type === 'yError') { + eValue = value; + point[type] = value; + } + + if (type === 'series') { + seriesName = String(value); + } + + if (type === 'size') { + point[type] = value; + sizeValue = value; + } + + if (type === 'zVal') { + point[type] = value; + zValue = value; + } + + if (type === 'multiFilter' || type === 'multi-filter') { + seriesName = String(value); + } + }); + + if (isNil(seriesName)) { + each(yValues, (yValue, ySeriesName) => { + point = { x: xValue, y: yValue, $raw: point.$raw }; + if (eValue !== null) { + point.yError = eValue; + } + + if (sizeValue !== null) { + point.size = sizeValue; + } + + if (zValue !== null) { + point.zVal = zValue; + } + addPointToSeries(point, series, ySeriesName); + }); + } else { + addPointToSeries(point, series, seriesName); + } + }); + return sortBy(values(series), ({ name }) => { + if (options.seriesOptions[name]) { + return options.seriesOptions[name].zIndex; + } + return 0; + }); +} diff --git a/client/app/visualizations/chart/index.js b/client/app/visualizations/chart/index.js index 3c0c480889..514be21592 100644 --- a/client/app/visualizations/chart/index.js +++ b/client/app/visualizations/chart/index.js @@ -1,7 +1,11 @@ import { - some, extend, defaults, has, partial, intersection, without, includes, isUndefined, - sortBy, each, map, keys, difference, + some, partial, intersection, without, includes, sortBy, each, map, keys, difference, merge, isNil, trim, pick, } from 'lodash'; +import { angular2react } from 'angular2react'; +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'; @@ -29,343 +33,291 @@ const DEFAULT_OPTIONS = { minRows: 5, }; -function ChartRenderer() { - return { - restrict: 'E', - scope: { - queryResult: '=', - options: '=?', - }, - template, - replace: false, - controller($scope, clientConfig) { - $scope.chartSeries = []; - - function zIndexCompare(series) { - if ($scope.options.seriesOptions[series.name]) { - return $scope.options.seriesOptions[series.name].zIndex; - } - return 0; - } - - function reloadData() { - if (!isUndefined($scope.queryResult) && $scope.queryResult.getData()) { - const data = $scope.queryResult.getChartData($scope.options.columnMapping); - $scope.chartSeries = sortBy(data, zIndexCompare); - } - } +function initEditorForm(options, columns) { + const result = { + yAxisColumns: [], + seriesList: sortBy(keys(options.seriesOptions), name => options.seriesOptions[name].zIndex), + valuesList: keys(options.valuesOptions), + }; - function reloadChart() { - reloadData(); - $scope.plotlyOptions = extend({ - showDataLabels: $scope.options.globalSeriesType === 'pie', - dateTimeFormat: clientConfig.dateTimeFormat, - }, DEFAULT_OPTIONS, $scope.options); - } + // Use only mappings for columns that exists in query results + const mappings = pick( + options.columnMapping, + map(columns, c => c.name), + ); + + each(mappings, (type, column) => { + switch (type) { + case 'x': + result.xAxisColumn = column; + break; + case 'y': + result.yAxisColumns.push(column); + break; + case 'series': + result.groupby = column; + break; + case 'yError': + result.errorColumn = column; + break; + case 'size': + result.sizeColumn = column; + break; + case 'zVal': + result.zValColumn = column; + break; + // no default + } + }); - $scope.$watch('options', reloadChart, true); - $scope.$watch('queryResult && queryResult.getData()', reloadData); - }, - }; + return result; } -function ChartEditor(ColorPalette, clientConfig) { - return { - restrict: 'E', - template: editorTemplate, - scope: { - queryResult: '=', - options: '=?', - }, - link(scope) { - scope.currentTab = 'general'; - scope.colors = extend({ Automatic: null }, ColorPalette); - - scope.stackingOptions = { - Disabled: null, - Stack: 'stack', - }; - - scope.changeTab = (tab) => { - scope.currentTab = tab; - }; - - scope.chartTypes = { - line: { name: 'Line', icon: 'line-chart' }, - column: { name: 'Bar', icon: 'bar-chart' }, - area: { name: 'Area', icon: 'area-chart' }, - pie: { name: 'Pie', icon: 'pie-chart' }, - scatter: { name: 'Scatter', icon: 'circle-o' }, - bubble: { name: 'Bubble', icon: 'circle-o' }, - heatmap: { name: 'Heatmap', icon: 'th' }, - box: { name: 'Box', icon: 'square-o' }, - }; - - if (clientConfig.allowCustomJSVisualizations) { - scope.chartTypes.custom = { name: 'Custom', icon: 'code' }; +const ChartRenderer = { + template, + bindings: { + data: '<', + options: '<', + }, + controller($scope) { + this.chartSeries = []; + + const update = () => { + if (this.data) { + this.chartSeries = getChartData(this.data.rows, this.options); } + }; - scope.xAxisScales = [ - { label: 'Auto Detect', value: '-' }, - { label: 'Datetime', value: 'datetime' }, - { label: 'Linear', value: 'linear' }, - { label: 'Logarithmic', value: 'logarithmic' }, - { label: 'Category', value: 'category' }, - ]; - scope.yAxisScales = ['linear', 'logarithmic', 'datetime', 'category']; - - scope.chartTypeChanged = () => { - keys(scope.options.seriesOptions).forEach((key) => { - scope.options.seriesOptions[key].type = scope.options.globalSeriesType; - }); - scope.options.showDataLabels = scope.options.globalSeriesType === 'pie'; - scope.$applyAsync(); - }; + $scope.$watch('$ctrl.data', update); + $scope.$watch('$ctrl.options', update, true); + }, +}; - scope.colorScheme = ['Blackbody', 'Bluered', 'Blues', 'Earth', 'Electric', - 'Greens', 'Greys', 'Hot', 'Jet', 'Picnic', 'Portland', - 'Rainbow', 'RdBu', 'Reds', 'Viridis', 'YlGnBu', 'YlOrRd', 'Custom...']; +const ChartEditor = { + template: editorTemplate, + bindings: { + data: '<', + options: '<', + onOptionsChange: '<', + }, + controller($scope) { + this.currentTab = 'general'; + this.setCurrentTab = (tab) => { + this.currentTab = tab; + }; + + this.colors = { + Automatic: null, + ...ColorPalette, + }; + + this.stackingOptions = { + Disabled: null, + Stack: 'stack', + }; + + this.chartTypes = { + line: { name: 'Line', icon: 'line-chart' }, + column: { name: 'Bar', icon: 'bar-chart' }, + area: { name: 'Area', icon: 'area-chart' }, + pie: { name: 'Pie', icon: 'pie-chart' }, + scatter: { name: 'Scatter', icon: 'circle-o' }, + bubble: { name: 'Bubble', icon: 'circle-o' }, + heatmap: { name: 'Heatmap', icon: 'th' }, + box: { name: 'Box', icon: 'square-o' }, + }; + + if (clientConfig.allowCustomJSVisualizations) { + this.chartTypes.custom = { name: 'Custom', icon: 'code' }; + } + + this.xAxisScales = [ + { label: 'Auto Detect', value: '-' }, + { label: 'Datetime', value: 'datetime' }, + { label: 'Linear', value: 'linear' }, + { label: 'Logarithmic', value: 'logarithmic' }, + { label: 'Category', value: 'category' }, + ]; + this.yAxisScales = ['linear', 'logarithmic', 'datetime', 'category']; + + this.colorScheme = ['Blackbody', 'Bluered', 'Blues', 'Earth', 'Electric', + 'Greens', 'Greys', 'Hot', 'Jet', 'Picnic', 'Portland', + 'Rainbow', 'RdBu', 'Reds', 'Viridis', 'YlGnBu', 'YlOrRd', 'Custom...']; + + this.chartTypeChanged = () => { + keys(this.options.seriesOptions).forEach((key) => { + this.options.seriesOptions[key].type = this.options.globalSeriesType; + }); + this.options.showDataLabels = this.options.globalSeriesType === 'pie'; + $scope.$applyAsync(); + }; - scope.showSizeColumnPicker = () => some(scope.options.seriesOptions, options => options.type === 'bubble'); - scope.showZColumnPicker = () => some(scope.options.seriesOptions, options => options.type === 'heatmap'); + this.showSizeColumnPicker = () => some(this.options.seriesOptions, options => options.type === 'bubble'); + this.showZColumnPicker = () => some(this.options.seriesOptions, options => options.type === 'heatmap'); - if (scope.options.customCode === undefined) { - scope.options.customCode = `// Available variables are x, ys, element, and Plotly + if (isNil(this.options.customCode)) { + this.options.customCode = trim(` +// Available variables are x, ys, element, and Plotly // Type console.log(x, ys); for more info about x and ys // To plot your graph call Plotly.plot(element, ...) -// Plotly examples and docs: https://plot.ly/javascript/`; - } - - function refreshColumns() { - scope.columns = scope.queryResult.getColumns(); - scope.columnNames = map(scope.columns, i => i.name); - if (scope.columnNames.length > 0) { - each(difference(keys(scope.options.columnMapping), scope.columnNames), (column) => { - delete scope.options.columnMapping[column]; - }); - } +// Plotly examples and docs: https://plot.ly/javascript/ + `); + } + + this.form = initEditorForm(this.options, this.data.columns); + + const refreshColumns = () => { + this.columns = this.data.columns; + this.columnNames = map(this.columns, c => c.name); + if (this.columnNames.length > 0) { + each(difference(keys(this.options.columnMapping), this.columnNames), (column) => { + delete this.options.columnMapping[column]; + }); } + }; - function refreshColumnsAndForm() { - refreshColumns(); - if (!scope.queryResult.getData() || - scope.queryResult.getData().length === 0 || - scope.columns.length === 0) { - return; - } - scope.form.yAxisColumns = intersection(scope.form.yAxisColumns, scope.columnNames); - if (!includes(scope.columnNames, scope.form.xAxisColumn)) { - scope.form.xAxisColumn = undefined; + const refreshColumnsAndForm = () => { + refreshColumns(); + const data = this.data; + if (data && (data.columns.length > 0) && (data.rows.length > 0)) { + this.form.yAxisColumns = intersection(this.form.yAxisColumns, this.columnNames); + if (!includes(this.columnNames, this.form.xAxisColumn)) { + this.form.xAxisColumn = undefined; } - if (!includes(scope.columnNames, scope.form.groupby)) { - scope.form.groupby = undefined; + if (!includes(this.columnNames, this.form.groupby)) { + this.form.groupby = undefined; } } + }; + + const refreshSeries = () => { + const chartData = getChartData(this.data.rows, this.options); + const seriesNames = map(chartData, s => s.name); + const existing = keys(this.options.seriesOptions); + each(difference(seriesNames, existing), (name) => { + this.options.seriesOptions[name] = { + type: this.options.globalSeriesType, + yAxis: 0, + }; + this.form.seriesList.push(name); + }); + each(difference(existing, seriesNames), (name) => { + this.form.seriesList = without(this.form.seriesList, name); + delete this.options.seriesOptions[name]; + }); - function refreshSeries() { - const chartData = scope.queryResult.getChartData(scope.options.columnMapping); - const seriesNames = map(chartData, i => i.name); - const existing = keys(scope.options.seriesOptions); - each(difference(seriesNames, existing), (name) => { - scope.options.seriesOptions[name] = { - type: scope.options.globalSeriesType, - yAxis: 0, - }; - scope.form.seriesList.push(name); + if (this.options.globalSeriesType === 'pie') { + const uniqueValuesNames = new Set(); + each(chartData, (series) => { + each(series.data, (row) => { + uniqueValuesNames.add(row.x); + }); }); - each(difference(existing, seriesNames), (name) => { - scope.form.seriesList = without(scope.form.seriesList, name); - delete scope.options.seriesOptions[name]; + const valuesNames = []; + uniqueValuesNames.forEach(v => valuesNames.push(v)); + + // initialize newly added values + const newValues = difference(valuesNames, keys(this.options.valuesOptions)); + each(newValues, (name) => { + this.options.valuesOptions[name] = {}; + this.form.valuesList.push(name); }); - - if (scope.options.globalSeriesType === 'pie') { - const uniqueValuesNames = new Set(); - each(chartData, (series) => { - each(series.data, (row) => { - uniqueValuesNames.add(row.x); - }); - }); - const valuesNames = []; - uniqueValuesNames.forEach(v => valuesNames.push(v)); - - // initialize newly added values - const newValues = difference(valuesNames, keys(scope.options.valuesOptions)); - each(newValues, (name) => { - scope.options.valuesOptions[name] = {}; - scope.form.valuesList.push(name); - }); - // remove settings for values that are no longer available - each(keys(scope.options.valuesOptions), (name) => { - if (valuesNames.indexOf(name) === -1) { - delete scope.options.valuesOptions[name]; - } - }); - scope.form.valuesList = intersection(scope.form.valuesList, valuesNames); - } - } - - function setColumnRole(role, column) { - scope.options.columnMapping[column] = role; + // remove settings for values that are no longer available + each(keys(this.options.valuesOptions), (name) => { + if (valuesNames.indexOf(name) === -1) { + delete this.options.valuesOptions[name]; + } + }); + this.form.valuesList = intersection(this.form.valuesList, valuesNames); } + }; - function unsetColumn(column) { - setColumnRole('unused', column); - } + const setColumnRole = (role, column) => { + this.options.columnMapping[column] = role; + }; - refreshColumns(); + const unsetColumn = column => setColumnRole('unused', column); - scope.$watch('options.columnMapping', () => { - if (scope.queryResult.status === 'done') { - refreshSeries(); - } - }, true); + refreshColumns(); - scope.$watch(() => [scope.queryResult.getId(), scope.queryResult.status], (changed) => { - if (!changed[0] || changed[1] !== 'done') { - return; - } + $scope.$watch('$ctrl.options.columnMapping', refreshSeries, true); - refreshColumnsAndForm(); - refreshSeries(); - }, true); - - scope.form = { - yAxisColumns: [], - seriesList: sortBy(keys(scope.options.seriesOptions), name => scope.options.seriesOptions[name].zIndex), - valuesList: keys(scope.options.valuesOptions), - }; - - scope.$watchCollection('form.seriesList', (value) => { - each(value, (name, index) => { - scope.options.seriesOptions[name].zIndex = index; - scope.options.seriesOptions[name].index = 0; // is this needed? - }); - }); - - - scope.$watchCollection('form.yAxisColumns', (value, old) => { - each(old, unsetColumn); - each(value, partial(setColumnRole, 'y')); - }); - - scope.$watch('form.xAxisColumn', (value, old) => { - if (old !== undefined) { - unsetColumn(old); - } - if (value !== undefined) { setColumnRole('x', value); } - }); + $scope.$watch('$ctrl.data', () => { + refreshColumnsAndForm(); + refreshSeries(); + }); - scope.$watch('form.errorColumn', (value, old) => { - if (old !== undefined) { - unsetColumn(old); - } - if (value !== undefined) { - setColumnRole('yError', value); - } + $scope.$watchCollection('$ctrl.form.seriesList', (value) => { + each(value, (name, index) => { + this.options.seriesOptions[name].zIndex = index; + this.options.seriesOptions[name].index = 0; // is this needed? }); + }); - scope.$watch('form.sizeColumn', (value, old) => { - if (old !== undefined) { - unsetColumn(old); - } - if (value !== undefined) { - setColumnRole('size', value); - } - }); + $scope.$watchCollection('$ctrl.form.yAxisColumns', (value, old) => { + each(old, unsetColumn); + each(value, partial(setColumnRole, 'y')); + }); - scope.$watch('form.zValColumn', (value, old) => { - if (old !== undefined) { - unsetColumn(old); - } - if (value !== undefined) { - setColumnRole('zVal', value); - } - }); + $scope.$watch('$ctrl.form.xAxisColumn', (value, old) => { + if (old !== undefined) { unsetColumn(old); } + if (value !== undefined) { setColumnRole('x', value); } + }); - scope.$watch('form.groupby', (value, old) => { - if (old !== undefined) { - unsetColumn(old); - } - if (value !== undefined) { - setColumnRole('series', value); - } - }); + $scope.$watch('$ctrl.form.errorColumn', (value, old) => { + if (old !== undefined) { unsetColumn(old); } + if (value !== undefined) { setColumnRole('yError', value); } + }); - if (!has(scope.options, 'legend')) { - scope.options.legend = { enabled: true }; - } + $scope.$watch('$ctrl.form.sizeColumn', (value, old) => { + if (old !== undefined) { unsetColumn(old); } + if (value !== undefined) { setColumnRole('size', value); } + }); - if (scope.columnNames) { - each(scope.options.columnMapping, (value, key) => { - if (scope.columnNames.length > 0 && !includes(scope.columnNames, key)) { - return; - } - if (value === 'x') { - scope.form.xAxisColumn = key; - } else if (value === 'y') { - scope.form.yAxisColumns.push(key); - } else if (value === 'series') { - scope.form.groupby = key; - } else if (value === 'yError') { - scope.form.errorColumn = key; - } else if (value === 'size') { - scope.form.sizeColumn = key; - } else if (value === 'zVal') { - scope.form.zValColumn = key; - } - }); - } + $scope.$watch('$ctrl.form.zValColumn', (value, old) => { + if (old !== undefined) { unsetColumn(old); } + if (value !== undefined) { setColumnRole('zVal', value); } + }); - function setOptionsDefaults() { - if (scope.options) { - // For existing visualization - set default options - defaults(scope.options, extend({}, DEFAULT_OPTIONS, { - showDataLabels: scope.options.globalSeriesType === 'pie', - dateTimeFormat: clientConfig.dateTimeFormat, - })); - } - } - setOptionsDefaults(); - scope.$watch('options', setOptionsDefaults); - - scope.templateHint = ` -
      Use special names to access additional properties:
      -
      {{ @@name }} series name;
      -
      {{ @@x }} x-value;
      -
      {{ @@y }} y-value;
      -
      {{ @@yPercent }} relative y-value;
      -
      {{ @@yError }} y deviation;
      -
      {{ @@size }} bubble size;
      -
      Also, all query result columns can be referenced using - {{ column_name }} syntax.
      - `; - }, - }; -} + $scope.$watch('$ctrl.form.groupby', (value, old) => { + if (old !== undefined) { unsetColumn(old); } + if (value !== undefined) { setColumnRole('series', value); } + }); -const ColorBox = { - bindings: { - color: '<', + $scope.$watch('$ctrl.options', (options) => { + this.onOptionsChange(options); + }, true); + + this.templateHint = ` +
      Use special names to access additional properties:
      +
      {{ @@name }} series name;
      +
      {{ @@x }} x-value;
      +
      {{ @@y }} y-value;
      +
      {{ @@yPercent }} relative y-value;
      +
      {{ @@yError }} y deviation;
      +
      {{ @@size }} bubble size;
      +
      Also, all query result columns can be referenced using + {{ column_name }} syntax.
      + `; }, - template: "", }; export default function init(ngModule) { - ngModule.component('colorBox', ColorBox); - ngModule.directive('chartRenderer', ChartRenderer); - ngModule.directive('chartEditor', ChartEditor); - ngModule.config((VisualizationProvider) => { - const renderTemplate = ''; - const editTemplate = ''; - - VisualizationProvider.registerVisualization({ + ngModule.component('chartRenderer', ChartRenderer); + ngModule.component('chartEditor', ChartEditor); + + ngModule.run(($injector) => { + registerVisualization({ type: 'CHART', name: 'Chart', - renderTemplate, - editorTemplate: editTemplate, - defaultOptions: DEFAULT_OPTIONS, + getOptions: options => merge({}, DEFAULT_OPTIONS, { + showDataLabels: options.globalSeriesType === 'pie', + dateTimeFormat: clientConfig.dateTimeFormat, + }, options), + Renderer: angular2react('chartRenderer', ChartRenderer, $injector), + Editor: angular2react('chartEditor', ChartEditor, $injector), }); }); } -// init.init = true; +init.init = true; diff --git a/client/app/visualizations/chart/plotly/index.js b/client/app/visualizations/chart/plotly/index.js index c2e7172e42..4d0a91d4d0 100644 --- a/client/app/visualizations/chart/plotly/index.js +++ b/client/app/visualizations/chart/plotly/index.js @@ -8,7 +8,6 @@ import box from 'plotly.js/lib/box'; import heatmap from 'plotly.js/lib/heatmap'; import { - ColorPalette, prepareData, prepareLayout, updateData, @@ -134,7 +133,6 @@ const CustomPlotlyChart = clientConfig => ({ }); export default function init(ngModule) { - ngModule.constant('ColorPalette', ColorPalette); ngModule.directive('plotlyChart', PlotlyChart); ngModule.directive('customPlotlyChart', CustomPlotlyChart); } diff --git a/client/app/visualizations/chart/plotly/utils.js b/client/app/visualizations/chart/plotly/utils.js index fdd7982178..ff73a3810f 100644 --- a/client/app/visualizations/chart/plotly/utils.js +++ b/client/app/visualizations/chart/plotly/utils.js @@ -6,36 +6,7 @@ import moment from 'moment'; import d3 from 'd3'; import plotlyCleanNumber from 'plotly.js/src/lib/clean_number'; import { createFormatter, formatSimpleTemplate } from '@/lib/value-format'; - -// The following colors will be used if you pick "Automatic" color. -const BaseColors = { - Blue: '#356AFF', - Red: '#E92828', - Green: '#3BD973', - Purple: '#604FE9', - Cyan: '#50F5ED', - Orange: '#FB8D3D', - 'Light Blue': '#799CFF', - Lilac: '#B554FF', - 'Light Green': '#8CFFB4', - Brown: '#A55F2A', - Black: '#000000', - Gray: '#494949', - Pink: '#FF7DE3', - 'Dark Blue': '#002FB4', -}; - -// Additional colors for the user to choose from: -export const ColorPalette = Object.assign({}, BaseColors, { - 'Indian Red': '#981717', - 'Green 2': '#17BF51', - 'Green 3': '#049235', - DarkTurquoise: '#00B6EB', - 'Dark Violet': '#A58AFF', - 'Pink 2': '#C63FA9', -}); - -const ColorPaletteArray = values(BaseColors); +import { ColorPaletteArray } from '@/visualizations/ColorPalette'; function cleanNumber(value) { return isUndefined(value) ? value : (plotlyCleanNumber(value) || 0.0); diff --git a/client/app/visualizations/choropleth/index.js b/client/app/visualizations/choropleth/index.js index 01b771b0d0..21219e3518 100644 --- a/client/app/visualizations/choropleth/index.js +++ b/client/app/visualizations/choropleth/index.js @@ -6,8 +6,7 @@ import 'leaflet-fullscreen'; import 'leaflet-fullscreen/dist/leaflet.fullscreen.css'; import { angular2react } from 'angular2react'; import { registerVisualization } from '@/visualizations'; - -import { ColorPalette } from '@/visualizations/chart/plotly/utils'; +import ColorPalette from '@/visualizations/ColorPalette'; import { AdditionalColors, diff --git a/client/app/visualizations/funnel/index.js b/client/app/visualizations/funnel/index.js index 0a2da265d9..8e710b3dba 100644 --- a/client/app/visualizations/funnel/index.js +++ b/client/app/visualizations/funnel/index.js @@ -4,7 +4,8 @@ import angular from 'angular'; import { angular2react } from 'angular2react'; import { registerVisualization } from '@/visualizations'; -import { ColorPalette, normalizeValue } from '@/visualizations/chart/plotly/utils'; +import { normalizeValue } from '@/visualizations/chart/plotly/utils'; +import ColorPalette from '@/visualizations/ColorPalette'; import editorTemplate from './funnel-editor.html'; import './funnel.less'; From 8b6e06512ed32465573bfabd531b8d218c06c2ab Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Tue, 5 Mar 2019 13:18:00 +0200 Subject: [PATCH 12/41] Cleanup --- client/app/visualizations/_index.js | 148 ------------------ client/app/visualizations/cohort/index.js | 12 -- .../edit-visualization-dialog.html | 43 ----- .../edit-visualization-dialog.js | 98 ------------ client/app/visualizations/funnel/index.js | 5 +- client/app/visualizations/pivot/index.js | 18 --- client/app/visualizations/sankey/Editor.jsx | 20 +++ client/app/visualizations/sankey/index.js | 30 +--- .../visualizations/sankey/sankey-editor.html | 14 -- client/app/visualizations/sunburst/Editor.jsx | 33 ++++ client/app/visualizations/sunburst/index.js | 19 +-- .../sunburst/sunburst-sequence-editor.html | 23 --- 12 files changed, 59 insertions(+), 404 deletions(-) delete mode 100644 client/app/visualizations/_index.js delete mode 100644 client/app/visualizations/edit-visualization-dialog.html delete mode 100644 client/app/visualizations/edit-visualization-dialog.js create mode 100644 client/app/visualizations/sankey/Editor.jsx delete mode 100644 client/app/visualizations/sankey/sankey-editor.html create mode 100644 client/app/visualizations/sunburst/Editor.jsx delete mode 100644 client/app/visualizations/sunburst/sunburst-sequence-editor.html diff --git a/client/app/visualizations/_index.js b/client/app/visualizations/_index.js deleted file mode 100644 index e8a826de57..0000000000 --- a/client/app/visualizations/_index.js +++ /dev/null @@ -1,148 +0,0 @@ -import moment from 'moment'; -import { isArray, reduce } from 'lodash'; - -function VisualizationProvider() { - this.visualizations = {}; - // this.visualizationTypes = {}; - this.visualizationTypes = []; - const defaultConfig = { - defaultOptions: {}, - skipTypes: false, - editorTemplate: null, - }; - - this.registerVisualization = (config) => { - const visualization = Object.assign({}, defaultConfig, config); - - // TODO: this is prone to errors; better refactor. - if (this.defaultVisualization === undefined && !visualization.name.match(/Deprecated/)) { - this.defaultVisualization = visualization; - } - - this.visualizations[config.type] = visualization; - - if (!config.skipTypes) { - this.visualizationTypes.push({ name: config.name, type: config.type }); - } - }; - - this.getSwitchTemplate = (property) => { - const pattern = /(<[a-zA-Z0-9-]*?)( |>)/; - - let mergedTemplates = reduce( - this.visualizations, - (templates, visualization) => { - if (visualization[property]) { - const ngSwitch = `$1 ng-switch-when="${visualization.type}" $2`; - const template = visualization[property].replace(pattern, ngSwitch); - - return `${templates}\n${template}`; - } - - return templates; - }, - '', - ); - - mergedTemplates = `
      ${mergedTemplates}
      `; - - return mergedTemplates; - }; - - this.$get = ($resource) => { - const Visualization = $resource('api/visualizations/:id', { id: '@id' }); - Visualization.visualizations = this.visualizations; - Visualization.visualizationTypes = this.visualizationTypes; - Visualization.renderVisualizationsTemplate = this.getSwitchTemplate('renderTemplate'); - Visualization.editorTemplate = this.getSwitchTemplate('editorTemplate'); - Visualization.defaultVisualization = this.defaultVisualization; - - return Visualization; - }; -} - -function VisualizationName(Visualization) { - return { - restrict: 'E', - scope: { - visualization: '=', - }, - template: '{{name}}', - replace: false, - link(scope) { - if (Visualization.visualizations[scope.visualization.type]) { - const defaultName = Visualization.visualizations[scope.visualization.type].name; - if (defaultName !== scope.visualization.name) { - scope.name = scope.visualization.name; - } - } - }, - }; -} - -function VisualizationRenderer(Visualization) { - return { - restrict: 'E', - scope: { - visualization: '=', - queryResult: '=', - }, - // TODO: using switch here (and in the options editor) might introduce errors and bad - // performance wise. It's better to eventually show the correct template based on the - // visualization type and not make the browser render all of them. - template: `\n${Visualization.renderVisualizationsTemplate}`, - replace: false, - link(scope) { - scope.$watch('queryResult && queryResult.getFilters()', (filters) => { - if (filters) { - scope.filters = filters; - } - }); - }, - }; -} - -function VisualizationOptionsEditor(Visualization) { - return { - restrict: 'E', - template: Visualization.editorTemplate, - replace: false, - scope: { - visualization: '=', - query: '=', - queryResult: '=', - }, - }; -} - -function FilterValueFilter(clientConfig) { - return (value, filter) => { - let firstValue = value; - if (isArray(value)) { - firstValue = value[0]; - } - - // TODO: deduplicate code with table.js: - if (filter.column.type === 'date') { - if (firstValue && moment.isMoment(firstValue)) { - return firstValue.format(clientConfig.dateFormat); - } - } else if (filter.column.type === 'datetime') { - if (firstValue && moment.isMoment(firstValue)) { - return firstValue.format(clientConfig.dateTimeFormat); - } - } - - return firstValue; - }; -} - -export default function init(ngModule) { - ngModule.provider('Visualization', VisualizationProvider); - ngModule.directive('visualizationRenderer', VisualizationRenderer); - ngModule.directive('visualizationOptionsEditor', VisualizationOptionsEditor); - ngModule.directive('visualizationName', VisualizationName); - ngModule.filter('filterValue', FilterValueFilter); -} - -// init.init = true; diff --git a/client/app/visualizations/cohort/index.js b/client/app/visualizations/cohort/index.js index 60a717207d..cbb8e06ce1 100644 --- a/client/app/visualizations/cohort/index.js +++ b/client/app/visualizations/cohort/index.js @@ -219,18 +219,6 @@ export default function init(ngModule) { Editor: angular2react('cohortEditor', CohortEditor, $injector), }); }); - - // ngModule.config((VisualizationProvider) => { - // const editTemplate = ''; - // - // VisualizationProvider.registerVisualization({ - // type: 'COHORT', - // name: 'Cohort', - // renderTemplate: '', - // editorTemplate: editTemplate, - // defaultOptions: DEFAULT_OPTIONS, - // }); - // }); } init.init = true; diff --git a/client/app/visualizations/edit-visualization-dialog.html b/client/app/visualizations/edit-visualization-dialog.html deleted file mode 100644 index 28791ee2ca..0000000000 --- a/client/app/visualizations/edit-visualization-dialog.html +++ /dev/null @@ -1,43 +0,0 @@ -
      - - - -
      diff --git a/client/app/visualizations/edit-visualization-dialog.js b/client/app/visualizations/edit-visualization-dialog.js deleted file mode 100644 index 227cc983b8..0000000000 --- a/client/app/visualizations/edit-visualization-dialog.js +++ /dev/null @@ -1,98 +0,0 @@ -import { map } from 'lodash'; -import { copy } from 'angular'; -import template from './edit-visualization-dialog.html'; - -const EditVisualizationDialog = { - template, - bindings: { - resolve: '<', - close: '&', - dismiss: '&', - }, - controller($window, currentUser, Events, Visualization, toastr) { - 'ngInject'; - - this.query = this.resolve.query; - this.queryResult = this.resolve.queryResult; - this.originalVisualization = this.resolve.visualization; - this.onNewSuccess = this.resolve.onNewSuccess; - this.visualization = copy(this.originalVisualization); - this.visTypes = Visualization.visualizationTypes; - - // Don't allow to change type after creating visualization - this.canChangeType = !(this.visualization && this.visualization.id); - - this.newVisualization = () => ({ - type: Visualization.defaultVisualization.type, - name: Visualization.defaultVisualization.name, - description: '', - options: Visualization.defaultVisualization.defaultOptions, - }); - if (!this.visualization) { - this.visualization = this.newVisualization(); - } - - this.typeChanged = (oldType) => { - const type = this.visualization.type; - // if not edited by user, set name to match type - // todo: this is wrong, because he might have edited it before. - if (type && oldType !== type && this.visualization && !this.visForm.name.$dirty) { - this.visualization.name = Visualization.visualizations[this.visualization.type].name; - } - - // Bring default options - if (type && oldType !== type && this.visualization) { - this.visualization.options = Visualization.visualizations[this.visualization.type].defaultOptions; - } - }; - - this.submit = () => { - if (this.visualization.id) { - Events.record('update', 'visualization', this.visualization.id, { type: this.visualization.type }); - } else { - Events.record('create', 'visualization', null, { type: this.visualization.type }); - } - - this.visualization.query_id = this.query.id; - - Visualization.save( - this.visualization, - (result) => { - toastr.success('Visualization saved'); - - const visIds = map(this.query.visualizations, i => i.id); - const index = visIds.indexOf(result.id); - if (index > -1) { - this.query.visualizations[index] = result; - } else { - // new visualization - this.query.visualizations.push(result); - if (this.onNewSuccess) { - this.onNewSuccess(result); - } - } - this.close(); - }, - () => { - toastr.error('Visualization could not be saved'); - }, - ); - }; - - this.closeDialog = () => { - if (this.visForm.$dirty) { - if ($window.confirm('Are you sure you want to close the editor without saving?')) { - this.close(); - } - } else { - this.close(); - } - }; - }, -}; - -export default function init(ngModule) { - ngModule.component('editVisualizationDialog', EditVisualizationDialog); -} - -// init.init = true; diff --git a/client/app/visualizations/funnel/index.js b/client/app/visualizations/funnel/index.js index 8e710b3dba..d6a2ad4857 100644 --- a/client/app/visualizations/funnel/index.js +++ b/client/app/visualizations/funnel/index.js @@ -153,10 +153,7 @@ function Funnel(scope, element) { if (!options.autoSort) { colToCheck.push(options.sortKeyCol.colName); } - if (difference(colToCheck, colNames).length > 0) { - return true; - } - return false; + return difference(colToCheck, colNames).length > 0; } function refresh() { diff --git a/client/app/visualizations/pivot/index.js b/client/app/visualizations/pivot/index.js index e5dc731417..70ebdc6200 100644 --- a/client/app/visualizations/pivot/index.js +++ b/client/app/visualizations/pivot/index.js @@ -82,24 +82,6 @@ export default function init(ngModule) { Editor: angular2react('pivotTableEditor', PivotTableEditor, $injector), }); }); - - // ngModule.config((VisualizationProvider) => { - // const editTemplate = ''; - // const defaultOptions = { - // defaultRows: 10, - // defaultColumns: 3, - // minColumns: 2, - // }; - // - // VisualizationProvider.registerVisualization({ - // type: 'PIVOT', - // name: 'Pivot Table', - // renderTemplate: - // '', - // editorTemplate: editTemplate, - // defaultOptions, - // }); - // }); } init.init = true; diff --git a/client/app/visualizations/sankey/Editor.jsx b/client/app/visualizations/sankey/Editor.jsx new file mode 100644 index 0000000000..0e7a4822d4 --- /dev/null +++ b/client/app/visualizations/sankey/Editor.jsx @@ -0,0 +1,20 @@ +import React from 'react'; + +export default function Editor() { + return ( +
      +
      + This visualization expects the query result to have rows in the following format: + +
        +
      • stage1 - stage 1 value
      • +
      • stage2 - stage 2 value (or null)
      • +
      • stage3 - stage 3 value (or null)
      • +
      • stage4 - stage 4 value (or null)
      • +
      • stage5 - stage 5 value (or null)
      • +
      • value - number of times this sequence occurred
      • +
      +
      +
      + ); +} diff --git a/client/app/visualizations/sankey/index.js b/client/app/visualizations/sankey/index.js index fb7927adbb..28f1485d3e 100644 --- a/client/app/visualizations/sankey/index.js +++ b/client/app/visualizations/sankey/index.js @@ -5,7 +5,8 @@ import { angular2react } from 'angular2react'; import { registerVisualization } from '@/visualizations'; import d3sankey from '@/lib/visualizations/d3sankey'; -import editorTemplate from './sankey-editor.html'; + +import Editor from './Editor'; const DEFAULT_OPTIONS = { defaultRows: 7, @@ -257,23 +258,8 @@ const SankeyRenderer = { }, }; -const SankeyEditor = { - template: editorTemplate, - bindings: { - data: '<', - options: '<', - onOptionsChange: '<', - }, - controller($scope) { - $scope.$watch('$ctrl.options', (options) => { - this.onOptionsChange(options); - }, true); - }, -}; - export default function init(ngModule) { ngModule.component('sankeyRenderer', SankeyRenderer); - ngModule.component('sankeyEditor', SankeyEditor); ngModule.run(($injector) => { registerVisualization({ @@ -281,19 +267,9 @@ export default function init(ngModule) { name: 'Sankey', getOptions: options => ({ ...DEFAULT_OPTIONS, ...options }), Renderer: angular2react('sankeyRenderer', SankeyRenderer, $injector), - Editor: angular2react('sankeyEditor', SankeyEditor, $injector), + Editor, }); }); - - // ngModule.config((VisualizationProvider) => { - // VisualizationProvider.registerVisualization({ - // type: 'SANKEY', - // name: 'Sankey', - // renderTemplate: '', - // editorTemplate: '', - // defaultOptions, - // }); - // }); } init.init = true; diff --git a/client/app/visualizations/sankey/sankey-editor.html b/client/app/visualizations/sankey/sankey-editor.html deleted file mode 100644 index c3afad1248..0000000000 --- a/client/app/visualizations/sankey/sankey-editor.html +++ /dev/null @@ -1,14 +0,0 @@ -
      -
      - This visualization expects the query result to have rows in the following format: - -
        -
      • stage1 - stage 1 value
      • -
      • stage2 - stage 2 value (or null)
      • -
      • stage3 - stage 3 value (or null)
      • -
      • stage4 - stage 4 value (or null)
      • -
      • stage5 - stage 5 value (or null)
      • -
      • value - number of times this sequence occurred
      • -
      -
      -
      diff --git a/client/app/visualizations/sunburst/Editor.jsx b/client/app/visualizations/sunburst/Editor.jsx new file mode 100644 index 0000000000..2457b904da --- /dev/null +++ b/client/app/visualizations/sunburst/Editor.jsx @@ -0,0 +1,33 @@ +import React from 'react'; + +export default function Editor() { + return ( +
      +
      + This visualization expects the query result to have rows in one of the following formats: + +
      + Option 1: +
        +
      • sequence - sequence id
      • +
      • stage - what stage in sequence this is (1, 2, ...)
      • +
      • node - stage name
      • +
      • value - number of times this sequence occurred
      • +
      +
      + +
      + Option 2: +
        +
      • stage1 - stage 1 value
      • +
      • stage2 - stage 2 value (or null)
      • +
      • stage3 - stage 3 value (or null)
      • +
      • stage4 - stage 4 value (or null)
      • +
      • stage5 - stage 5 value (or null)
      • +
      • value - number of times this sequence occurred
      • +
      +
      +
      +
      + ); +} diff --git a/client/app/visualizations/sunburst/index.js b/client/app/visualizations/sunburst/index.js index df8f388a71..dfb7f63a49 100644 --- a/client/app/visualizations/sunburst/index.js +++ b/client/app/visualizations/sunburst/index.js @@ -3,7 +3,7 @@ import Sunburst from '@/lib/visualizations/sunburst'; import { angular2react } from 'angular2react'; import { registerVisualization } from '@/visualizations'; -import editorTemplate from './sunburst-sequence-editor.html'; +import Editor from './Editor'; const DEFAULT_OPTIONS = { defaultRows: 7, @@ -28,23 +28,8 @@ const SunburstSequenceRenderer = { }, }; -const SunburstSequenceEditor = { - template: editorTemplate, - bindings: { - data: '<', - options: '<', - onOptionsChange: '<', - }, - controller($scope) { - $scope.$watch('$ctrl.options', (options) => { - this.onOptionsChange(options); - }, true); - }, -}; - export default function init(ngModule) { ngModule.component('sunburstSequenceRenderer', SunburstSequenceRenderer); - ngModule.component('sunburstSequenceEditor', SunburstSequenceEditor); ngModule.run(($injector) => { registerVisualization({ @@ -52,7 +37,7 @@ export default function init(ngModule) { name: 'Sunburst Sequence', getOptions: options => ({ ...DEFAULT_OPTIONS, ...options }), Renderer: angular2react('sunburstSequenceRenderer', SunburstSequenceRenderer, $injector), - Editor: angular2react('sunburstSequenceEditor', SunburstSequenceEditor, $injector), + Editor, }); }); } diff --git a/client/app/visualizations/sunburst/sunburst-sequence-editor.html b/client/app/visualizations/sunburst/sunburst-sequence-editor.html deleted file mode 100644 index 1de8cd8cbe..0000000000 --- a/client/app/visualizations/sunburst/sunburst-sequence-editor.html +++ /dev/null @@ -1,23 +0,0 @@ -
      -
      - This visualization expects the query result to have rows in one of the following formats: - - Option 1: -
        -
      • sequence - sequence id
      • -
      • stage - what stage in sequence this is (1, 2, ...)
      • -
      • node - stage name
      • -
      • value - number of times this sequence occurred
      • -
      - - Option 2: -
        -
      • stage1 - stage 1 value
      • -
      • stage2 - stage 2 value (or null)
      • -
      • stage3 - stage 3 value (or null)
      • -
      • stage4 - stage 4 value (or null)
      • -
      • stage5 - stage 5 value (or null)
      • -
      • value - number of times this sequence occurred
      • -
      -
      -
      From 2c614e8bc017146e07947c89a2ef7eccdbb5575b Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Tue, 5 Mar 2019 13:51:51 +0200 Subject: [PATCH 13/41] Pivot editor --- client/app/visualizations/pivot/Editor.jsx | 29 ++++++++++++++++++++++ client/app/visualizations/pivot/index.js | 20 +++------------ 2 files changed, 32 insertions(+), 17 deletions(-) create mode 100644 client/app/visualizations/pivot/Editor.jsx diff --git a/client/app/visualizations/pivot/Editor.jsx b/client/app/visualizations/pivot/Editor.jsx new file mode 100644 index 0000000000..8014f97370 --- /dev/null +++ b/client/app/visualizations/pivot/Editor.jsx @@ -0,0 +1,29 @@ +import { merge } from 'lodash'; +import React from 'react'; +import Switch from 'antd/lib/switch'; +import { EditorPropTypes } from '@/visualizations'; + +export default function Editor({ options, onOptionsChange }) { + const updateOptions = (updates) => { + onOptionsChange(merge({}, options, updates)); + }; + + return ( +
      +
      +
      + +
      +
      +
      + ); +} + +Editor.propTypes = EditorPropTypes; diff --git a/client/app/visualizations/pivot/index.js b/client/app/visualizations/pivot/index.js index 70ebdc6200..dbc0f0f3cd 100644 --- a/client/app/visualizations/pivot/index.js +++ b/client/app/visualizations/pivot/index.js @@ -6,9 +6,10 @@ import 'pivottable/dist/pivot.css'; import { angular2react } from 'angular2react'; import { registerVisualization } from '@/visualizations'; -import editorTemplate from './pivottable-editor.html'; import './pivot.less'; +import Editor from './Editor'; + const DEFAULT_OPTIONS = { defaultRows: 10, defaultColumns: 3, @@ -55,23 +56,8 @@ const PivotTableRenderer = { }, }; -const PivotTableEditor = { - template: editorTemplate, - bindings: { - data: '<', - options: '<', - onOptionsChange: '<', - }, - controller($scope) { - $scope.$watch('$ctrl.options', (options) => { - this.onOptionsChange(options); - }, true); - }, -}; - export default function init(ngModule) { ngModule.component('pivotTableRenderer', PivotTableRenderer); - ngModule.component('pivotTableEditor', PivotTableEditor); ngModule.run(($injector) => { registerVisualization({ @@ -79,7 +65,7 @@ export default function init(ngModule) { name: 'Pivot Table', getOptions: options => merge({}, DEFAULT_OPTIONS, options), Renderer: angular2react('pivotTableRenderer', PivotTableRenderer, $injector), - Editor: angular2react('pivotTableEditor', PivotTableEditor, $injector), + Editor, }); }); } From b53bbeb8ee40c5ec6a8d2a6b1f71f0ec53d64707 Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Tue, 5 Mar 2019 14:13:19 +0200 Subject: [PATCH 14/41] Fix filters --- .../visualizations/VisualizationRenderer.jsx | 41 ++++++++++++------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/client/app/visualizations/VisualizationRenderer.jsx b/client/app/visualizations/VisualizationRenderer.jsx index 7f1893f310..ae92e89aa5 100644 --- a/client/app/visualizations/VisualizationRenderer.jsx +++ b/client/app/visualizations/VisualizationRenderer.jsx @@ -1,4 +1,4 @@ -import { isArray } from 'lodash'; +import { map, find } from 'lodash'; import React from 'react'; import PropTypes from 'prop-types'; import { react2angular } from 'react2angular'; @@ -6,10 +6,6 @@ import { Filters, FiltersType, filterData } from '@/components/Filters'; import { createPromiseHandler } from '@/lib/utils'; import { registeredVisualizations, VisualizationType } from './index'; -function chooseFilters(globalFilters, localFilters) { - return isArray(globalFilters) && (globalFilters.length > 0) ? globalFilters : localFilters; -} - export class VisualizationRenderer extends React.Component { static propTypes = { visualization: VisualizationType.isRequired, @@ -24,7 +20,7 @@ export class VisualizationRenderer extends React.Component { state = { allRows: [], // eslint-disable-line data: { columns: [], rows: [] }, - filters: this.props.filters, // use global filters by default, if available + filters: [], }; handleQueryResult = createPromiseHandler( @@ -37,17 +33,14 @@ export class VisualizationRenderer extends React.Component { allRows: rows, // eslint-disable-line data: { columns, rows }, }); - this.applyFilters( - // If global filters available, use them, otherwise get new local filters from query - chooseFilters(this.props.filters, queryResult.getFilters()), - ); + this.applyFilters(queryResult.getFilters()); }, ); componentDidUpdate(prevProps) { if (this.props.filters !== prevProps.filters) { - // When global filters changed - apply them instead of local - this.applyFilters(this.props.filters); + // When global filters changed - apply corresponding values to local filters + this.applyFilters(this.state.filters, true); } } @@ -55,7 +48,25 @@ export class VisualizationRenderer extends React.Component { this.handleQueryResult.cancel(); } - applyFilters = (filters) => { + applyFilters(filters, applyGlobals = false) { + // tiny optimization - to avoid unnecessary updates + if ((this.state.filters.length === 0) && (filters.length === 0)) { + return; + } + + if (applyGlobals) { + filters = map(filters, (localFilter) => { + const globalFilter = find(this.props.filters, f => f.name === localFilter.name); + if (globalFilter) { + return { + ...localFilter, + current: globalFilter.current, + }; + } + return localFilter; + }); + } + this.setState(({ allRows, data }) => ({ filters, data: { @@ -63,7 +74,7 @@ export class VisualizationRenderer extends React.Component { rows: filterData(allRows, filters), }, })); - }; + } render() { const { visualization, queryResult } = this.props; @@ -75,7 +86,7 @@ export class VisualizationRenderer extends React.Component { return ( - + this.applyFilters(newFilters)} /> ); From 968b70f4179c6f1b6999b14c6c9d612e4d53d4d0 Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Tue, 5 Mar 2019 17:57:05 +0200 Subject: [PATCH 15/41] Refine code --- client/app/pages/queries/view.js | 11 +-- client/app/services/visualization.js | 9 +++ .../EditVisualizationDialog.jsx | 8 +- client/app/visualizations/index.js | 75 +++++++++++-------- 4 files changed, 60 insertions(+), 43 deletions(-) create mode 100644 client/app/services/visualization.js diff --git a/client/app/pages/queries/view.js b/client/app/pages/queries/view.js index 6ca882afc4..00b33b1bb7 100644 --- a/client/app/pages/queries/view.js +++ b/client/app/pages/queries/view.js @@ -2,9 +2,10 @@ import { pick, some, find, minBy, map, intersection, isArray, isObject } from 'l import { SCHEMA_NOT_SUPPORTED, SCHEMA_LOAD_ERROR } from '@/services/data-source'; import getTags from '@/services/getTags'; import { policy } from '@/services/policy'; +import { Visualization } from '@/services/visualization'; import Notifications from '@/services/notifications'; import ScheduleDialog from '@/components/queries/ScheduleDialog'; -import { Visualization } from '@/visualizations'; +import { newVisualization } from '@/visualizations'; import EditVisualizationDialog from '@/visualizations/EditVisualizationDialog'; import template from './query.html'; @@ -139,13 +140,7 @@ function QueryViewCtrl( $scope.query = $route.current.locals.query; $scope.showPermissionsControl = clientConfig.showPermissionsControl; - $scope.defaultVis = { - type: 'TABLE', - name: 'Table', - options: { - itemsPerPage: 50, - }, - }; + $scope.defaultVis = newVisualization('TABLE', { itemsPerPage: 50 }); const shortcuts = { 'mod+enter': $scope.executeQuery, diff --git a/client/app/services/visualization.js b/client/app/services/visualization.js new file mode 100644 index 0000000000..5b57e1cd2a --- /dev/null +++ b/client/app/services/visualization.js @@ -0,0 +1,9 @@ +export let Visualization = null; // eslint-disable-line import/no-mutable-exports + +export default function init(ngModule) { + ngModule.run(($resource) => { + Visualization = $resource('api/visualizations/:id', { id: '@id' }); + }); +} + +init.init = true; diff --git a/client/app/visualizations/EditVisualizationDialog.jsx b/client/app/visualizations/EditVisualizationDialog.jsx index 2fa8ec9ee0..573bc92de9 100644 --- a/client/app/visualizations/EditVisualizationDialog.jsx +++ b/client/app/visualizations/EditVisualizationDialog.jsx @@ -5,9 +5,13 @@ import Modal from 'antd/lib/modal'; import Select from 'antd/lib/select'; import Input from 'antd/lib/input'; import { wrap as wrapDialog, DialogPropType } from '@/components/DialogWrapper'; -import { Visualization, registeredVisualizations, getDefaultVisualization, newVisualization } from './index'; +import { + VisualizationType, registeredVisualizations, + getDefaultVisualization, newVisualization, +} from './index'; import { toastr } from '@/services/ng'; +import { Visualization } from '@/services/visualization'; import recordEvent from '@/services/recordEvent'; // ANGULAR_REMOVE_ME Remove when all visualizations will be migrated to React @@ -17,7 +21,7 @@ class EditVisualizationDialog extends React.Component { static propTypes = { dialog: DialogPropType.isRequired, query: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types - visualization: PropTypes.object, // eslint-disable-line react/forbid-prop-types + visualization: VisualizationType, queryResult: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types }; diff --git a/client/app/visualizations/index.js b/client/app/visualizations/index.js index 255978e8e5..3518a834be 100644 --- a/client/app/visualizations/index.js +++ b/client/app/visualizations/index.js @@ -1,49 +1,62 @@ import { find } from 'lodash'; import PropTypes from 'prop-types'; -export let Visualization = null; // eslint-disable-line import/no-mutable-exports +/* -------------------------------------------------------- + Types +-----------------------------------------------------------*/ -export const registeredVisualizations = {}; +const VisualizationOptions = PropTypes.object; // eslint-disable-line react/forbid-prop-types -// for `registerVisualization` -export const VisualizationConfig = PropTypes.shape({ - type: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - getOptions: PropTypes.func.isRequired, // (existingOptions: object, data: { columns[], rows[] }) => object - isDeprecated: PropTypes.bool, - Renderer: PropTypes.func.isRequired, - Editor: PropTypes.func, +const Data = PropTypes.shape({ + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + rows: PropTypes.arrayOf(PropTypes.object).isRequired, }); export const VisualizationType = PropTypes.shape({ type: PropTypes.string.isRequired, name: PropTypes.string.isRequired, - options: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types + options: VisualizationOptions.isRequired, // eslint-disable-line react/forbid-prop-types }); // For each visualization's renderer export const RendererPropTypes = { visualizationName: PropTypes.string, - data: PropTypes.shape({ - columns: PropTypes.arrayOf(PropTypes.object).isRequired, - rows: PropTypes.arrayOf(PropTypes.object).isRequired, - }).isRequired, - options: PropTypes.object.isRequired, - onOptionsChange: PropTypes.func, + data: Data.isRequired, + options: VisualizationOptions.isRequired, + onOptionsChange: PropTypes.func, // (newOptions) => void }; // For each visualization's editor export const EditorPropTypes = { visualizationName: PropTypes.string, - data: PropTypes.shape({ - columns: PropTypes.arrayOf(PropTypes.object).isRequired, - rows: PropTypes.arrayOf(PropTypes.object).isRequired, - }).isRequired, - options: PropTypes.object.isRequired, - onOptionsChange: PropTypes.func, + data: Data.isRequired, + options: VisualizationOptions.isRequired, + onOptionsChange: PropTypes.func.isRequired, // (newOptions) => void }; +/* -------------------------------------------------------- + Visualizations registry +-----------------------------------------------------------*/ + +export const registeredVisualizations = {}; + +const VisualizationConfig = PropTypes.shape({ + type: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + getOptions: PropTypes.func.isRequired, // (existingOptions: object, data: { columns[], rows[] }) => object + isDeprecated: PropTypes.bool, + Renderer: PropTypes.func.isRequired, + Editor: PropTypes.func, +}); + +function validateVisualizationConfig(config) { + const typeSpecs = { config: VisualizationConfig }; + const values = { config }; + PropTypes.checkPropTypes(typeSpecs, values, 'prop', 'registerVisualization'); +} + export function registerVisualization(config) { + validateVisualizationConfig(config); config = { ...config }; // clone if (registeredVisualizations[config.type]) { @@ -53,24 +66,20 @@ export function registerVisualization(config) { registeredVisualizations[config.type] = config; } +/* -------------------------------------------------------- + Helpers +-----------------------------------------------------------*/ + export function getDefaultVisualization() { return find(registeredVisualizations, visualization => !visualization.isDeprecated); } -export function newVisualization(type = null) { +export function newVisualization(type = null, options = {}) { const visualization = type ? registeredVisualizations[type] : getDefaultVisualization(); return { type: visualization.type, name: visualization.name, description: '', - options: {}, + options, }; } - -export default function init(ngModule) { - ngModule.run(($resource) => { - Visualization = $resource('api/visualizations/:id', { id: '@id' }); - }); -} - -init.init = true; From ce74b0846e8e04d5f6219c13a2b7c8ccd3aab839 Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Tue, 5 Mar 2019 19:08:28 +0200 Subject: [PATCH 16/41] Avoid infinite sync loop when map bounds updated --- client/app/visualizations/choropleth/index.js | 57 +++++++++++-------- client/app/visualizations/map/index.js | 47 ++++++++++----- 2 files changed, 65 insertions(+), 39 deletions(-) diff --git a/client/app/visualizations/choropleth/index.js b/client/app/visualizations/choropleth/index.js index 21219e3518..e0b2a5e0fa 100644 --- a/client/app/visualizations/choropleth/index.js +++ b/client/app/visualizations/choropleth/index.js @@ -79,23 +79,30 @@ const ChoroplethRenderer = { let countriesData = null; let map = null; let choropleth = null; - let updateBoundsLock = false; - - const getBounds = () => { - if (!updateBoundsLock) { - const bounds = map.getBounds(); - this.options.bounds = [ - [bounds._southWest.lat, bounds._southWest.lng], - [bounds._northEast.lat, bounds._northEast.lng], - ]; - if (this.onOptionsChange) { - this.onOptionsChange(this.options); - } - $scope.$applyAsync(); + let mapMoveLock = false; + + const onMapMoveStart = () => { + mapMoveLock = true; + }; + + const onMapMoveEnd = () => { + const bounds = map.getBounds(); + this.options.bounds = [ + [bounds._southWest.lat, bounds._southWest.lng], + [bounds._northEast.lat, bounds._northEast.lng], + ]; + if (this.onOptionsChange) { + this.onOptionsChange(this.options); } + $scope.$applyAsync(() => { + mapMoveLock = false; + }); }; - const setBounds = ({ disableAnimation = false } = {}) => { + const updateBounds = ({ disableAnimation = false } = {}) => { + if (mapMoveLock) { + return; + } if (map && choropleth) { const bounds = this.options.bounds || choropleth.getBounds(); const options = disableAnimation ? { @@ -190,10 +197,16 @@ const ChoroplethRenderer = { fullscreenControl: true, }); - map.on('focus', () => { map.on('moveend', getBounds); }); - map.on('blur', () => { map.off('moveend', getBounds); }); + map.on('focus', () => { + map.on('movestart', onMapMoveStart); + map.on('moveend', onMapMoveEnd); + }); + map.on('blur', () => { + map.off('movestart', onMapMoveStart); + map.off('moveend', onMapMoveEnd); + }); - setBounds({ disableAnimation: true }); + updateBounds({ disableAnimation: true }); }; loadCountriesData($http, countriesDataUrl).then((data) => { @@ -206,19 +219,13 @@ const ChoroplethRenderer = { $scope.handleResize = _.debounce(() => { if (map) { map.invalidateSize(false); - setBounds({ disableAnimation: true }); + updateBounds({ disableAnimation: true }); } }, 50); $scope.$watch('$ctrl.data', render); $scope.$watch(() => _.omit(this.options, 'bounds'), render, true); - $scope.$watch('$ctrl.options.bounds', () => { - // Prevent infinite digest loop - const savedLock = updateBoundsLock; - updateBoundsLock = true; - setBounds(); - updateBoundsLock = savedLock; - }, true); + $scope.$watch('$ctrl.options.bounds', updateBounds, true); }, }; diff --git a/client/app/visualizations/map/index.js b/client/app/visualizations/map/index.js index 9798287286..ba809b0afc 100644 --- a/client/app/visualizations/map/index.js +++ b/client/app/visualizations/map/index.js @@ -131,14 +131,24 @@ const MapRenderer = { attribution: '© OpenStreetMap contributors', }).addTo(map); - const getBounds = () => { + let mapMoveLock = false; + + const onMapMoveStart = () => { + mapMoveLock = true; + }; + + const onMapMoveEnd = () => { this.options.bounds = map.getBounds(); if (this.onOptionsChange) { this.onOptionsChange(this.options); } }; - const setBounds = () => { + const updateBounds = ({ disableAnimation = false } = {}) => { + if (mapMoveLock) { + return; + } + const b = this.options.bounds; if (b) { @@ -149,19 +159,23 @@ const MapRenderer = { if (allMarkers.length > 0) { // eslint-disable-next-line new-cap const group = new L.featureGroup(allMarkers); - map.fitBounds(group.getBounds()); + const options = disableAnimation ? { + animate: false, + duration: 0, + } : null; + map.fitBounds(group.getBounds(), options); } } }; - map.on('focus', () => { map.on('moveend', getBounds); }); - map.on('blur', () => { map.off('moveend', getBounds); }); - - const resize = () => { - if (!map) return; - map.invalidateSize(false); - setBounds(); - }; + map.on('focus', () => { + map.on('movestart', onMapMoveStart); + map.on('moveend', onMapMoveEnd); + }); + map.on('blur', () => { + map.off('movestart', onMapMoveStart); + map.off('moveend', onMapMoveEnd); + }); const removeLayer = (layer) => { if (layer) { @@ -266,14 +280,19 @@ const MapRenderer = { addLayer(k, v); }); - setBounds(); + updateBounds({ disableAnimation: true }); } }; - $scope.handleResize = resize; + $scope.handleResize = () => { + if (!map) return; + map.invalidateSize(false); + updateBounds({ disableAnimation: true }); + }; $scope.$watch('$ctrl.data', render); - $scope.$watch('$ctrl.options', render, true); + $scope.$watch(() => _.omit(this.options, 'bounds'), render, true); + $scope.$watch('$ctrl.options.bounds', updateBounds, true); }, }; From 34705c70da20c97e219a360200bb6891c9840b57 Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Tue, 5 Mar 2019 19:27:21 +0200 Subject: [PATCH 17/41] Remove redundant code --- client/app/components/Filters.jsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/client/app/components/Filters.jsx b/client/app/components/Filters.jsx index a151ce0917..381ce7e3f7 100644 --- a/client/app/components/Filters.jsx +++ b/client/app/components/Filters.jsx @@ -1,4 +1,4 @@ -import { isArray, map, find, includes, every, some } from 'lodash'; +import { isArray, map, includes, every, some } from 'lodash'; import moment from 'moment'; import React from 'react'; import PropTypes from 'prop-types'; @@ -29,9 +29,8 @@ function createFilterChangeHandler(filters, onChange) { if (filter.multiple && includes(value, NONE_VALUES)) { value = []; } - const allFilters = map(filters, f => (f.name === filter.name ? { ...filter, current: value } : f)); - const changedFilter = find(allFilters, f => f.name === filter.name); - onChange(allFilters, changedFilter); + filters = map(filters, f => (f.name === filter.name ? { ...filter, current: value } : f)); + onChange(filters); }; } From f6c6d80302a4f02993cf6e6d9229f9b642000035 Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Tue, 5 Mar 2019 19:33:47 +0200 Subject: [PATCH 18/41] Optimize Pivot table updates --- client/app/visualizations/pivot/Editor.jsx | 2 +- client/app/visualizations/pivot/index.js | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/client/app/visualizations/pivot/Editor.jsx b/client/app/visualizations/pivot/Editor.jsx index 8014f97370..67bf797422 100644 --- a/client/app/visualizations/pivot/Editor.jsx +++ b/client/app/visualizations/pivot/Editor.jsx @@ -16,7 +16,7 @@ export default function Editor({ options, onOptionsChange }) { Hide Pivot Controls updateOptions({ controls: { enabled } })} /> diff --git a/client/app/visualizations/pivot/index.js b/client/app/visualizations/pivot/index.js index dbc0f0f3cd..dce3b82899 100644 --- a/client/app/visualizations/pivot/index.js +++ b/client/app/visualizations/pivot/index.js @@ -52,7 +52,9 @@ const PivotTableRenderer = { }; $scope.$watch('$ctrl.data', update); - $scope.$watch('$ctrl.options', update, true); + // `options.controls.enabled` is not related to pivot renderer, it's handled by `ng-if`, + // so re-render only if other options changed + $scope.$watch(() => omit(this.options, 'controls'), update, true); }, }; From 3ceea2645a7f28243f1e42738ba7db08e36a6d7f Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Tue, 5 Mar 2019 20:20:55 +0200 Subject: [PATCH 19/41] Word Cloud: editor in React; Box plot: fix rendering, editor in React --- client/app/visualizations/box-plot/Editor.jsx | 43 +++++++++++++++++++ .../box-plot/box-plot-editor.html | 15 ------- client/app/visualizations/box-plot/index.js | 42 +++++++----------- .../app/visualizations/word-cloud/Editor.jsx | 35 +++++++++++++++ client/app/visualizations/word-cloud/index.js | 19 +------- .../word-cloud/word-cloud-editor.html | 8 ---- 6 files changed, 96 insertions(+), 66 deletions(-) create mode 100644 client/app/visualizations/box-plot/Editor.jsx delete mode 100644 client/app/visualizations/box-plot/box-plot-editor.html create mode 100644 client/app/visualizations/word-cloud/Editor.jsx delete mode 100644 client/app/visualizations/word-cloud/word-cloud-editor.html diff --git a/client/app/visualizations/box-plot/Editor.jsx b/client/app/visualizations/box-plot/Editor.jsx new file mode 100644 index 0000000000..3b4313aa88 --- /dev/null +++ b/client/app/visualizations/box-plot/Editor.jsx @@ -0,0 +1,43 @@ +import React from 'react'; +import Input from 'antd/lib/input'; +import { EditorPropTypes } from '@/visualizations'; + +export default function Editor({ options, onOptionsChange }) { + const onXAxisLabelChanged = (xAxisLabel) => { + const newOptions = { ...options, xAxisLabel }; + onOptionsChange(newOptions); + }; + + const onYAxisLabelChanged = (yAxisLabel) => { + const newOptions = { ...options, yAxisLabel }; + onOptionsChange(newOptions); + }; + + return ( +
      +
      + +
      + onXAxisLabelChanged(event.target.value)} + /> +
      +
      + +
      + +
      + onYAxisLabelChanged(event.target.value)} + /> +
      +
      +
      + ); +} + +Editor.propTypes = EditorPropTypes; diff --git a/client/app/visualizations/box-plot/box-plot-editor.html b/client/app/visualizations/box-plot/box-plot-editor.html deleted file mode 100644 index fcd1df4f3c..0000000000 --- a/client/app/visualizations/box-plot/box-plot-editor.html +++ /dev/null @@ -1,15 +0,0 @@ -
      -
      - -
      - -
      -
      - -
      - -
      - -
      -
      -
      diff --git a/client/app/visualizations/box-plot/index.js b/client/app/visualizations/box-plot/index.js index 910e914f83..2794f01ec4 100644 --- a/client/app/visualizations/box-plot/index.js +++ b/client/app/visualizations/box-plot/index.js @@ -3,7 +3,8 @@ import d3 from 'd3'; import { angular2react } from 'angular2react'; import box from '@/lib/visualizations/d3box'; import { registerVisualization } from '@/visualizations'; -import editorTemplate from './box-plot-editor.html'; + +import Editor from './Editor'; const DEFAULT_OPTIONS = { defaultRows: 8, @@ -11,12 +12,14 @@ const DEFAULT_OPTIONS = { }; const BoxPlotRenderer = { - template: '
      ', + template: '
      ', bindings: { data: '<', options: '<', }, controller($scope, $element) { + const container = $element[0].querySelector('div'); + function calcIqr(k) { return (d) => { const q1 = d.quartiles[0]; @@ -36,7 +39,7 @@ const BoxPlotRenderer = { const update = () => { const data = this.data.rows; - const parentWidth = d3.select($element[0].parentNode).node().getBoundingClientRect().width; + const parentWidth = container.offsetWidth; const margin = { top: 10, right: 50, bottom: 40, left: 50, inner: 25, }; @@ -106,24 +109,24 @@ const BoxPlotRenderer = { return xscale(columns[i]) + (xscale(columns[1]) - margin.inner) / 2.0; } - d3.select($element[0]).selectAll('svg').remove(); + d3.select(container).selectAll('*').remove(); - const plot = d3.select($element[0]) - .append('svg') + const svg = d3.select(container).append('svg') .attr('width', parentWidth) - .attr('height', height + margin.bottom + margin.top) - .append('g') + .attr('height', height + margin.bottom + margin.top); + + const plot = svg.append('g') .attr('width', parentWidth - margin.left - margin.right) .attr('transform', `translate(${margin.left},${margin.top})`); - d3.select('svg').append('text') + svg.append('text') .attr('class', 'box') .attr('x', parentWidth / 2.0) .attr('text-anchor', 'middle') .attr('y', height + margin.bottom) .text(xAxisLabel); - d3.select('svg').append('text') + svg.append('text') .attr('class', 'box') .attr('transform', `translate(10,${(height + margin.top + margin.bottom) / 2.0})rotate(-90)`) .attr('text-anchor', 'middle') @@ -160,28 +163,15 @@ const BoxPlotRenderer = { .call(chart); }; + $scope.handleResize = update; + $scope.$watch('$ctrl.data', update); $scope.$watch('$ctrl.options', update, true); }, }; -const BoxPlotEditor = { - template: editorTemplate, - bindings: { - data: '<', - options: '<', - onOptionsChange: '<', - }, - controller($scope) { - $scope.$watch('$ctrl.options', (options) => { - this.onOptionsChange(options); - }, true); - }, -}; - export default function init(ngModule) { ngModule.component('boxplotRenderer', BoxPlotRenderer); - ngModule.component('boxplotEditor', BoxPlotEditor); ngModule.run(($injector) => { registerVisualization({ @@ -190,7 +180,7 @@ export default function init(ngModule) { isDeprecated: true, getOptions: options => ({ ...DEFAULT_OPTIONS, ...options }), Renderer: angular2react('boxplotRenderer', BoxPlotRenderer, $injector), - Editor: angular2react('boxplotEditor', BoxPlotEditor, $injector), + Editor, }); }); } diff --git a/client/app/visualizations/word-cloud/Editor.jsx b/client/app/visualizations/word-cloud/Editor.jsx new file mode 100644 index 0000000000..a215cc54be --- /dev/null +++ b/client/app/visualizations/word-cloud/Editor.jsx @@ -0,0 +1,35 @@ +import { map } from 'lodash'; +import React from 'react'; +import Select from 'antd/lib/select'; +import { EditorPropTypes } from '@/visualizations'; + +const { Option } = Select; + +export default function Editor({ options, data, onOptionsChange }) { + const onColumnChanged = (column) => { + const newOptions = { ...options, column }; + onOptionsChange(newOptions); + }; + + return ( +
      +
      + +
      + +
      +
      +
      + ); +} + +Editor.propTypes = EditorPropTypes; diff --git a/client/app/visualizations/word-cloud/index.js b/client/app/visualizations/word-cloud/index.js index aaa20271a0..0754129657 100644 --- a/client/app/visualizations/word-cloud/index.js +++ b/client/app/visualizations/word-cloud/index.js @@ -4,7 +4,7 @@ import { each } from 'lodash'; import { angular2react } from 'angular2react'; import { registerVisualization } from '@/visualizations'; -import editorTemplate from './word-cloud-editor.html'; +import Editor from './Editor'; const DEFAULT_OPTIONS = { defaultRows: 8, @@ -90,23 +90,8 @@ const WordCloudRenderer = { }, }; -const WordCloudEditor = { - template: editorTemplate, - bindings: { - data: '<', - options: '<', - onOptionsChange: '<', - }, - controller($scope) { - $scope.$watch('$ctrl.options', (options) => { - this.onOptionsChange(options); - }, true); - }, -}; - export default function init(ngModule) { ngModule.component('wordCloudRenderer', WordCloudRenderer); - ngModule.component('wordCloudEditor', WordCloudEditor); ngModule.run(($injector) => { registerVisualization({ @@ -114,7 +99,7 @@ export default function init(ngModule) { name: 'Word Cloud', getOptions: options => ({ ...DEFAULT_OPTIONS, ...options }), Renderer: angular2react('wordCloudRenderer', WordCloudRenderer, $injector), - Editor: angular2react('wordCloudEditor', WordCloudEditor, $injector), + Editor, }); }); } diff --git a/client/app/visualizations/word-cloud/word-cloud-editor.html b/client/app/visualizations/word-cloud/word-cloud-editor.html deleted file mode 100644 index ffe73929a5..0000000000 --- a/client/app/visualizations/word-cloud/word-cloud-editor.html +++ /dev/null @@ -1,8 +0,0 @@ -
      -
      - -
      - -
      -
      -
      From c2ee55bdb98a61346c716906018c9adcbec8bcf4 Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Tue, 5 Mar 2019 20:45:00 +0200 Subject: [PATCH 20/41] EditVisualizationDialog fixes --- .../EditVisualizationDialog.jsx | 60 ++++++++++++------- 1 file changed, 40 insertions(+), 20 deletions(-) diff --git a/client/app/visualizations/EditVisualizationDialog.jsx b/client/app/visualizations/EditVisualizationDialog.jsx index 573bc92de9..4127e5a68e 100644 --- a/client/app/visualizations/EditVisualizationDialog.jsx +++ b/client/app/visualizations/EditVisualizationDialog.jsx @@ -15,7 +15,7 @@ import { Visualization } from '@/services/visualization'; import recordEvent from '@/services/recordEvent'; // ANGULAR_REMOVE_ME Remove when all visualizations will be migrated to React -import { cleanAngularProps } from '@/lib/utils'; +import { cleanAngularProps, createPromiseHandler } from '@/lib/utils'; class EditVisualizationDialog extends React.Component { static propTypes = { @@ -29,40 +29,57 @@ class EditVisualizationDialog extends React.Component { visualization: null, }; + handleQueryResult = createPromiseHandler( + queryResult => queryResult.toPromise(), + () => { + this.setState(({ isNew, type }) => { + const config = registeredVisualizations[type]; + + const { queryResult, visualization } = this.props; + const data = { + columns: queryResult ? queryResult.getColumns() : [], + rows: queryResult ? queryResult.getData() : [], + }; + + const options = config.getOptions(isNew ? {} : visualization.options, data); + + this._originalOptions = cloneDeep(options); + + return { isLoading: false, data, options }; + }); + }, + ); + constructor(props) { super(props); - const { visualization, queryResult } = this.props; + const { visualization } = this.props; const isNew = !visualization; const config = isNew ? getDefaultVisualization() : registeredVisualizations[visualization.type]; - // it's safe to use queryResult right here because while query is running - - // all UI to access this dialog is hidden/disabled - const data = { - columns: queryResult.getColumns(), - rows: queryResult.getData(), - }; - - const options = config.getOptions(isNew ? {} : visualization.options, data); - this.state = { + isLoading: true, + isNew, // eslint-disable-line - data, + data: { columns: [], rows: [] }, + options: {}, type: config.type, canChangeType: isNew, // cannot change type when editing existing visualization name: isNew ? config.name : visualization.name, nameChanged: false, - originalOptions: cloneDeep(options), - options, saveInProgress: false, }; } + componentWillUnmount() { + this.handleQueryResult.cancel(); + } + setVisualizationType(type) { this.setState(({ isNew, name, nameChanged, data }) => { const { visualization } = this.props; @@ -88,9 +105,9 @@ class EditVisualizationDialog extends React.Component { }; dismiss() { - const { nameChanged, options, originalOptions } = this.state; + const { nameChanged, options } = this.state; - const optionsChanged = !isEqual(cleanAngularProps(options), originalOptions); + const optionsChanged = !isEqual(cleanAngularProps(options), this._originalOptions); if (nameChanged || optionsChanged) { Modal.confirm({ title: 'Visualization Editor', @@ -110,10 +127,7 @@ class EditVisualizationDialog extends React.Component { const { query } = this.props; const { type, name, options } = this.state; - const visualization = { - ...extend({}, this.props.visualization), - ...newVisualization(type), - }; + const visualization = extend({}, newVisualization(type), this.props.visualization); visualization.name = name; visualization.options = options; @@ -147,6 +161,12 @@ class EditVisualizationDialog extends React.Component { } render() { + this.handleQueryResult(this.props.queryResult); + + if (this.state.isLoading) { + return null; + } + const { dialog } = this.props; const { type, name, data, options, canChangeType, saveInProgress } = this.state; From 7e28ab52dedb8a72c0312585eb0604406a1b79f2 Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Tue, 5 Mar 2019 21:08:43 +0200 Subject: [PATCH 21/41] Fix ColorBox component --- client/app/components/ColorBox.jsx | 6 +++++- client/app/components/color-box.less | 6 +++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/client/app/components/ColorBox.jsx b/client/app/components/ColorBox.jsx index fa0498bace..73b3f3681e 100644 --- a/client/app/components/ColorBox.jsx +++ b/client/app/components/ColorBox.jsx @@ -9,7 +9,11 @@ export function ColorBox({ color }) { } ColorBox.propTypes = { - color: PropTypes.string.isRequired, + color: PropTypes.string, +}; + +ColorBox.defaultProps = { + color: 'transparent', }; export default function init(ngModule) { diff --git a/client/app/components/color-box.less b/client/app/components/color-box.less index c90ca0bcac..e1027258a4 100644 --- a/client/app/components/color-box.less +++ b/client/app/components/color-box.less @@ -1,8 +1,8 @@ color-box { span { - width: 12px; - height: 12px; - display: inline-block; + width: 12px !important; + height: 12px !important; + display: inline-block !important; margin-right: 5px; } } From 5c35f1a6468b17322373eae35dec04dc99a5755f Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Wed, 6 Mar 2019 12:34:37 +0200 Subject: [PATCH 22/41] Fix Pivot, Sankey and Sunburst errors --- client/app/lib/visualizations/sunburst.js | 5 ++-- client/app/visualizations/pivot/index.js | 3 +++ client/app/visualizations/sankey/index.js | 32 ++++++++++++----------- 3 files changed, 23 insertions(+), 17 deletions(-) diff --git a/client/app/lib/visualizations/sunburst.js b/client/app/lib/visualizations/sunburst.js index 613eae15af..f700295b13 100644 --- a/client/app/lib/visualizations/sunburst.js +++ b/client/app/lib/visualizations/sunburst.js @@ -283,17 +283,18 @@ function Sunburst(scope, element) { values = _.map(grouped, (value) => { const sorted = _.sortBy(value, 'stage'); return { - size: value[0].value, + size: value[0].value || 0, sequence: value[0].sequence, nodes: _.map(sorted, i => i.node), }; }); } else { + // ANGULAR_REMOVE_ME $$ check is for Angular's internal properties const validKey = key => key !== 'value' && key.indexOf('$$') !== 0; const keys = _.sortBy(_.filter(_.keys(raw[0]), validKey), _.identity); values = _.map(raw, (row, sequence) => ({ - size: row.value, + size: row.value || 0, sequence, nodes: _.compact(_.map(keys, key => row[key])), })); diff --git a/client/app/visualizations/pivot/index.js b/client/app/visualizations/pivot/index.js index dce3b82899..a9420002d9 100644 --- a/client/app/visualizations/pivot/index.js +++ b/client/app/visualizations/pivot/index.js @@ -11,6 +11,9 @@ import './pivot.less'; import Editor from './Editor'; const DEFAULT_OPTIONS = { + controls: { + enabled: false, // `false` means "show controls" o_O + }, defaultRows: 10, defaultColumns: 3, minColumns: 2, diff --git a/client/app/visualizations/sankey/index.js b/client/app/visualizations/sankey/index.js index 28f1485d3e..b628d2759e 100644 --- a/client/app/visualizations/sankey/index.js +++ b/client/app/visualizations/sankey/index.js @@ -30,12 +30,13 @@ function graph(data) { const links = {}; const nodes = []; + // ANGULAR_REMOVE_ME $$ check is for Angular's internal properties const validKey = key => key !== 'value' && key.indexOf('$$') !== 0; const keys = _.sortBy(_.filter(_.keys(data[0]), validKey), _.identity); function normalizeName(name) { - if (name) { - return name; + if (!_.isNil(name)) { + return '' + name; } return 'Exit'; @@ -47,8 +48,7 @@ function graph(data) { let node = nodesDict[key]; if (!node) { node = { name }; - const id = nodes.push(node) - 1; - node.id = id; + node.id = nodes.push(node) - 1; nodesDict[key] = node; } return node; @@ -76,10 +76,10 @@ function graph(data) { } data.forEach((row) => { - addLink(row[keys[0]], row[keys[1]], row.value, 1); - addLink(row[keys[1]], row[keys[2]], row.value, 2); - addLink(row[keys[2]], row[keys[3]], row.value, 3); - addLink(row[keys[3]], row[keys[4]], row.value, 4); + addLink(row[keys[0]], row[keys[1]], row.value || 0, 1); + addLink(row[keys[1]], row[keys[2]], row.value || 0, 2); + addLink(row[keys[2]], row[keys[3]], row.value || 0, 3); + addLink(row[keys[3]], row[keys[4]], row.value || 0, 4); }); return { nodes, links: _.values(links) }; @@ -189,12 +189,7 @@ function createSankey(element, data) { if (d === currentNode) { return false; } - - if (_.includes(nodes, d.id)) { - return false; - } - - return true; + return !_.includes(nodes, d.id); }) .style('opacity', 0.2); link @@ -234,6 +229,11 @@ function createSankey(element, data) { .attr('text-anchor', 'start'); } +function isDataValid(data) { + // data should contain column named 'value', otherwise no reason to render anything at all + return _.find(data.columns, c => c.name === 'value'); +} + const SankeyRenderer = { template: '
      ', bindings: { @@ -247,7 +247,9 @@ const SankeyRenderer = { if (this.data) { // do the render logic. angular.element(container).empty(); - createSankey(container, this.data.rows); + if (isDataValid(this.data)) { + createSankey(container, this.data.rows); + } } }; From ab3a7ea6cb0e35869cf455f50dd32669ad564795 Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Wed, 6 Mar 2019 13:12:07 +0200 Subject: [PATCH 23/41] Move grid optsettings from options to visualization config; fix auto-height feature --- .../assets/less/inc/visualizations/misc.less | 2 ++ client/app/services/widget.js | 24 +++++++++---------- client/app/visualizations/box-plot/index.js | 10 ++++---- client/app/visualizations/chart/index.js | 10 ++++---- client/app/visualizations/choropleth/index.js | 8 +++---- client/app/visualizations/cohort/index.js | 6 ++--- client/app/visualizations/counter/index.js | 5 ++-- client/app/visualizations/funnel/index.js | 3 ++- client/app/visualizations/index.js | 9 +++++++ client/app/visualizations/map/index.js | 7 +++--- client/app/visualizations/pivot/index.js | 7 +++--- client/app/visualizations/sankey/index.js | 8 +++---- client/app/visualizations/sunburst/index.js | 8 +++---- client/app/visualizations/table/index.js | 9 +++---- client/app/visualizations/word-cloud/index.js | 8 +++---- 15 files changed, 65 insertions(+), 59 deletions(-) diff --git a/client/app/assets/less/inc/visualizations/misc.less b/client/app/assets/less/inc/visualizations/misc.less index d439837db0..cc5600bd16 100644 --- a/client/app/assets/less/inc/visualizations/misc.less +++ b/client/app/assets/less/inc/visualizations/misc.less @@ -1,4 +1,6 @@ visualization-renderer { + display: block; + .pagination, .ant-pagination { margin: 0; diff --git a/client/app/services/widget.js b/client/app/services/widget.js index 1a131bbf90..089b9000a7 100644 --- a/client/app/services/widget.js +++ b/client/app/services/widget.js @@ -17,45 +17,43 @@ function calculatePositionOptions(dashboardGridOptions, widget) { maxSizeY: dashboardGridOptions.maxSizeY, }; - const visualization = widget.visualization ? registeredVisualizations[widget.visualization.type] : null; - if (isObject(visualization)) { - const options = extend({}, visualization.getOptions({}, { columns: [], rows: [] })); - - if (Object.prototype.hasOwnProperty.call(options, 'autoHeight')) { - visualizationOptions.autoHeight = options.autoHeight; + const config = widget.visualization ? registeredVisualizations[widget.visualization.type] : null; + if (isObject(config)) { + if (Object.prototype.hasOwnProperty.call(config, 'autoHeight')) { + visualizationOptions.autoHeight = config.autoHeight; } // Width constraints - const minColumns = parseInt(options.minColumns, 10); + const minColumns = parseInt(config.minColumns, 10); if (isFinite(minColumns) && minColumns >= 0) { visualizationOptions.minSizeX = minColumns; } - const maxColumns = parseInt(options.maxColumns, 10); + const maxColumns = parseInt(config.maxColumns, 10); if (isFinite(maxColumns) && maxColumns >= 0) { visualizationOptions.maxSizeX = Math.min(maxColumns, dashboardGridOptions.columns); } // Height constraints // `minRows` is preferred, but it should be kept for backward compatibility - const height = parseInt(options.height, 10); + const height = parseInt(config.height, 10); if (isFinite(height)) { visualizationOptions.minSizeY = Math.ceil(height / dashboardGridOptions.rowHeight); } - const minRows = parseInt(options.minRows, 10); + const minRows = parseInt(config.minRows, 10); if (isFinite(minRows)) { visualizationOptions.minSizeY = minRows; } - const maxRows = parseInt(options.maxRows, 10); + const maxRows = parseInt(config.maxRows, 10); if (isFinite(maxRows) && maxRows >= 0) { visualizationOptions.maxSizeY = maxRows; } // Default dimensions - const defaultWidth = parseInt(options.defaultColumns, 10); + const defaultWidth = parseInt(config.defaultColumns, 10); if (isFinite(defaultWidth) && defaultWidth > 0) { visualizationOptions.sizeX = defaultWidth; } - const defaultHeight = parseInt(options.defaultRows, 10); + const defaultHeight = parseInt(config.defaultRows, 10); if (isFinite(defaultHeight) && defaultHeight > 0) { visualizationOptions.sizeY = defaultHeight; } diff --git a/client/app/visualizations/box-plot/index.js b/client/app/visualizations/box-plot/index.js index 2794f01ec4..26d1947002 100644 --- a/client/app/visualizations/box-plot/index.js +++ b/client/app/visualizations/box-plot/index.js @@ -6,11 +6,6 @@ import { registerVisualization } from '@/visualizations'; import Editor from './Editor'; -const DEFAULT_OPTIONS = { - defaultRows: 8, - minRows: 5, -}; - const BoxPlotRenderer = { template: '
      ', bindings: { @@ -178,9 +173,12 @@ export default function init(ngModule) { type: 'BOXPLOT', name: 'Boxplot (Deprecated)', isDeprecated: true, - getOptions: options => ({ ...DEFAULT_OPTIONS, ...options }), + getOptions: options => ({ ...options }), Renderer: angular2react('boxplotRenderer', BoxPlotRenderer, $injector), Editor, + + defaultRows: 8, + minRows: 5, }); }); } diff --git a/client/app/visualizations/chart/index.js b/client/app/visualizations/chart/index.js index 514be21592..2486708eaa 100644 --- a/client/app/visualizations/chart/index.js +++ b/client/app/visualizations/chart/index.js @@ -26,11 +26,6 @@ const DEFAULT_OPTIONS = { percentFormat: '0[.]00%', // dateTimeFormat: 'DD/MM/YYYY HH:mm', // will be set from clientConfig textFormat: '', // default: combination of {{ @@yPercent }} ({{ @@y }} ± {{ @@yError }}) - - defaultColumns: 3, - defaultRows: 8, - minColumns: 1, - minRows: 5, }; function initEditorForm(options, columns) { @@ -316,6 +311,11 @@ export default function init(ngModule) { }, options), Renderer: angular2react('chartRenderer', ChartRenderer, $injector), Editor: angular2react('chartEditor', ChartEditor, $injector), + + defaultColumns: 3, + defaultRows: 8, + minColumns: 1, + minRows: 5, }); }); } diff --git a/client/app/visualizations/choropleth/index.js b/client/app/visualizations/choropleth/index.js index e0b2a5e0fa..d5dd238178 100644 --- a/client/app/visualizations/choropleth/index.js +++ b/client/app/visualizations/choropleth/index.js @@ -28,10 +28,6 @@ import countriesDataUrl from './countries.geo.json'; export const ChoroplethPalette = _.extend({}, AdditionalColors, ColorPalette); const DEFAULT_OPTIONS = { - defaultColumns: 3, - defaultRows: 8, - minColumns: 2, - countryCodeColumn: '', countryCodeType: 'iso_a3', valueColumn: '', @@ -306,6 +302,10 @@ export default function init(ngModule) { getOptions: options => _.merge({}, DEFAULT_OPTIONS, options), Renderer: angular2react('choroplethRenderer', ChoroplethRenderer, $injector), Editor: angular2react('choroplethEditor', ChoroplethEditor, $injector), + + defaultColumns: 3, + defaultRows: 8, + minColumns: 2, }); }); } diff --git a/client/app/visualizations/cohort/index.js b/client/app/visualizations/cohort/index.js index cbb8e06ce1..764517c0d1 100644 --- a/client/app/visualizations/cohort/index.js +++ b/client/app/visualizations/cohort/index.js @@ -21,9 +21,6 @@ const DEFAULT_OPTIONS = { stageColumn: 'day_number', totalColumn: 'total', valueColumn: 'value', - - autoHeight: true, - defaultRows: 8, }; function groupData(sortedData) { @@ -217,6 +214,9 @@ export default function init(ngModule) { getOptions: options => ({ ...DEFAULT_OPTIONS, ...options }), Renderer: angular2react('cohortRenderer', CohortRenderer, $injector), Editor: angular2react('cohortEditor', CohortEditor, $injector), + + autoHeight: true, + defaultRows: 8, }); }); } diff --git a/client/app/visualizations/counter/index.js b/client/app/visualizations/counter/index.js index e3a832a7f4..3b48321cb5 100644 --- a/client/app/visualizations/counter/index.js +++ b/client/app/visualizations/counter/index.js @@ -13,8 +13,6 @@ const DEFAULT_OPTIONS = { stringDecimal: 0, stringDecChar: '.', stringThouSep: ',', - defaultColumns: 2, - defaultRows: 5, }; // TODO: Need to review this function, it does not properly handle edge cases. @@ -158,6 +156,9 @@ export default function init(ngModule) { getOptions: options => ({ ...DEFAULT_OPTIONS, ...options }), Renderer: angular2react('counterRenderer', CounterRenderer, $injector), Editor: angular2react('counterEditor', CounterEditor, $injector), + + defaultColumns: 2, + defaultRows: 5, }); }); } diff --git a/client/app/visualizations/funnel/index.js b/client/app/visualizations/funnel/index.js index d6a2ad4857..17d85b8a60 100644 --- a/client/app/visualizations/funnel/index.js +++ b/client/app/visualizations/funnel/index.js @@ -14,7 +14,6 @@ const DEFAULT_OPTIONS = { valueCol: { colName: '', displayAs: 'Value' }, sortKeyCol: { colName: '' }, autoSort: true, - defaultRows: 10, }; function normalizePercentage(num) { @@ -228,6 +227,8 @@ export default function init(ngModule) { getOptions: options => merge({}, DEFAULT_OPTIONS, options), Renderer: angular2react('funnelRenderer', FunnelRenderer, $injector), Editor: angular2react('funnelEditor', FunnelEditor, $injector), + + defaultRows: 10, }); }); } diff --git a/client/app/visualizations/index.js b/client/app/visualizations/index.js index 3518a834be..faad2d6e48 100644 --- a/client/app/visualizations/index.js +++ b/client/app/visualizations/index.js @@ -47,6 +47,15 @@ const VisualizationConfig = PropTypes.shape({ isDeprecated: PropTypes.bool, Renderer: PropTypes.func.isRequired, Editor: PropTypes.func, + + // other config options + autoHeight: PropTypes.bool, + defaultRows: PropTypes.number, + defaultColumns: PropTypes.number, + minRows: PropTypes.number, + maxRows: PropTypes.number, + minColumns: PropTypes.number, + maxColumns: PropTypes.number, }); function validateVisualizationConfig(config) { diff --git a/client/app/visualizations/map/index.js b/client/app/visualizations/map/index.js index ba809b0afc..96268107d8 100644 --- a/client/app/visualizations/map/index.js +++ b/client/app/visualizations/map/index.js @@ -77,9 +77,6 @@ const MAP_TILES = [ ]; const DEFAULT_OPTIONS = { - defaultColumns: 3, - defaultRows: 8, - minColumns: 2, classify: 'none', clusterMarkers: true, }; @@ -334,6 +331,10 @@ export default function init(ngModule) { getOptions: options => _.merge({}, DEFAULT_OPTIONS, options), Renderer: angular2react('mapRenderer', MapRenderer, $injector), Editor: angular2react('mapEditor', MapEditor, $injector), + + defaultColumns: 3, + defaultRows: 8, + minColumns: 2, }); }); } diff --git a/client/app/visualizations/pivot/index.js b/client/app/visualizations/pivot/index.js index a9420002d9..6c3f45ac88 100644 --- a/client/app/visualizations/pivot/index.js +++ b/client/app/visualizations/pivot/index.js @@ -14,9 +14,6 @@ const DEFAULT_OPTIONS = { controls: { enabled: false, // `false` means "show controls" o_O }, - defaultRows: 10, - defaultColumns: 3, - minColumns: 2, }; const PivotTableRenderer = { @@ -71,6 +68,10 @@ export default function init(ngModule) { getOptions: options => merge({}, DEFAULT_OPTIONS, options), Renderer: angular2react('pivotTableRenderer', PivotTableRenderer, $injector), Editor, + + defaultRows: 10, + defaultColumns: 3, + minColumns: 2, }); }); } diff --git a/client/app/visualizations/sankey/index.js b/client/app/visualizations/sankey/index.js index b628d2759e..893b9c2f82 100644 --- a/client/app/visualizations/sankey/index.js +++ b/client/app/visualizations/sankey/index.js @@ -8,10 +8,6 @@ import d3sankey from '@/lib/visualizations/d3sankey'; import Editor from './Editor'; -const DEFAULT_OPTIONS = { - defaultRows: 7, -}; - function getConnectedNodes(node) { // source link = this node is the source, I need the targets const nodes = []; @@ -267,9 +263,11 @@ export default function init(ngModule) { registerVisualization({ type: 'SANKEY', name: 'Sankey', - getOptions: options => ({ ...DEFAULT_OPTIONS, ...options }), + getOptions: options => ({ ...options }), Renderer: angular2react('sankeyRenderer', SankeyRenderer, $injector), Editor, + + defaultRows: 7, }); }); } diff --git a/client/app/visualizations/sunburst/index.js b/client/app/visualizations/sunburst/index.js index dfb7f63a49..d747eae6ce 100644 --- a/client/app/visualizations/sunburst/index.js +++ b/client/app/visualizations/sunburst/index.js @@ -5,10 +5,6 @@ import { registerVisualization } from '@/visualizations'; import Editor from './Editor'; -const DEFAULT_OPTIONS = { - defaultRows: 7, -}; - const SunburstSequenceRenderer = { template: '
      ', bindings: { @@ -35,9 +31,11 @@ export default function init(ngModule) { registerVisualization({ type: 'SUNBURST_SEQUENCE', name: 'Sunburst Sequence', - getOptions: options => ({ ...DEFAULT_OPTIONS, ...options }), + getOptions: options => ({ ...options }), Renderer: angular2react('sunburstSequenceRenderer', SunburstSequenceRenderer, $injector), Editor, + + defaultRows: 7, }); }); } diff --git a/client/app/visualizations/table/index.js b/client/app/visualizations/table/index.js index 8e3a0ec2d6..c80921a118 100644 --- a/client/app/visualizations/table/index.js +++ b/client/app/visualizations/table/index.js @@ -22,10 +22,6 @@ const DISPLAY_AS_OPTIONS = [ const DEFAULT_OPTIONS = { itemsPerPage: 25, - autoHeight: true, - defaultRows: 14, - defaultColumns: 3, - minColumns: 2, }; function getColumnContentAlignment(type) { @@ -206,6 +202,11 @@ export default function init(ngModule) { }, Renderer: angular2react('gridRenderer', GridRenderer, $injector), Editor: angular2react('gridEditor', GridEditor, $injector), + + autoHeight: true, + defaultRows: 14, + defaultColumns: 3, + minColumns: 2, }); }); } diff --git a/client/app/visualizations/word-cloud/index.js b/client/app/visualizations/word-cloud/index.js index 0754129657..3e27d896dd 100644 --- a/client/app/visualizations/word-cloud/index.js +++ b/client/app/visualizations/word-cloud/index.js @@ -6,10 +6,6 @@ import { registerVisualization } from '@/visualizations'; import Editor from './Editor'; -const DEFAULT_OPTIONS = { - defaultRows: 8, -}; - function findWordFrequencies(data, columnName) { const wordsHash = {}; @@ -97,9 +93,11 @@ export default function init(ngModule) { registerVisualization({ type: 'WORD_CLOUD', name: 'Word Cloud', - getOptions: options => ({ ...DEFAULT_OPTIONS, ...options }), + getOptions: options => ({ ...options }), Renderer: angular2react('wordCloudRenderer', WordCloudRenderer, $injector), Editor, + + defaultRows: 8, }); }); } From c63f48149a00f908e34b4ba7b3901f985caf16dc Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Wed, 6 Mar 2019 13:27:24 +0200 Subject: [PATCH 24/41] Fix Sunburst error; fix editors --- client/app/lib/visualizations/sunburst.js | 1 + client/app/visualizations/box-plot/Editor.jsx | 34 ++++++++----------- .../app/visualizations/word-cloud/Editor.jsx | 28 +++++++-------- 3 files changed, 28 insertions(+), 35 deletions(-) diff --git a/client/app/lib/visualizations/sunburst.js b/client/app/lib/visualizations/sunburst.js index f700295b13..2a5f1ffb36 100644 --- a/client/app/lib/visualizations/sunburst.js +++ b/client/app/lib/visualizations/sunburst.js @@ -334,6 +334,7 @@ function Sunburst(scope, element) { let childNode = _.find(children, child => child.name === nodeName); if (isLeaf && childNode) { + childNode.children = childNode.children || []; childNode.children.push({ name: exitNode, size, diff --git a/client/app/visualizations/box-plot/Editor.jsx b/client/app/visualizations/box-plot/Editor.jsx index 3b4313aa88..d0b25ee777 100644 --- a/client/app/visualizations/box-plot/Editor.jsx +++ b/client/app/visualizations/box-plot/Editor.jsx @@ -14,27 +14,23 @@ export default function Editor({ options, onOptionsChange }) { }; return ( -
      -
      - -
      - onXAxisLabelChanged(event.target.value)} - /> -
      +
      +
      + + onXAxisLabelChanged(event.target.value)} + />
      -
      - -
      - onYAxisLabelChanged(event.target.value)} - /> -
      +
      + + onYAxisLabelChanged(event.target.value)} + />
      ); diff --git a/client/app/visualizations/word-cloud/Editor.jsx b/client/app/visualizations/word-cloud/Editor.jsx index a215cc54be..3499ca603a 100644 --- a/client/app/visualizations/word-cloud/Editor.jsx +++ b/client/app/visualizations/word-cloud/Editor.jsx @@ -12,22 +12,18 @@ export default function Editor({ options, data, onOptionsChange }) { }; return ( -
      -
      - -
      - -
      -
      +
      + +
      ); } From 46e2a5fb982cf02ffb89e873f6b9f218e757a51b Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Thu, 7 Mar 2019 14:58:44 +0200 Subject: [PATCH 25/41] Fix Word Cloud rendering bug --- client/app/visualizations/word-cloud/index.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/app/visualizations/word-cloud/index.js b/client/app/visualizations/word-cloud/index.js index 3e27d896dd..170f3ece32 100644 --- a/client/app/visualizations/word-cloud/index.js +++ b/client/app/visualizations/word-cloud/index.js @@ -30,6 +30,8 @@ const WordCloudRenderer = { options: '<', }, controller($scope, $element) { + $element[0].style.display = 'block'; + const update = () => { const data = this.data.rows; const options = this.options; @@ -54,11 +56,9 @@ const WordCloudRenderer = { .fontSize(d => d.size); function draw(words) { - d3.select($element[0].parentNode) - .select('svg') - .remove(); + d3.select($element[0]).selectAll('*').remove(); - d3.select($element[0].parentNode) + d3.select($element[0]) .append('svg') .attr('width', layout.size()[0]) .attr('height', layout.size()[1]) From cc47139ae693b84ba041706f87c94bdce6179293 Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Thu, 7 Mar 2019 20:08:04 +0200 Subject: [PATCH 26/41] Word Cloud: predictive words sizes --- client/app/visualizations/word-cloud/index.js | 46 +++++++++++++++++-- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/client/app/visualizations/word-cloud/index.js b/client/app/visualizations/word-cloud/index.js index 170f3ece32..161d35ec58 100644 --- a/client/app/visualizations/word-cloud/index.js +++ b/client/app/visualizations/word-cloud/index.js @@ -1,6 +1,6 @@ import d3 from 'd3'; import cloud from 'd3-cloud'; -import { each } from 'lodash'; +import { map, min, max, values } from 'lodash'; import { angular2react } from 'angular2react'; import { registerVisualization } from '@/visualizations'; @@ -23,6 +23,40 @@ function findWordFrequencies(data, columnName) { return wordsHash; } +// target domain: [t1, t2] +const MIN_WORD_SIZE = 10; +const MAX_WORD_SIZE = 100; + +function createScale(wordCounts) { + wordCounts = values(wordCounts); + + // source domain: [s1, s2] + const minCount = min(wordCounts); + const maxCount = max(wordCounts); + + // v is value from source domain: + // s1 <= v <= s2. + // We need to fit it target domain: + // t1 <= v" <= t2 + // 1. offset source value to zero point: + // v' = v - s1 + // 2. offset source and target domains to zero point: + // s' = s2 - s1 + // t' = t2 - t1 + // 3. compute fraction: + // f = v' / s'; + // 0 <= f <= 1 + // 4. map f to target domain: + // v" = f * t' + t1; + // t1 <= v" <= t' + t1; + // t1 <= v" <= t2 (because t' = t2 - t1, so t2 = t' + t1) + + const sourceScale = maxCount - minCount; + const targetScale = MAX_WORD_SIZE - MIN_WORD_SIZE; + + return value => ((value - minCount) / sourceScale) * targetScale + MIN_WORD_SIZE; +} + const WordCloudRenderer = { restrict: 'E', bindings: { @@ -41,10 +75,12 @@ const WordCloudRenderer = { wordsHash = findWordFrequencies(data, options.column); } - const wordList = []; - each(wordsHash, (v, key) => { - wordList.push({ text: key, size: 10 + Math.pow(v, 2) }); - }); + const scaleValue = createScale(wordsHash); + + const wordList = map(wordsHash, (count, key) => ({ + text: key, + size: scaleValue(count), + })); const fill = d3.scale.category20(); const layout = cloud() From bfd92c470390571dd6d21c43ed0cbb2ddd895d29 Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Sun, 10 Mar 2019 13:00:37 +0200 Subject: [PATCH 27/41] Visualization Editor improvements --- .../EditVisualizationDialog.jsx | 50 +++++++++++-------- client/app/visualizations/funnel/index.js | 8 ++- client/app/visualizations/pivot/Editor.jsx | 22 ++++---- 3 files changed, 40 insertions(+), 40 deletions(-) diff --git a/client/app/visualizations/EditVisualizationDialog.jsx b/client/app/visualizations/EditVisualizationDialog.jsx index 4127e5a68e..114dd34cc6 100644 --- a/client/app/visualizations/EditVisualizationDialog.jsx +++ b/client/app/visualizations/EditVisualizationDialog.jsx @@ -4,12 +4,8 @@ import PropTypes from 'prop-types'; import Modal from 'antd/lib/modal'; import Select from 'antd/lib/select'; import Input from 'antd/lib/input'; +import * as Grid from 'antd/lib/grid'; import { wrap as wrapDialog, DialogPropType } from '@/components/DialogWrapper'; -import { - VisualizationType, registeredVisualizations, - getDefaultVisualization, newVisualization, -} from './index'; - import { toastr } from '@/services/ng'; import { Visualization } from '@/services/visualization'; import recordEvent from '@/services/recordEvent'; @@ -17,6 +13,11 @@ import recordEvent from '@/services/recordEvent'; // ANGULAR_REMOVE_ME Remove when all visualizations will be migrated to React import { cleanAngularProps, createPromiseHandler } from '@/lib/utils'; +import { + VisualizationType, registeredVisualizations, + getDefaultVisualization, newVisualization, +} from './index'; + class EditVisualizationDialog extends React.Component { static propTypes = { dialog: DialogPropType.isRequired, @@ -101,7 +102,12 @@ class EditVisualizationDialog extends React.Component { } setVisualizationOptions = (options) => { - this.setState({ options: extend({}, options) }); + this.setState(({ type, data }) => { + const config = registeredVisualizations[type]; + return { + options: config.getOptions(options, data), + }; + }); }; dismiss() { @@ -170,9 +176,7 @@ class EditVisualizationDialog extends React.Component { const { dialog } = this.props; const { type, name, data, options, canChangeType, saveInProgress } = this.state; - const { Renderer, Editor, getOptions } = registeredVisualizations[type]; - - const previewOptions = getOptions(options, data); + const { Renderer, Editor } = registeredVisualizations[type]; return ( this.save()} onCancel={() => this.dismiss()} > -
      -
      + +
      this.setVisualizationType(t)} - > - {map( - registeredVisualizations, - vis => {vis.name}, - )} - -
      -
      - - this.setVisualizationName(event.target.value)} - /> -
      -
      - -
      -
      - - -
      - -
      -
      -
      +
      + + +
      + + +
      +
      + + this.setVisualizationName(event.target.value)} + /> +
      +
      + +
      +
      + + +
      + +
      +
      +
      +
      ); } diff --git a/client/cypress/integration/visualizations/edit_visualization_dialog_spec.js b/client/cypress/integration/visualizations/edit_visualization_dialog_spec.js new file mode 100644 index 0000000000..45255855ec --- /dev/null +++ b/client/cypress/integration/visualizations/edit_visualization_dialog_spec.js @@ -0,0 +1,44 @@ +function addQueryByAPI(data, shouldPublish = true) { + const merged = Object.assign({ + name: 'Test Query', + query: 'select 1', + data_source_id: 1, + options: { + parameters: [], + }, + schedule: null, + }, data); + + const request = cy.request('POST', '/api/queries', merged); + if (shouldPublish) { + request.then(({ body }) => cy.request('POST', `/api/queries/${body.id}`, { is_draft: false })); + } + + return request.then(({ body }) => body); +} + +describe('Edit visualization dialog', () => { + beforeEach(() => { + cy.login(); + addQueryByAPI().then(({ id }) => { + cy.visit(`queries/${id}/source`); + cy.getByTestId('ExecuteButton').click(); + }); + }); + + it('opens New Visualization dialog', () => { + cy.getByTestId('NewVisualization').should('exist').click(); + cy.getByTestId('EditVisualizationDialog').should('exist'); + // Default visualization should be selected + cy.getByTestId('VisualizationType').should('exist').should('contain', 'Chart'); + cy.getByTestId('VisualizationName').should('exist').should('have.value', 'Chart'); + }); + + it('opens Edit Visualization dialog', () => { + cy.getByTestId('EditVisualization').should('exist').click(); + cy.getByTestId('EditVisualizationDialog').should('exist'); + // Default visualization should be selected + cy.getByTestId('VisualizationType').should('exist').should('contain', 'Table'); + cy.getByTestId('VisualizationName').should('exist').should('have.value', 'Table'); + }); +}); From 0b76bd5acb9f2fbab61a5fef2c3fb5796ff6d3ae Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Mon, 25 Mar 2019 19:28:51 +0200 Subject: [PATCH 30/41] Tests: Sunburst, Sankey --- client/app/pages/queries/query.html | 2 +- .../EditVisualizationDialog.jsx | 101 +++++++++--------- .../edit_visualization_dialog_spec.js | 2 +- .../visualizations/sankey_sunburst_spec.js | 64 +++++++++++ 4 files changed, 116 insertions(+), 53 deletions(-) create mode 100644 client/cypress/integration/visualizations/sankey_sunburst_spec.js diff --git a/client/app/pages/queries/query.html b/client/app/pages/queries/query.html index 183ed9bd6f..6af4011fed 100644 --- a/client/app/pages/queries/query.html +++ b/client/app/pages/queries/query.html @@ -226,7 +226,7 @@

      Log Information:

      {{l}}

      -
        +
      diff --git a/client/cypress/integration/visualizations/sankey_sunburst_spec.js b/client/cypress/integration/visualizations/sankey_sunburst_spec.js index cdbd774230..4c43c56bfd 100644 --- a/client/cypress/integration/visualizations/sankey_sunburst_spec.js +++ b/client/cypress/integration/visualizations/sankey_sunburst_spec.js @@ -28,7 +28,7 @@ describe('Sankey and Sunburst', () => { cy.getByTestId('NewVisualization').click(); cy.getByTestId('VisualizationType').click(); - cy.contains('li', 'Sunburst').click(); + cy.getByTestId('VisualizationType.SUNBURST_SEQUENCE').click(); cy.getByTestId('VisualizationName').clear().type(visualizationName); cy.getByTestId('VisualizationPreview').find('svg').should('exist'); cy.getByTestId('EditVisualizationDialog').contains('button', 'Save').click(); @@ -40,7 +40,7 @@ describe('Sankey and Sunburst', () => { cy.getByTestId('NewVisualization').click(); cy.getByTestId('VisualizationType').click(); - cy.contains('li', 'Sankey').click(); + cy.getByTestId('VisualizationType.SANKEY').click(); cy.getByTestId('VisualizationName').clear().type(visualizationName); cy.getByTestId('VisualizationPreview').find('svg').should('exist'); cy.getByTestId('EditVisualizationDialog').contains('button', 'Save').click(); From aa816628d1f5b1904d5cd1d0fcbaacd4552d3b42 Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Wed, 27 Mar 2019 10:22:08 +0200 Subject: [PATCH 34/41] Tests: Pivot --- client/app/pages/queries/query.html | 3 +- client/app/visualizations/pivot/Editor.jsx | 1 + client/app/visualizations/pivot/pivot.less | 2 +- .../edit_visualization_dialog_spec.js | 2 + .../integration/visualizations/pivot_spec.js | 69 +++++++++++++++++++ .../visualizations/sankey_sunburst_spec.js | 2 + 6 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 client/cypress/integration/visualizations/pivot_spec.js diff --git a/client/app/pages/queries/query.html b/client/app/pages/queries/query.html index 6af4011fed..de2090ff63 100644 --- a/client/app/pages/queries/query.html +++ b/client/app/pages/queries/query.html @@ -242,7 +242,8 @@

      -
      +
      diff --git a/client/app/visualizations/pivot/Editor.jsx b/client/app/visualizations/pivot/Editor.jsx index 710ed596b8..3538b07af4 100644 --- a/client/app/visualizations/pivot/Editor.jsx +++ b/client/app/visualizations/pivot/Editor.jsx @@ -13,6 +13,7 @@ export default function Editor({ options, onOptionsChange }) {
        - + @@ -238,13 +238,8 @@

        New Visualization

      -
      - -
      - -
      - +
      +
      diff --git a/client/app/pages/queries/view.js b/client/app/pages/queries/view.js index 5c96b345f5..85d28547ba 100644 --- a/client/app/pages/queries/view.js +++ b/client/app/pages/queries/view.js @@ -1,4 +1,4 @@ -import { pick, some, find, minBy, map, intersection, isArray, isObject } from 'lodash'; +import { pick, some, find, minBy, map, intersection, isArray } from 'lodash'; import { SCHEMA_NOT_SUPPORTED, SCHEMA_LOAD_ERROR } from '@/services/data-source'; import getTags from '@/services/getTags'; import { policy } from '@/services/policy'; @@ -10,8 +10,6 @@ import EditVisualizationDialog from '@/visualizations/EditVisualizationDialog'; import notification from '@/services/notification'; import template from './query.html'; -const DEFAULT_TAB = 'table'; - function QueryViewCtrl( $scope, Events, @@ -29,6 +27,10 @@ function QueryViewCtrl( Query, DataSource, ) { + // Should create it here since visualization registry might not be fulfilled when this file is loaded + const DEFAULT_VISUALIZATION = newVisualization('TABLE', { itemsPerPage: 50 }); + DEFAULT_VISUALIZATION.id = 'table'; + function getQueryResult(maxAge, selectedQueryText) { if (maxAge === undefined) { maxAge = $location.search().maxAge; @@ -134,13 +136,15 @@ function QueryViewCtrl( Notifications.getPermissions(); }; - $scope.selectedTab = DEFAULT_TAB; + $scope.selectedVisualization = DEFAULT_VISUALIZATION; $scope.currentUser = currentUser; $scope.dataSource = {}; $scope.query = $route.current.locals.query; $scope.showPermissionsControl = clientConfig.showPermissionsControl; - $scope.defaultVis = newVisualization('TABLE', { itemsPerPage: 50 }); + $scope.$watch('selectedVisualization', () => { + $scope.selectedTab = $scope.selectedVisualization.id; // Needed for `` to work + }); const shortcuts = { 'mod+enter': $scope.executeQuery, @@ -360,7 +364,7 @@ function QueryViewCtrl( }; $scope.setVisualizationTab = (visualization) => { - $scope.selectedTab = visualization.id; + $scope.selectedVisualization = visualization; $location.hash(visualization.id); }; @@ -375,9 +379,9 @@ function QueryViewCtrl( Visualization.delete( { id: vis.id }, () => { - if ($scope.selectedTab === String(vis.id)) { - $scope.selectedTab = DEFAULT_TAB; - $location.hash($scope.selectedTab); + if ($scope.selectedVisualization.id === vis.id) { + $scope.selectedVisualization = DEFAULT_VISUALIZATION; + $location.hash($scope.selectedVisualization.id); } $scope.query.visualizations = $scope.query.visualizations.filter(v => vis.id !== v.id); }, @@ -392,14 +396,6 @@ function QueryViewCtrl( Title.set($scope.query.name); }); - $scope.$watch('queryResult && queryResult.getData()', (data) => { - if (!data) { - return; - } - - $scope.filters = $scope.queryResult.getFilters(); - }); - $scope.$watch('queryResult && queryResult.getStatus()', (status) => { if (!status) { return; @@ -510,13 +506,13 @@ function QueryViewCtrl( $scope.$watch( () => $location.hash(), (hash) => { - // eslint-disable-next-line eqeqeq - const exists = find($scope.query.visualizations, item => item.id == hash); - let visualization = minBy($scope.query.visualizations, viz => viz.id); - if (!isObject(visualization)) { - visualization = {}; - } - $scope.selectedTab = (exists ? hash : visualization.id) || DEFAULT_TAB; + $scope.selectedVisualization = + // try to find by hash + find($scope.query.visualizations, item => item.id == hash) || // eslint-disable-line eqeqeq + // try first one (with smallest ID) + minBy($scope.query.visualizations, viz => viz.id) || + // fallback to default + DEFAULT_VISUALIZATION; }, ); diff --git a/client/app/visualizations/EditVisualizationDialog.jsx b/client/app/visualizations/EditVisualizationDialog.jsx index 6f571a674a..10c370a03e 100644 --- a/client/app/visualizations/EditVisualizationDialog.jsx +++ b/client/app/visualizations/EditVisualizationDialog.jsx @@ -1,249 +1,213 @@ -import { isEqual, extend, map, findIndex, cloneDeep } from 'lodash'; -import React from 'react'; +import { extend, map, findIndex, isEqual } from 'lodash'; +import React, { useState, useMemo } from 'react'; import PropTypes from 'prop-types'; import Modal from 'antd/lib/modal'; import Select from 'antd/lib/select'; import Input from 'antd/lib/input'; import * as Grid from 'antd/lib/grid'; import { wrap as wrapDialog, DialogPropType } from '@/components/DialogWrapper'; +import { Filters, filterData } from '@/components/Filters'; import notification from '@/services/notification'; import { Visualization } from '@/services/visualization'; import recordEvent from '@/services/recordEvent'; // ANGULAR_REMOVE_ME Remove when all visualizations will be migrated to React -import { cleanAngularProps, createPromiseHandler } from '@/lib/utils'; - -import { - VisualizationType, registeredVisualizations, - getDefaultVisualization, newVisualization, -} from './index'; - -class EditVisualizationDialog extends React.Component { - static propTypes = { - dialog: DialogPropType.isRequired, - query: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types - visualization: VisualizationType, - queryResult: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types - }; - - static defaultProps = { - visualization: null, - }; - - handleQueryResult = createPromiseHandler( - queryResult => queryResult.toPromise(), - () => { - this.setState(({ isNew, type }) => { - const config = registeredVisualizations[type]; - - const { queryResult, visualization } = this.props; - const data = { - columns: queryResult ? queryResult.getColumns() : [], - rows: queryResult ? queryResult.getData() : [], - }; - - const options = config.getOptions(isNew ? {} : visualization.options, data); - - this._originalOptions = cloneDeep(options); - - return { isLoading: false, data, options }; - }); - }, - ); - - constructor(props) { - super(props); - - const { visualization } = this.props; - - const isNew = !visualization; - - const config = isNew ? getDefaultVisualization() - : registeredVisualizations[visualization.type]; - - this.state = { - isLoading: true, - - isNew, // eslint-disable-line - data: { columns: [], rows: [] }, - options: {}, - - type: config.type, - canChangeType: isNew, // cannot change type when editing existing visualization - name: isNew ? config.name : visualization.name, - nameChanged: false, - - saveInProgress: false, - }; - } - - componentWillUnmount() { - this.handleQueryResult.cancel(); - } - - setVisualizationType(type) { - this.setState(({ isNew, name, nameChanged, data }) => { - const { visualization } = this.props; - const config = registeredVisualizations[type]; - const options = config.getOptions(isNew ? {} : visualization.options, data); - return { - type, - name: nameChanged ? name : config.name, - options, - }; - }); +import { cleanAngularProps } from '@/lib/utils'; +import useQueryResult from '@/lib/hooks/useQueryResult'; + +import { VisualizationType, registeredVisualizations, getDefaultVisualization, newVisualization } from './index'; + +function updateQueryVisualizations(query, visualization) { + const index = findIndex(query.visualizations, v => v.id === visualization.id); + if (index > -1) { + query.visualizations[index] = visualization; + } else { + // new visualization + query.visualizations.push(visualization); } +} - setVisualizationName(name) { - this.setState({ - name, - nameChanged: true, - }); +function saveVisualization(visualization) { + if (visualization.id) { + recordEvent('update', 'visualization', visualization.id, { type: visualization.type }); + } else { + recordEvent('create', 'visualization', null, { type: visualization.type }); } - setVisualizationOptions = (options) => { - this.setState(({ type, data }) => { - const config = registeredVisualizations[type]; - return { - options: config.getOptions(options, data), - }; + return Visualization.save(visualization).$promise + .then((result) => { + notification.success('Visualization saved'); + return result; + }) + .catch((error) => { + notification.error('Visualization could not be saved'); + return Promise.reject(error); }); - }; - - dismiss() { - const { nameChanged, options } = this.state; +} - const optionsChanged = !isEqual(cleanAngularProps(options), this._originalOptions); - if (nameChanged || optionsChanged) { +function confirmDialogClose(isDirty) { + return new Promise((resolve, reject) => { + if (isDirty) { Modal.confirm({ title: 'Visualization Editor', content: 'Are you sure you want to close the editor without saving?', okText: 'Yes', cancelText: 'No', - onOk: () => { - this.props.dialog.dismiss(); - }, + onOk: () => resolve(), + onCancel: () => reject(), }); } else { - this.props.dialog.dismiss(); + resolve(); } - } + }); +} - save() { - const { query } = this.props; - const { type, name, options } = this.state; +function EditVisualizationDialog({ dialog, visualization, query, queryResult }) { + const isNew = !visualization; - const visualization = extend({}, newVisualization(type), this.props.visualization); + const data = useQueryResult(queryResult); + const [filters, setFilters] = useState(data.filters); - visualization.name = name; - visualization.options = options; - visualization.query_id = query.id; + const filteredData = useMemo(() => ({ + columns: data.columns, + rows: filterData(data.rows, filters), + }), [data, filters]); - if (visualization.id) { - recordEvent('update', 'visualization', visualization.id, { type: visualization.type }); - } else { - recordEvent('create', 'visualization', null, { type: visualization.type }); + const defaultState = useMemo( + () => { + const config = visualization ? registeredVisualizations[visualization.type] : getDefaultVisualization(); + const options = config.getOptions(isNew ? {} : visualization.options, data); + return { + type: config.type, + name: isNew ? config.name : visualization.name, + options, + originalOptions: options, + }; + }, + [visualization], + ); + + const [type, setType] = useState(defaultState.type); + const [name, setName] = useState(defaultState.name); + const [nameChanged, setNameChanged] = useState(false); + const [options, setOptions] = useState(defaultState.options); + + const [saveInProgress, setSaveInProgress] = useState(false); + + function onTypeChanged(newType) { + setType(newType); + + const config = registeredVisualizations[newType]; + if (!nameChanged) { + setName(config.name); } - this.setState({ saveInProgress: true }); - - Visualization.save(visualization).$promise - .then((result) => { - notification.success('Visualization saved'); - - const index = findIndex(query.visualizations, v => v.id === result.id); - if (index > -1) { - query.visualizations[index] = result; - } else { - // new visualization - query.visualizations.push(result); - } - this.props.dialog.close(result); - }) - .catch(() => { - notification.error('Visualization could not be saved'); - this.setState({ saveInProgress: false }); - }); + setOptions(config.getOptions(isNew ? {} : visualization.options, data)); } - render() { - this.handleQueryResult(this.props.queryResult); + function onNameChanged(newName) { + setName(newName); + setNameChanged(newName !== name); + } - if (this.state.isLoading) { - return null; - } + function onOptionsChanged(newOptions) { + const config = registeredVisualizations[type]; + setOptions(config.getOptions(newOptions, data)); + } - const { dialog } = this.props; - const { type, name, data, options, canChangeType, saveInProgress } = this.state; - - const { Renderer, Editor } = registeredVisualizations[type]; - - return ( - this.save()} - onCancel={() => this.dismiss()} - wrapProps={{ 'data-test': 'EditVisualizationDialog' }} - > - - -
      - - -
      -
      - - this.setVisualizationName(event.target.value)} - /> -
      -
      - -
      -
      - - -
      - -
      -
      -
      -
      - ); + function save() { + setSaveInProgress(true); + const visualizationData = extend(newVisualization(type), visualization, { name, options, query_id: query.id }); + saveVisualization(visualizationData).then((savedVisualization) => { + updateQueryVisualizations(query, savedVisualization); + dialog.close(savedVisualization); + }); + } + + function dismiss() { + const optionsChanged = !isEqual(cleanAngularProps(options), defaultState.originalOptions); + confirmDialogClose(nameChanged || optionsChanged).then(dialog.dismiss); } + + const { Renderer, Editor } = registeredVisualizations[type]; + + return ( + + + +
      + + +
      +
      + + onNameChanged(event.target.value)} + /> +
      +
      + +
      +
      + + + +
      + +
      +
      +
      +
      + ); } +EditVisualizationDialog.propTypes = { + dialog: DialogPropType.isRequired, + query: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types + visualization: VisualizationType, + queryResult: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types +}; + +EditVisualizationDialog.defaultProps = { + visualization: null, +}; + export default wrapDialog(EditVisualizationDialog); diff --git a/client/app/visualizations/VisualizationRenderer.jsx b/client/app/visualizations/VisualizationRenderer.jsx index df5516b34f..e6a45b52d5 100644 --- a/client/app/visualizations/VisualizationRenderer.jsx +++ b/client/app/visualizations/VisualizationRenderer.jsx @@ -1,100 +1,74 @@ import { map, find } from 'lodash'; -import React from 'react'; +import React, { useState, useMemo, useEffect } from 'react'; import PropTypes from 'prop-types'; import { react2angular } from 'react2angular'; +import useQueryResult from '@/lib/hooks/useQueryResult'; import { Filters, FiltersType, filterData } from '@/components/Filters'; -import { createPromiseHandler } from '@/lib/utils'; import { registeredVisualizations, VisualizationType } from './index'; -export class VisualizationRenderer extends React.Component { - static propTypes = { - visualization: VisualizationType.isRequired, - queryResult: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types - filters: FiltersType, - }; - - static defaultProps = { - filters: [], - }; - - state = { - allRows: [], // eslint-disable-line - data: { columns: [], rows: [] }, - filters: [], - }; - - handleQueryResult = createPromiseHandler( - queryResult => queryResult.toPromise(), - () => { - const { queryResult } = this.props; - const columns = queryResult ? queryResult.getColumns() : []; - const rows = queryResult ? queryResult.getData() : []; - this.setState({ - allRows: rows, // eslint-disable-line - data: { columns, rows }, - }); - this.applyFilters(queryResult.getFilters()); - }, - ); - - componentDidUpdate(prevProps) { - if (this.props.filters !== prevProps.filters) { - // When global filters changed - apply corresponding values to local filters - this.applyFilters(this.state.filters, true); - } - } - - componentWillUnmount() { - this.handleQueryResult.cancel(); +function combineFilters(localFilters, globalFilters) { + // tiny optimization - to avoid unnecessary updates + if ((localFilters.length === 0) || (globalFilters.length === 0)) { + return localFilters; } - applyFilters(filters, applyGlobals = false) { - // tiny optimization - to avoid unnecessary updates - if ((this.state.filters.length === 0) && (filters.length === 0)) { - return; + return map(localFilters, (localFilter) => { + const globalFilter = find(globalFilters, f => f.name === localFilter.name); + if (globalFilter) { + return { + ...localFilter, + current: globalFilter.current, + }; } + return localFilter; + }); +} - if (applyGlobals) { - filters = map(filters, (localFilter) => { - const globalFilter = find(this.props.filters, f => f.name === localFilter.name); - if (globalFilter) { - return { - ...localFilter, - current: globalFilter.current, - }; - } - return localFilter; - }); - } +export function VisualizationRenderer(props) { + const data = useQueryResult(props.queryResult); + const [filters, setFilters] = useState(data.filters); - this.setState(({ allRows, data }) => ({ - filters, - data: { - columns: data.columns, - rows: filterData(allRows, filters), - }, - })); - } + // Reset local filters when query results updated + useEffect(() => { + setFilters(combineFilters(data.filters, props.filters)); + }, [data]); - render() { - const { visualization, queryResult } = this.props; - const { data, filters } = this.state; - const { Renderer, getOptions } = registeredVisualizations[visualization.type]; - const options = getOptions(visualization.options, data); + // Update local filters when global filters changed + useEffect(() => { + setFilters(combineFilters(filters, props.filters)); + }, [props.filters]); - this.handleQueryResult(queryResult); + const filteredData = useMemo(() => ({ + columns: data.columns, + rows: filterData(data.rows, filters), + }), [data, filters]); - return ( - - this.applyFilters(newFilters)} /> -
      - -
      -
      - ); - } + const { showFilters, visualization } = props; + const { Renderer, getOptions } = registeredVisualizations[visualization.type]; + const options = getOptions(visualization.options, data); + + return ( + + {showFilters && } +
      + +
      +
      + ); } +VisualizationRenderer.propTypes = { + visualization: VisualizationType.isRequired, + queryResult: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types + filters: FiltersType, + showFilters: PropTypes.bool, +}; + +VisualizationRenderer.defaultProps = { + filters: [], + showFilters: true, +}; + export default function init(ngModule) { ngModule.component('visualizationRenderer', react2angular(VisualizationRenderer)); } diff --git a/client/app/visualizations/counter/index.js b/client/app/visualizations/counter/index.js index 3b48321cb5..b930576f0d 100644 --- a/client/app/visualizations/counter/index.js +++ b/client/app/visualizations/counter/index.js @@ -7,6 +7,7 @@ import counterTemplate from './counter.html'; import counterEditorTemplate from './counter-editor.html'; const DEFAULT_OPTIONS = { + counterLabel: '', counterColName: 'counter', rowNumber: 1, targetRowNumber: 1, From cabb24756f4651d50768ae7071e6c52a73d9593c Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Wed, 17 Apr 2019 11:23:57 +0300 Subject: [PATCH 37/41] Fix bugs in Word Cound and Funnel --- client/app/visualizations/funnel/index.js | 5 ++++- client/app/visualizations/word-cloud/index.js | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/client/app/visualizations/funnel/index.js b/client/app/visualizations/funnel/index.js index 8092612564..7fe167cc41 100644 --- a/client/app/visualizations/funnel/index.js +++ b/client/app/visualizations/funnel/index.js @@ -117,6 +117,9 @@ function Funnel(scope, element) { } function prepareData(queryData) { + if (queryData.length === 0) { + return []; + } const data = queryData.map( row => ({ step: normalizeValue(row[options.stepCol.colName]), @@ -161,7 +164,7 @@ function Funnel(scope, element) { const queryData = scope.$ctrl.data.rows; const data = prepareData(queryData, options); - if (data) { + if (data.length > 0) { createVisualization(data); // draw funnel } } diff --git a/client/app/visualizations/word-cloud/index.js b/client/app/visualizations/word-cloud/index.js index 161d35ec58..1f57689bcc 100644 --- a/client/app/visualizations/word-cloud/index.js +++ b/client/app/visualizations/word-cloud/index.js @@ -34,6 +34,11 @@ function createScale(wordCounts) { const minCount = min(wordCounts); const maxCount = max(wordCounts); + // Edge case - if all words have the same count; just set middle size for all + if (minCount === maxCount) { + return () => (MAX_WORD_SIZE + MIN_WORD_SIZE) / 2; + } + // v is value from source domain: // s1 <= v <= s2. // We need to fit it target domain: From 5bb3d01672488d0985e210125530ebfaa562762b Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Wed, 17 Apr 2019 11:59:37 +0300 Subject: [PATCH 38/41] Remove unused function --- client/app/lib/utils.js | 30 +----------------------------- 1 file changed, 1 insertion(+), 29 deletions(-) diff --git a/client/app/lib/utils.js b/client/app/lib/utils.js index 9171003797..c2fdfa291d 100644 --- a/client/app/lib/utils.js +++ b/client/app/lib/utils.js @@ -1,4 +1,4 @@ -import { isObject, isFunction, cloneDeep, each, extend } from 'lodash'; +import { isObject, cloneDeep, each, extend } from 'lodash'; export function routesToAngularRoutes(routes, template) { const result = {}; @@ -40,31 +40,3 @@ export function cleanAngularProps(value) { const result = cloneDeep(value); return isObject(result) ? omitAngularProps(result) : result; } - -export function createPromiseHandler(toPromise, onResolved, onRejected = null) { - let lastValue = null; - let isCancelled = false; - - function handle(value) { - if (value !== lastValue) { - lastValue = value; - toPromise(value) - .then((result) => { - if (!isCancelled && (lastValue === value) && isFunction(onResolved)) { - onResolved(result); - } - }) - .catch((error) => { - if (!isCancelled && (lastValue === value) && isFunction(onRejected)) { - onRejected(error); - } - }); - } - } - - handle.cancel = () => { - isCancelled = true; - }; - - return handle; -} From a771ef53063e422fbd9f93658de2c91709eed524 Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Wed, 17 Apr 2019 12:15:25 +0300 Subject: [PATCH 39/41] Fix linter errors --- client/cypress/integration/dashboard/dashboard_spec.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/cypress/integration/dashboard/dashboard_spec.js b/client/cypress/integration/dashboard/dashboard_spec.js index a1f4f4e0d3..27b0696ad4 100644 --- a/client/cypress/integration/dashboard/dashboard_spec.js +++ b/client/cypress/integration/dashboard/dashboard_spec.js @@ -521,11 +521,11 @@ describe('Dashboard', () => { context('viewport width is at 800px', () => { before(function () { cy.login(); - createNewDashboardByAPI('Foo Bar') + createDashboard('Foo Bar') .then(({ slug, id }) => { this.dashboardUrl = `/dashboard/${slug}`; this.dashboardEditUrl = `/dashboard/${slug}?edit`; - return addTextboxByAPI('Hello World!', id); + return addTextbox(id, 'Hello World!'); }) .then((elTestId) => { cy.visit(this.dashboardUrl); @@ -578,7 +578,7 @@ describe('Dashboard', () => { context('viewport width is at 767px', () => { before(function () { cy.login(); - createNewDashboardByAPI('Foo Bar').then(({ slug }) => { + createDashboard('Foo Bar').then(({ slug }) => { this.dashboardUrl = `/dashboard/${slug}`; }); }); From 03057dc3c348bbfb8c32c21357be35dbc448f26d Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Wed, 17 Apr 2019 12:26:27 +0300 Subject: [PATCH 40/41] Restore missing component --- .../app/visualizations/VisualizationName.jsx | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 client/app/visualizations/VisualizationName.jsx diff --git a/client/app/visualizations/VisualizationName.jsx b/client/app/visualizations/VisualizationName.jsx new file mode 100644 index 0000000000..83c67765cc --- /dev/null +++ b/client/app/visualizations/VisualizationName.jsx @@ -0,0 +1,23 @@ +import { react2angular } from 'react2angular'; +import { VisualizationType, registeredVisualizations } from './index'; + +export function VisualizationName({ visualization }) { + const config = registeredVisualizations[visualization.type]; + if (config) { + if (visualization.name !== config.name) { + return visualization.name; + } + } + + return null; +} + +VisualizationName.propTypes = { + visualization: VisualizationType.isRequired, +}; + +export default function init(ngModule) { + ngModule.component('visualizationName', react2angular(VisualizationName)); +} + +init.init = true; From ddbd16231bd4280a5b691ee08c3e5d88b6b1713c Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Wed, 17 Apr 2019 13:10:16 +0300 Subject: [PATCH 41/41] Fix tests --- client/app/pages/queries/query.html | 2 +- client/cypress/integration/dashboard/dashboard_spec.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client/app/pages/queries/query.html b/client/app/pages/queries/query.html index f8022bd829..2fe0687c08 100644 --- a/client/app/pages/queries/query.html +++ b/client/app/pages/queries/query.html @@ -238,7 +238,7 @@

      New Visualization

    -
    +
    diff --git a/client/cypress/integration/dashboard/dashboard_spec.js b/client/cypress/integration/dashboard/dashboard_spec.js index 27b0696ad4..0aecdd39da 100644 --- a/client/cypress/integration/dashboard/dashboard_spec.js +++ b/client/cypress/integration/dashboard/dashboard_spec.js @@ -525,7 +525,7 @@ describe('Dashboard', () => { .then(({ slug, id }) => { this.dashboardUrl = `/dashboard/${slug}`; this.dashboardEditUrl = `/dashboard/${slug}?edit`; - return addTextbox(id, 'Hello World!'); + return addTextbox(id, 'Hello World!').then(getWidgetTestId); }) .then((elTestId) => { cy.visit(this.dashboardUrl);