From 0c45d6966201c7bc79e7efece8cb55c84c5fce7d Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Tue, 15 Jan 2019 13:14:54 +0200 Subject: [PATCH] Dashboard Parameters (#2756) * getredash/redash#2641 Step 1: split Add Widget/Add Textbox buttons * Convert Add widget/textbox dialogs to React components * getredash/redash#2641 Step 2: Implement new dashboard parameters logic * Resolve conflicts and fix build errors * getredash/redash#2641 Refactoring and improve code quality * Add Edit parameter mappings dialog to the widget * getredash/redash#2641 Changes after code review * Use Ant's Select component instead on this.updateParamMapping(mapping, { type })} + dropdownClassName="ant-dropdown-in-bootstrap-modal" + > + + { + (existingParamNames.length > 0) && + + } + + + + + ); + } + + renderDashboardAddNew() { + const { mapping, existingParamNames } = this.props; + const alreadyExists = includes(existingParamNames, mapping.mapTo); + return ( +
+ this.updateParamMapping(mapping, { mapTo: event.target.value })} + /> + { alreadyExists && +
+ Dashboard parameter with this name already exists +
+ } +
+ ); + } + + renderDashboardMapToExisting() { + const { mapping, existingParamNames } = this.props; + return ( +
+ +
+ ); + } + + renderStaticValue() { + const { mapping } = this.props; + return ( +
+ + this.updateParamMapping(mapping, { value })} + clientConfig={this.props.clientConfig} + Query={this.props.Query} + /> +
+ ); + } + + renderInputBlock() { + const { mapping } = this.props; + switch (mapping.type) { + case MappingType.DashboardAddNew: return this.renderDashboardAddNew(); + case MappingType.DashboardMapToExisting: return this.renderDashboardMapToExisting(); + case MappingType.StaticValue: return this.renderStaticValue(); + // no default + } + } + + renderTitleInput() { + const { mapping } = this.props; + if (mapping.type === MappingType.StaticValue) { + return null; + } + return ( +
+ + this.updateParamMapping(mapping, { title: event.target.value })} + placeholder={mapping.param.title} + /> +
+ ); + } + + render() { + const { mapping } = this.props; + return ( +
+
+
{'{{ ' + mapping.name + ' }}'}
+
+
+ {this.renderMappingTypeSelector()} + {this.renderInputBlock()} + {this.renderTitleInput()} +
+
+ ); + } +} + +export class ParameterMappingListInput extends React.Component { + static propTypes = { + mappings: PropTypes.arrayOf(PropTypes.object), + existingParamNames: PropTypes.arrayOf(PropTypes.string), + onChange: PropTypes.func, + clientConfig: PropTypes.any, // eslint-disable-line react/forbid-prop-types + Query: PropTypes.any, // eslint-disable-line react/forbid-prop-types + }; + + static defaultProps = { + mappings: [], + existingParamNames: [], + onChange: () => {}, + clientConfig: null, + Query: null, + }; + + updateParamMapping(oldMapping, newMapping) { + const mappings = [...this.props.mappings]; + const index = findIndex(mappings, oldMapping); + if (index >= 0) { + // This should be the only possible case, but need to handle `else` too + mappings[index] = newMapping; + } else { + mappings.push(newMapping); + } + this.props.onChange(mappings); + } + + render() { + const clientConfig = this.props.clientConfig; // eslint-disable-line react/prop-types + const Query = this.props.Query; // eslint-disable-line react/prop-types + + return ( +
+ {this.props.mappings.map((mapping, index) => ( +
+ this.updateParamMapping(mapping, newMapping)} + clientConfig={clientConfig} + Query={Query} + /> +
+ ))} +
+ ); + } +} diff --git a/client/app/components/ParameterValueInput.jsx b/client/app/components/ParameterValueInput.jsx new file mode 100644 index 0000000000..c16edcc7af --- /dev/null +++ b/client/app/components/ParameterValueInput.jsx @@ -0,0 +1,221 @@ +import { isNull, isUndefined } from 'lodash'; +import React from 'react'; +import PropTypes from 'prop-types'; +import { react2angular } from 'react2angular'; +import Select from 'antd/lib/select'; +import { DateInput } from './DateInput'; +import { DateRangeInput } from './DateRangeInput'; +import { DateTimeInput } from './DateTimeInput'; +import { DateTimeRangeInput } from './DateTimeRangeInput'; +import { QueryBasedParameterInput } from './QueryBasedParameterInput'; + +const { Option } = Select; + +export class ParameterValueInput extends React.Component { + static propTypes = { + type: PropTypes.string, + value: PropTypes.any, // eslint-disable-line react/forbid-prop-types + enumOptions: PropTypes.string, + queryId: PropTypes.number, + onSelect: PropTypes.func, + className: PropTypes.string, + }; + + static defaultProps = { + type: 'text', + value: null, + enumOptions: '', + queryId: null, + onSelect: () => {}, + className: '', + }; + + renderDateTimeWithSecondsInput() { + const { + value, + onSelect, + clientConfig, // eslint-disable-line react/prop-types + } = this.props; + return ( + + ); + } + + renderDateTimeInput() { + const { + value, + onSelect, + clientConfig, // eslint-disable-line react/prop-types + } = this.props; + return ( + + ); + } + + renderDateInput() { + const { + value, + onSelect, + clientConfig, // eslint-disable-line react/prop-types + } = this.props; + return ( + + ); + } + + renderDateTimeRangeWithSecondsInput() { + const { + value, + onSelect, + clientConfig, // eslint-disable-line react/prop-types + } = this.props; + return ( + + ); + } + + renderDateTimeRangeInput() { + const { + value, + onSelect, + clientConfig, // eslint-disable-line react/prop-types + } = this.props; + return ( + + ); + } + + renderDateRangeInput() { + const { + value, + onSelect, + clientConfig, // eslint-disable-line react/prop-types + } = this.props; + return ( + + ); + } + + renderEnumInput() { + const { value, onSelect, enumOptions } = this.props; + const enumOptionsArray = enumOptions.split('\n').filter(v => v !== ''); + return ( + + ); + } + + renderQueryBasedInput() { + const { + value, + onSelect, + queryId, + Query, // eslint-disable-line react/prop-types + } = this.props; + return ( + + ); + } + + renderTextInput() { + const { value, onSelect, type } = this.props; + return ( + onSelect(event.target.value)} + /> + ); + } + + render() { + const { type } = this.props; + switch (type) { + case 'datetime-with-seconds': return this.renderDateTimeWithSecondsInput(); + case 'datetime-local': return this.renderDateTimeInput(); + case 'date': return this.renderDateInput(); + case 'datetime-range-with-seconds': return this.renderDateTimeRangeWithSecondsInput(); + case 'datetime-range': return this.renderDateTimeRangeInput(); + case 'date-range': return this.renderDateRangeInput(); + case 'enum': return this.renderEnumInput(); + case 'query': return this.renderQueryBasedInput(); + default: return this.renderTextInput(); + } + } +} + +export default function init(ngModule) { + ngModule.component('parameterValueInput', { + template: ` + + `, + bindings: { + param: '<', + }, + controller($scope) { + this.setValue = (value) => { + this.param.setValue(value); + $scope.$applyAsync(); + }; + }, + }); + ngModule.component( + 'parameterValueInputImpl', + react2angular(ParameterValueInput, null, ['clientConfig', 'Query']), + ); +} + +init.init = true; diff --git a/client/app/components/QueryBasedParameterInput.jsx b/client/app/components/QueryBasedParameterInput.jsx new file mode 100644 index 0000000000..f9ef4ebace --- /dev/null +++ b/client/app/components/QueryBasedParameterInput.jsx @@ -0,0 +1,123 @@ +import { find, isFunction } from 'lodash'; +import React from 'react'; +import PropTypes from 'prop-types'; +import { react2angular } from 'react2angular'; +import Select from 'antd/lib/select'; + +const { Option } = Select; + +function optionsFromQueryResult(queryResult) { + const columns = queryResult.data.columns; + const numColumns = columns.length; + let options = []; + // If there are multiple columns, check if there is a column + // named 'name' and column named 'value'. If name column is present + // in results, use name from name column. Similar for value column. + // Default: Use first string column for name and value. + if (numColumns > 0) { + let nameColumn = null; + let valueColumn = null; + columns.forEach((column) => { + const columnName = column.name.toLowerCase(); + if (columnName === 'name') { + nameColumn = column.name; + } + if (columnName === 'value') { + valueColumn = column.name; + } + // Assign first string column as name and value column. + if (nameColumn === null) { + nameColumn = column.name; + } + if (valueColumn === null) { + valueColumn = column.name; + } + }); + if (nameColumn !== null && valueColumn !== null) { + options = queryResult.data.rows.map(row => ({ + name: row[nameColumn], + value: row[valueColumn], + })); + } + } + return options; +} + +export class QueryBasedParameterInput extends React.Component { + static propTypes = { + value: PropTypes.any, // eslint-disable-line react/forbid-prop-types + queryId: PropTypes.number, + onSelect: PropTypes.func, + className: PropTypes.string, + }; + + static defaultProps = { + value: null, + queryId: null, + onSelect: () => {}, + className: '', + }; + + constructor(props) { + super(props); + this.state = { + options: [], + loading: false, + }; + } + + componentDidMount() { + this._loadOptions(this.props.queryId); + } + + // eslint-disable-next-line no-unused-vars + componentWillReceiveProps(nextProps) { + if (nextProps.queryId !== this.props.queryId) { + this._loadOptions(nextProps.queryId, nextProps.value); + } + } + + _loadOptions(queryId) { + if (queryId && (queryId !== this.state.queryId)) { + const Query = this.props.Query; // eslint-disable-line react/prop-types + this.setState({ loading: true }); + Query.resultById({ id: queryId }, (result) => { + if (this.props.queryId === queryId) { + const options = optionsFromQueryResult(result.query_result); + this.setState({ options, loading: false }); + + const found = find(options, option => option.value === this.props.value) !== undefined; + if (!found && isFunction(this.props.onSelect)) { + this.props.onSelect(options[0].value); + } + } + }); + } + } + + render() { + const { className, value, onSelect } = this.props; + const { loading, options } = this.state; + return ( + + + + ); + } +} + +export default function init(ngModule) { + ngModule.component('queryBasedParameterInput', react2angular(QueryBasedParameterInput, null, ['Query'])); +} + +init.init = true; diff --git a/client/app/components/dashboards/AddTextboxDialog.jsx b/client/app/components/dashboards/AddTextboxDialog.jsx new file mode 100644 index 0000000000..97fead0cc8 --- /dev/null +++ b/client/app/components/dashboards/AddTextboxDialog.jsx @@ -0,0 +1,154 @@ +import { markdown } from 'markdown'; +import { debounce } from 'lodash'; +import React from 'react'; +import PropTypes from 'prop-types'; +import { react2angular } from 'react2angular'; + +class AddTextboxDialog extends React.Component { + static propTypes = { + dashboard: PropTypes.object, // eslint-disable-line react/forbid-prop-types + close: PropTypes.func, + dismiss: PropTypes.func, + }; + + static defaultProps = { + dashboard: null, + close: () => {}, + dismiss: () => {}, + }; + + constructor(props) { + super(props); + this.state = { + saveInProgress: false, + text: '', + preview: '', + }; + + const updatePreview = debounce(() => { + this.setState({ + preview: markdown.toHTML(this.state.text), + }); + }, 100); + + this.onTextChanged = (event) => { + this.setState({ text: event.target.value }); + updatePreview(); + }; + } + + saveWidget() { + const Widget = this.props.Widget; // eslint-disable-line react/prop-types + const toastr = this.props.toastr; // eslint-disable-line react/prop-types + const dashboard = this.props.dashboard; + + this.setState({ saveInProgress: true }); + + const widget = new Widget({ + visualization_id: null, + dashboard_id: dashboard.id, + options: { + isHidden: false, + position: {}, + }, + visualization: null, + text: this.state.text, + }); + + const position = dashboard.calculateNewWidgetPosition(widget); + widget.options.position.col = position.col; + widget.options.position.row = position.row; + + widget + .save() + .then(() => { + dashboard.widgets.push(widget); + this.props.close(); + }) + .catch(() => { + toastr.error('Widget can not be added'); + }) + .finally(() => { + this.setState({ saveInProgress: false }); + }); + } + + render() { + return ( +
+
+ +

Add Textbox

+
+
+
+ -
-
- Preview: -

-
-
- -
-
- -
- - -
-
- -
-
- -
- -
-
- No results matching search term. -
-
- -
-
-
- -
-
- - -
-
-
-
- - diff --git a/client/app/components/dashboards/add-widget-dialog.js b/client/app/components/dashboards/add-widget-dialog.js deleted file mode 100644 index a0ae6fcafb..0000000000 --- a/client/app/components/dashboards/add-widget-dialog.js +++ /dev/null @@ -1,118 +0,0 @@ -import { debounce } from 'lodash'; -import template from './add-widget-dialog.html'; -import './add-widget-dialog.less'; - -const AddWidgetDialog = { - template, - bindings: { - resolve: '<', - close: '&', - dismiss: '&', - }, - controller($sce, toastr, Query, Widget) { - 'ngInject'; - - this.dashboard = this.resolve.dashboard; - this.saveInProgress = false; - - // Textbox - this.text = ''; - - // Visualization - this.selectedQuery = null; - this.searchTerm = ''; - this.recentQueries = []; - - // Don't show draft (unpublished) queries - Query.recent().$promise.then((items) => { - this.recentQueries = items.filter(item => !item.is_draft); - }); - - this.searchedQueries = []; - this.selectedVis = null; - - this.trustAsHtml = html => $sce.trustAsHtml(html); - - this.setType = (type) => { - this.type = type; - this.isVisualization = this.type === 'visualization'; - this.isTextBox = this.type === 'textbox'; - }; - this.setType('visualization'); - - this.selectQuery = (queryId) => { - // Clear previously selected query (if any) - this.selectedQuery = null; - this.selectedVis = null; - - if (queryId) { - Query.get({ id: queryId }, (query) => { - if (query) { - this.selectedQuery = query; - if (query.visualizations.length) { - this.selectedVis = query.visualizations[0]; - } - } - }); - } - }; - - // `ng-model-options` does not work with `ng-change`, so do debounce here - this.searchQueries = debounce((term) => { - if (!term || term.length === 0) { - this.searchedQueries = []; - return; - } - - Query.query({ q: term }, (results) => { - // If user will type too quick - it's possible that there will be - // several requests running simultaneously. So we need to check - // which results are matching current search term and ignore - // outdated results. - if (this.searchTerm === term) { - this.searchedQueries = results.results; - } - }); - }, 200); - - this.saveWidget = () => { - this.saveInProgress = true; - - const selectedVis = this.isVisualization ? this.selectedVis : null; - - const widget = new Widget({ - visualization_id: selectedVis && selectedVis.id, - dashboard_id: this.dashboard.id, - options: { - isHidden: false, - position: {}, - }, - visualization: selectedVis, - text: this.isTextBox ? this.text : '', - }); - - const position = this.dashboard.calculateNewWidgetPosition(widget); - widget.options.position.col = position.col; - widget.options.position.row = position.row; - - widget - .save() - .then(() => { - this.dashboard.widgets.push(widget); - this.close(); - }) - .catch(() => { - toastr.error('Widget can not be added'); - }) - .finally(() => { - this.saveInProgress = false; - }); - }; - }, -}; - -export default function init(ngModule) { - ngModule.component('addWidgetDialog', AddWidgetDialog); -} - -init.init = true; diff --git a/client/app/components/dashboards/add-widget-dialog.less b/client/app/components/dashboards/add-widget-dialog.less deleted file mode 100644 index d156595123..0000000000 --- a/client/app/components/dashboards/add-widget-dialog.less +++ /dev/null @@ -1,3 +0,0 @@ -.word-wrap-break { - word-wrap: break-word; -} \ No newline at end of file diff --git a/client/app/components/dashboards/widget.html b/client/app/components/dashboards/widget.html index 1a6a9dbbed..6a37d4676b 100644 --- a/client/app/components/dashboards/widget.html +++ b/client/app/components/dashboards/widget.html @@ -8,16 +8,23 @@ - diff --git a/client/app/components/parameters.js b/client/app/components/parameters.js index 9692c32e28..d0f8b93e8d 100644 --- a/client/app/components/parameters.js +++ b/client/app/components/parameters.js @@ -1,8 +1,6 @@ -import { find, includes, words, capitalize, extend } from 'lodash'; +import { includes, words, capitalize, extend } from 'lodash'; import template from './parameters.html'; -import queryBasedParameterTemplate from './query-based-parameter.html'; import parameterSettingsTemplate from './parameter-settings.html'; -import parameterInputTemplate from './parameter-input.html'; function humanize(str) { return capitalize(words(str).join(' ')); @@ -49,75 +47,6 @@ const ParameterSettingsComponent = { }, }; -function optionsFromQueryResult(queryResult) { - const columns = queryResult.data.columns; - const numColumns = columns.length; - let options = []; - // If there are multiple columns, check if there is a column - // named 'name' and column named 'value'. If name column is present - // in results, use name from name column. Similar for value column. - // Default: Use first string column for name and value. - if (numColumns > 0) { - let nameColumn = null; - let valueColumn = null; - columns.forEach((column) => { - const columnName = column.name.toLowerCase(); - if (columnName === 'name') { - nameColumn = column.name; - } - if (columnName === 'value') { - valueColumn = column.name; - } - // Assign first string column as name and value column. - if (nameColumn === null) { - nameColumn = column.name; - } - if (valueColumn === null) { - valueColumn = column.name; - } - }); - if (nameColumn !== null && valueColumn !== null) { - options = queryResult.data.rows.map((row) => { - const queryResultOption = { - name: row[nameColumn], - value: row[valueColumn], - }; - return queryResultOption; - }); - } - } - return options; -} - -function updateCurrentValue(param, options) { - const found = find(options, option => option.value === param.value) !== undefined; - - if (!found) { - param.value = options[0].value; - } -} - -const QueryBasedParameterComponent = { - template: queryBasedParameterTemplate, - bindings: { - param: '<', - queryId: '<', - }, - controller(Query) { - 'ngInject'; - - this.$onChanges = (changes) => { - if (changes.queryId) { - Query.resultById({ id: this.queryId }, (result) => { - const queryResult = result.query_result; - this.queryResultOptions = optionsFromQueryResult(queryResult); - updateCurrentValue(this.param, this.queryResultOptions); - }); - } - }; - }, -}; - function ParametersDirective($location, $uibModal) { return { restrict: 'E', @@ -160,33 +89,9 @@ function ParametersDirective($location, $uibModal) { }; } -const ParameterInputComponent = { - template: parameterInputTemplate, - bindings: { - param: '<', - }, - controller($scope) { - // These are input as newline delimited values, - // so we split them here. - this.extractEnumOptions = (enumOptions) => { - if (enumOptions) { - return enumOptions.split('\n'); - } - return []; - }; - - $scope.setParamValue = (value) => { - this.param.setValue(value); - $scope.$applyAsync(); - }; - }, -}; - export default function init(ngModule) { ngModule.directive('parameters', ParametersDirective); - ngModule.component('queryBasedParameter', QueryBasedParameterComponent); ngModule.component('parameterSettings', ParameterSettingsComponent); - ngModule.component('parameterInput', ParameterInputComponent); } init.init = true; diff --git a/client/app/components/query-based-parameter.html b/client/app/components/query-based-parameter.html deleted file mode 100644 index 4c553cbc7e..0000000000 --- a/client/app/components/query-based-parameter.html +++ /dev/null @@ -1,2 +0,0 @@ - \ No newline at end of file diff --git a/client/app/components/tags-control/TagsEditorModal.jsx b/client/app/components/tags-control/TagsEditorModal.jsx index 7896f52acb..0b24ad8494 100644 --- a/client/app/components/tags-control/TagsEditorModal.jsx +++ b/client/app/components/tags-control/TagsEditorModal.jsx @@ -2,9 +2,9 @@ import { map, trim, uniq } from 'lodash'; import React from 'react'; import PropTypes from 'prop-types'; import { react2angular } from 'react2angular'; -import { Select } from 'antd'; +import Select from 'antd/lib/select'; -const Option = Select.Option; +const { Option } = Select; class TagsEditorModal extends React.Component { static propTypes = { diff --git a/client/app/lib/highlight.js b/client/app/lib/highlight.js new file mode 100644 index 0000000000..322a66f722 --- /dev/null +++ b/client/app/lib/highlight.js @@ -0,0 +1,8 @@ +function escapeRegexp(queryToEscape) { + return ('' + queryToEscape).replace(/([.?*+^$[\]\\(){}|-])/g, '\\$1'); +} + +// https://github.com/angular-ui/ui-select/blob/master/src/common.js#L146 +export default function highlight(matchItem, query, template = '$&') { + return query && matchItem ? ('' + matchItem).replace(new RegExp(escapeRegexp(query), 'gi'), template) : matchItem; +} diff --git a/client/app/pages/dashboards/dashboard.html b/client/app/pages/dashboards/dashboard.html index 3174a50549..0e5a675f9e 100644 --- a/client/app/pages/dashboards/dashboard.html +++ b/client/app/pages/dashboards/dashboard.html @@ -79,7 +79,7 @@

- +
@@ -105,6 +105,9 @@

- Add Widget +
diff --git a/client/app/pages/dashboards/dashboard.js b/client/app/pages/dashboards/dashboard.js index 2d0c0e422e..9e77d1e57a 100644 --- a/client/app/pages/dashboards/dashboard.js +++ b/client/app/pages/dashboards/dashboard.js @@ -28,6 +28,7 @@ function DashboardCtrl( $timeout, $q, $uibModal, + $scope, Title, AlertDialog, Dashboard, @@ -100,35 +101,18 @@ function DashboardCtrl( }; this.extractGlobalParameters = () => { - let globalParams = {}; - this.dashboard.widgets.forEach((widget) => { - if (widget.getQuery()) { - widget - .getQuery() - .getParametersDefs() - .filter(p => p.global) - .forEach((param) => { - const defaults = {}; - defaults[param.name] = param.clone(); - defaults[param.name].locals = []; - globalParams = _.defaults(globalParams, defaults); - globalParams[param.name].locals.push(param); - }); - } - }); - this.globalParameters = _.values(globalParams); + this.globalParameters = this.dashboard.getParametersDefs(); }; - this.onGlobalParametersChange = () => { - this.globalParameters.forEach((global) => { - global.locals.forEach((local) => { - local.value = global.value; - }); - }); - }; + $scope.$on('dashboard.update-parameters', () => { + this.extractGlobalParameters(); + }); const collectFilters = (dashboard, forceRefresh) => { - const queryResultPromises = _.compact(this.dashboard.widgets.map(widget => widget.load(forceRefresh))); + const queryResultPromises = _.compact(this.dashboard.widgets.map((widget) => { + widget.getParametersDefs(); // Force widget to read parameters values from URL + return widget.load(forceRefresh); + })); $q.all(queryResultPromises).then((queryResults) => { const filters = {}; @@ -334,10 +318,14 @@ function DashboardCtrl( ); }; - this.addWidget = () => { + this.addWidget = (widgetType) => { + const widgetTypes = { + textbox: 'addTextboxDialog', + widget: 'addWidgetDialog', + }; $uibModal .open({ - component: 'addWidgetDialog', + component: widgetTypes[widgetType], resolve: { dashboard: () => this.dashboard, }, diff --git a/client/app/services/dashboard.js b/client/app/services/dashboard.js index 625fb50d51..bb63aea261 100644 --- a/client/app/services/dashboard.js +++ b/client/app/services/dashboard.js @@ -44,7 +44,7 @@ function prepareWidgetsForDashboard(widgets) { return widgets; } -function Dashboard($resource, $http, currentUser, Widget, dashboardGridOptions) { +function Dashboard($resource, $http, $location, currentUser, Widget, dashboardGridOptions) { function prepareDashboardWidgets(widgets) { return prepareWidgetsForDashboard(_.map(widgets, widget => new Widget(widget))); } @@ -151,6 +151,34 @@ function Dashboard($resource, $http, currentUser, Widget, dashboardGridOptions) resource.prepareDashboardWidgets = prepareDashboardWidgets; resource.prepareWidgetsForDashboard = prepareWidgetsForDashboard; + resource.prototype.getParametersDefs = function getParametersDefs() { + const globalParams = {}; + const queryParams = $location.search(); + _.each(this.widgets, (widget) => { + if (widget.getQuery()) { + const mappings = widget.getParameterMappings(); + widget + .getQuery() + .getParametersDefs() + .forEach((param) => { + const mapping = mappings[param.name]; + if (mapping.type === Widget.MappingType.DashboardLevel) { + if (!globalParams[mapping.mapTo]) { + globalParams[mapping.mapTo] = param.clone(); + globalParams[mapping.mapTo].name = mapping.mapTo; + globalParams[mapping.mapTo].title = mapping.title || param.title; + globalParams[mapping.mapTo].locals = []; + } + globalParams[mapping.mapTo].locals.push(param); + } + }); + } + }); + return _.values(_.each(globalParams, (param) => { + param.fromUrlParams(queryParams); + })); + }; + return resource; } diff --git a/client/app/services/query.js b/client/app/services/query.js index 013d2ef5ff..1999787cd9 100644 --- a/client/app/services/query.js +++ b/client/app/services/query.js @@ -3,7 +3,7 @@ import debug from 'debug'; import Mustache from 'mustache'; import { zipObject, isEmpty, map, filter, includes, union, uniq, has, - isNull, isUndefined, isArray, isObject, identity, extend, + isNull, isUndefined, isArray, isObject, identity, extend, each, } from 'lodash'; Mustache.escape = identity; // do not html-escape values @@ -53,12 +53,18 @@ class Parameter { this.name = parameter.name; this.type = parameter.type; this.useCurrentDateTime = parameter.useCurrentDateTime; - this.global = parameter.global; + this.global = parameter.global; // backward compatibility in Widget service this.enumOptions = parameter.enumOptions; this.queryId = parameter.queryId; + // Used for meta-parameters (i.e. dashboard-level params) + this.locals = []; + // validate value and init internal state this.setValue(parameter.value); + + // Used for URL serialization + this.urlPrefix = 'p_'; } clone() { @@ -121,6 +127,12 @@ class Parameter { this.value = value; this.$$value = value; } + + if (isArray(this.locals)) { + each(this.locals, (local) => { + local.setValue(this.value); + }); + } } get normalizedValue() { @@ -139,26 +151,28 @@ class Parameter { if (this.isEmpty) { return {}; } + const prefix = this.urlPrefix; if (isDateRangeParameter(this.type)) { return { - [`p_${this.name}.start`]: this.value.start, - [`p_${this.name}.end`]: this.value.end, + [`${prefix}${this.name}.start`]: this.value.start, + [`${prefix}${this.name}.end`]: this.value.end, }; } return { - [`p_${this.name}`]: this.value, + [`${prefix}${this.name}`]: this.value, }; } fromUrlParams(query) { + const prefix = this.urlPrefix; if (isDateRangeParameter(this.type)) { - const keyStart = `p_${this.name}.start`; - const keyEnd = `p_${this.name}.end`; + const keyStart = `${prefix}${this.name}.start`; + const keyEnd = `${prefix}${this.name}.end`; if (has(query, keyStart) && has(query, keyEnd)) { this.setValue([query[keyStart], query[keyEnd]]); } } else { - const key = `p_${this.name}`; + const key = `${prefix}${this.name}`; if (has(query, key)) { this.setValue(query[key]); } diff --git a/client/app/services/widget.js b/client/app/services/widget.js index a4df4b1f95..9966689882 100644 --- a/client/app/services/widget.js +++ b/client/app/services/widget.js @@ -1,5 +1,5 @@ import moment from 'moment'; -import { each, pick, extend, isObject, truncate } from 'lodash'; +import { each, pick, extend, isObject, truncate, keys, difference, filter, map } from 'lodash'; function calculatePositionOptions(Visualization, dashboardGridOptions, widget) { widget.width = 1; // Backward compatibility, user on back-end @@ -61,8 +61,16 @@ function calculatePositionOptions(Visualization, dashboardGridOptions, widget) { return visualizationOptions; } -function WidgetFactory($http, Query, Visualization, dashboardGridOptions) { +export const ParameterMappingType = { + DashboardLevel: 'dashboard-level', + WidgetLevel: 'widget-level', + StaticValue: 'static-value', +}; + +function WidgetFactory($http, $location, Query, Visualization, dashboardGridOptions) { class Widget { + static MappingType = ParameterMappingType; + constructor(data) { // Copy properties each(data, (v, k) => { @@ -159,6 +167,76 @@ function WidgetFactory($http, Query, Visualization, dashboardGridOptions) { const url = `api/widgets/${this.id}`; return $http.delete(url); } + + isStaticParam(param) { + const mappings = this.getParameterMappings(); + const mappingType = mappings[param.name].type; + return mappingType === Widget.MappingType.StaticValue; + } + + getParametersDefs() { + const mappings = this.getParameterMappings(); + // textboxes does not have query + const params = this.getQuery() ? this.getQuery().getParametersDefs() : []; + + const queryParams = $location.search(); + + const localTypes = [ + Widget.MappingType.WidgetLevel, + Widget.MappingType.StaticValue, + ]; + return map( + filter(params, param => localTypes.indexOf(mappings[param.name].type) >= 0), + (param) => { + const mapping = mappings[param.name]; + const result = param.clone(); + result.title = mapping.title || param.title; + result.locals = [param]; + result.urlPrefix = `w${this.id}_`; + if (mapping.type === Widget.MappingType.StaticValue) { + result.setValue(mapping.value); + } else { + result.fromUrlParams(queryParams); + } + return result; + }, + ); + } + + getParameterMappings() { + if (!isObject(this.options.parameterMappings)) { + this.options.parameterMappings = {}; + } + + const existingParams = {}; + // textboxes does not have query + const params = this.getQuery() ? this.getQuery().getParametersDefs() : []; + each(params, (param) => { + existingParams[param.name] = true; + if (!isObject(this.options.parameterMappings[param.name])) { + // "migration" for old dashboards: parameters with `global` flag + // should be mapped to a dashboard-level parameter with the same name + this.options.parameterMappings[param.name] = { + name: param.name, + type: param.global ? Widget.MappingType.DashboardLevel : Widget.MappingType.WidgetLevel, + mapTo: param.name, // map to param with the same name + value: null, // for StaticValue + title: '', // Use parameter's title + }; + } + }); + + // Remove mappings for parameters that do not exists anymore + const removedParams = difference( + keys(this.options.parameterMappings), + keys(existingParams), + ); + each(removedParams, (name) => { + delete this.options.parameterMappings[name]; + }); + + return this.options.parameterMappings; + } } return Widget;