diff --git a/client/app/assets/less/ant.less b/client/app/assets/less/ant.less index f05dfcd2bd..d4993f9c86 100644 --- a/client/app/assets/less/ant.less +++ b/client/app/assets/less/ant.less @@ -221,21 +221,61 @@ } } -// 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/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/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 7c250c9fff..6cf1b8d3e2 100644 --- a/client/app/assets/less/redash/query.less +++ b/client/app/assets/less/redash/query.less @@ -222,7 +222,7 @@ edit-in-place p.editable:hover { .widget-wrapper { .body-container { - filters { + .filters-wrapper { display: block; padding-left: 15px; } @@ -343,7 +343,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/components/ColorBox.jsx b/client/app/components/ColorBox.jsx new file mode 100644 index 0000000000..73b3f3681e --- /dev/null +++ b/client/app/components/ColorBox.jsx @@ -0,0 +1,23 @@ +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, +}; + +ColorBox.defaultProps = { + color: 'transparent', +}; + +export default function init(ngModule) { + ngModule.component('colorBox', react2angular(ColorBox)); +} + +init.init = true; diff --git a/client/app/components/Filters.jsx b/client/app/components/Filters.jsx new file mode 100644 index 0000000000..f9da7f56ed --- /dev/null +++ b/client/app/components/Filters.jsx @@ -0,0 +1,122 @@ +import { isArray, map, 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 = []; + } + filters = map(filters, f => (f.name === filter.name ? { ...filter, current: value } : f)); + onChange(filters); + }; +} + +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/color-box.less b/client/app/components/color-box.less new file mode 100644 index 0000000000..e1027258a4 --- /dev/null +++ b/client/app/components/color-box.less @@ -0,0 +1,8 @@ +color-box { + span { + width: 12px !important; + height: 12px !important; + display: inline-block !important; + margin-right: 5px; + } +} diff --git a/client/app/components/dashboards/widget.html b/client/app/components/dashboards/widget.html index 9e6765174a..3b08aded89 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 c4d1439260..fc0667f1bd 100644 --- a/client/app/components/dashboards/widget.js +++ b/client/app/components/dashboards/widget.js @@ -115,6 +115,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/hooks/useQueryResult.js b/client/app/lib/hooks/useQueryResult.js new file mode 100644 index 0000000000..6a7179b930 --- /dev/null +++ b/client/app/lib/hooks/useQueryResult.js @@ -0,0 +1,30 @@ +import { useState, useEffect } from 'react'; + +function getQueryResultData(queryResult) { + return { + columns: queryResult ? queryResult.getColumns() : [], + rows: queryResult ? queryResult.getData() : [], + filters: queryResult ? queryResult.getFilters() : [], + }; +} + +export default function useQueryResult(queryResult) { + const [data, setData] = useState(getQueryResultData(queryResult)); + let isCancelled = false; + useEffect(() => { + if (queryResult) { + queryResult.toPromise() + .then(() => { + if (!isCancelled) { + setData(getQueryResultData(queryResult)); + } + }); + } else { + setData(getQueryResultData(queryResult)); + } + return () => { + isCancelled = true; + }; + }, [queryResult]); + return data; +} diff --git a/client/app/lib/utils.js b/client/app/lib/utils.js index ed13732faa..c2fdfa291d 100644 --- a/client/app/lib/utils.js +++ b/client/app/lib/utils.js @@ -1,6 +1,5 @@ -import { each, extend } from 'lodash'; +import { isObject, cloneDeep, each, extend } from 'lodash'; -// eslint-disable-next-line import/prefer-default-export export function routesToAngularRoutes(routes, template) { const result = {}; template = extend({}, template); // convert to object @@ -23,3 +22,21 @@ export function routesToAngularRoutes(routes, template) { }); return result; } + +// 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; +} diff --git a/client/app/lib/visualizations/sunburst.js b/client/app/lib/visualizations/sunburst.js index 3b4a1296cd..2a5f1ffb36 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])), })); @@ -333,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, @@ -366,15 +368,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/pages/dashboards/dashboard.html b/client/app/pages/dashboards/dashboard.html index 35559987ea..c99b5cc611 100644 --- a/client/app/pages/dashboards/dashboard.html +++ b/client/app/pages/dashboards/dashboard.html @@ -98,7 +98,7 @@

- +
@@ -106,9 +106,10 @@

is-one-column-mode="$ctrl.isGridDisabled" class="dashboard-wrapper">
+ gridstack-item="widget.options.position" gridstack-item-id="{{ widget.id }}" data-test="WidgetId{{ widget.id }}">
- +

diff --git a/client/app/pages/dashboards/dashboard.js b/client/app/pages/dashboards/dashboard.js index 98dbacfad8..a4811af7f7 100644 --- a/client/app/pages/dashboards/dashboard.js +++ b/client/app/pages/dashboards/dashboard.js @@ -7,6 +7,7 @@ import { editableMappingsToParameterMappings, synchronizeWidgetTitles, } from '@/components/ParameterMappingInput'; +import { collectDashboardFilters } from '@/services/dashboard'; import { durationHumanize } from '@/filters'; import template from './dashboard.html'; import ShareDashboardDialog from './ShareDashboardDialog'; @@ -101,6 +102,7 @@ function DashboardCtrl( this.globalParameters = []; this.isDashboardOwner = false; this.isLayoutDirty = false; + this.filters = []; this.refreshRates = clientConfig.dashboardRefreshIntervals.map(interval => ({ name: durationHumanize(interval), @@ -140,38 +142,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(); }; }); }; @@ -400,6 +374,7 @@ function DashboardCtrl( this.onWidgetAdded = () => { this.extractGlobalParameters(); + collectFilters(this.dashboard, false); // Save position of newly added widget (but not entire layout) const widget = _.last(this.dashboard.widgets); if (_.isObject(widget)) { @@ -411,6 +386,7 @@ function DashboardCtrl( this.removeWidget = (widgetId) => { this.dashboard.widgets = this.dashboard.widgets.filter(w => w.id !== undefined && w.id !== widgetId); this.extractGlobalParameters(); + collectFilters(this.dashboard, false); $scope.$applyAsync(); if (!this.layoutEditing) { diff --git a/client/app/pages/dashboards/dashboard.less b/client/app/pages/dashboards/dashboard.less index 2dda95f1de..a24eedf53c 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; } @@ -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 842b319550..a482c9ed20 100644 --- a/client/app/pages/queries/query.html +++ b/client/app/pages/queries/query.html @@ -226,25 +226,20 @@

Log Information:

{{l}}

-