diff --git a/client/app/assets/less/inc/ant-variables.less b/client/app/assets/less/inc/ant-variables.less index 428990094a..99e379a841 100644 --- a/client/app/assets/less/inc/ant-variables.less +++ b/client/app/assets/less/inc/ant-variables.less @@ -19,6 +19,12 @@ @font-size-base: 13px; +/* -------------------------------------------------------- + Borders +-----------------------------------------------------------*/ +@border-color-split: #f0f0f0; + + /* -------------------------------------------------------- Typograpgy -----------------------------------------------------------*/ @@ -77,4 +83,4 @@ Notification -----------------------------------------------------------*/ @notification-padding: @notification-padding-vertical 48px @notification-padding-vertical 17px; -@notification-width: auto; \ No newline at end of file +@notification-width: auto; diff --git a/client/app/components/DateInput.jsx b/client/app/components/DateInput.jsx index aeae70a35c..80b97660f1 100644 --- a/client/app/components/DateInput.jsx +++ b/client/app/components/DateInput.jsx @@ -1,17 +1,16 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { react2angular } from 'react2angular'; import DatePicker from 'antd/lib/date-picker'; import { clientConfig } from '@/services/auth'; import { Moment } from '@/components/proptypes'; -export function DateInput({ +const DateInput = React.forwardRef(({ defaultValue, value, onSelect, className, ...props -}) { +}, ref) => { const format = clientConfig.dateFormat || 'YYYY-MM-DD'; const additionalAttributes = {}; if (defaultValue && defaultValue.isValid()) { @@ -22,6 +21,7 @@ export function DateInput({ } return ( ); -} +}); DateInput.propTypes = { defaultValue: Moment, @@ -46,8 +46,4 @@ DateInput.defaultProps = { className: '', }; -export default function init(ngModule) { - ngModule.component('dateInput', react2angular(DateInput)); -} - -init.init = true; +export default DateInput; diff --git a/client/app/components/DateRangeInput.jsx b/client/app/components/DateRangeInput.jsx index cc67c2ef80..0cdd9e6a3b 100644 --- a/client/app/components/DateRangeInput.jsx +++ b/client/app/components/DateRangeInput.jsx @@ -1,20 +1,19 @@ import { isArray } from 'lodash'; import React from 'react'; import PropTypes from 'prop-types'; -import { react2angular } from 'react2angular'; import DatePicker from 'antd/lib/date-picker'; import { clientConfig } from '@/services/auth'; import { Moment } from '@/components/proptypes'; const { RangePicker } = DatePicker; -export function DateRangeInput({ +const DateRangeInput = React.forwardRef(({ defaultValue, value, onSelect, className, ...props -}) { +}, ref) => { const format = clientConfig.dateFormat || 'YYYY-MM-DD'; const additionalAttributes = {}; if (isArray(defaultValue) && defaultValue[0].isValid() && defaultValue[1].isValid()) { @@ -25,6 +24,7 @@ export function DateRangeInput({ } return ( ); -} +}); DateRangeInput.propTypes = { defaultValue: PropTypes.arrayOf(Moment), @@ -48,8 +48,4 @@ DateRangeInput.defaultProps = { className: '', }; -export default function init(ngModule) { - ngModule.component('dateRangeInput', react2angular(DateRangeInput)); -} - -init.init = true; +export default DateRangeInput; diff --git a/client/app/components/DateTimeInput.jsx b/client/app/components/DateTimeInput.jsx index db06a0dc48..8cb60d397d 100644 --- a/client/app/components/DateTimeInput.jsx +++ b/client/app/components/DateTimeInput.jsx @@ -1,18 +1,17 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { react2angular } from 'react2angular'; import DatePicker from 'antd/lib/date-picker'; import { clientConfig } from '@/services/auth'; import { Moment } from '@/components/proptypes'; -export function DateTimeInput({ +const DateTimeInput = React.forwardRef(({ defaultValue, value, withSeconds, onSelect, className, ...props -}) { +}, ref) => { const format = (clientConfig.dateFormat || 'YYYY-MM-DD') + (withSeconds ? ' HH:mm:ss' : ' HH:mm'); const additionalAttributes = {}; @@ -24,6 +23,7 @@ export function DateTimeInput({ } return ( ); -} +}); DateTimeInput.propTypes = { defaultValue: Moment, @@ -51,8 +51,4 @@ DateTimeInput.defaultProps = { className: '', }; -export default function init(ngModule) { - ngModule.component('dateTimeInput', react2angular(DateTimeInput)); -} - -init.init = true; +export default DateTimeInput; diff --git a/client/app/components/DateTimeRangeInput.jsx b/client/app/components/DateTimeRangeInput.jsx index 0263409268..f019a3a86b 100644 --- a/client/app/components/DateTimeRangeInput.jsx +++ b/client/app/components/DateTimeRangeInput.jsx @@ -1,21 +1,20 @@ import { isArray } from 'lodash'; import React from 'react'; import PropTypes from 'prop-types'; -import { react2angular } from 'react2angular'; import DatePicker from 'antd/lib/date-picker'; import { clientConfig } from '@/services/auth'; import { Moment } from '@/components/proptypes'; const { RangePicker } = DatePicker; -export function DateTimeRangeInput({ +const DateTimeRangeInput = React.forwardRef(({ defaultValue, value, withSeconds, onSelect, className, ...props -}) { +}, ref) => { const format = (clientConfig.dateFormat || 'YYYY-MM-DD') + (withSeconds ? ' HH:mm:ss' : ' HH:mm'); const additionalAttributes = {}; @@ -27,6 +26,7 @@ export function DateTimeRangeInput({ } return ( ); -} +}); DateTimeRangeInput.propTypes = { defaultValue: PropTypes.arrayOf(Moment), @@ -53,8 +53,4 @@ DateTimeRangeInput.defaultProps = { className: '', }; -export default function init(ngModule) { - ngModule.component('dateTimeRangeInput', react2angular(DateTimeRangeInput)); -} - -init.init = true; +export default DateTimeRangeInput; diff --git a/client/app/components/EditParameterSettingsDialog.jsx b/client/app/components/EditParameterSettingsDialog.jsx index 503e5ae477..7db1ee3003 100644 --- a/client/app/components/EditParameterSettingsDialog.jsx +++ b/client/app/components/EditParameterSettingsDialog.jsx @@ -2,6 +2,7 @@ import { includes, words, capitalize, clone, isNull } from 'lodash'; import React, { useState, useEffect } from 'react'; import PropTypes from 'prop-types'; +import Checkbox from 'antd/lib/checkbox'; import Modal from 'antd/lib/modal'; import Form from 'antd/lib/form'; import Button from 'antd/lib/button'; @@ -23,6 +24,13 @@ function isTypeDateRange(type) { return /-range/.test(type); } +function joinExampleList(multiValuesOptions) { + const { prefix, suffix } = multiValuesOptions; + return ['value1', 'value2', 'value3'] + .map(value => `${prefix}${value}${suffix}`) + .join(','); +} + function NameInput({ name, type, onChange, existingNames, setValidation }) { let helpText = ''; let validateStatus = ''; @@ -185,6 +193,48 @@ function EditParameterSettingsDialog(props) { /> )} + {(param.type === 'enum' || param.type === 'query') && ( + + setParam({ ...param, + multiValuesOptions: e.target.checked ? { + prefix: '', + suffix: '', + separator: ',', + } : null })} + data-test="AllowMultipleValuesCheckbox" + > + Allow multiple values + + + )} + {(param.type === 'enum' || param.type === 'query') && param.multiValuesOptions && ( + + Placed in query as: {joinExampleList(param.multiValuesOptions)} + + )} + {...formItemProps} + > + + + )} ); diff --git a/client/app/components/HtmlContent.jsx b/client/app/components/HtmlContent.jsx new file mode 100644 index 0000000000..85c30808d5 --- /dev/null +++ b/client/app/components/HtmlContent.jsx @@ -0,0 +1,20 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { $sanitize } from '@/services/ng'; + +export default function HtmlContent({ children, ...props }) { + return ( +
+ ); +} + +HtmlContent.propTypes = { + children: PropTypes.string, +}; + +HtmlContent.defaultProps = { + children: '', +}; diff --git a/client/app/components/ParameterValueInput.jsx b/client/app/components/ParameterValueInput.jsx index 05683f5a3a..dcc9e261f9 100644 --- a/client/app/components/ParameterValueInput.jsx +++ b/client/app/components/ParameterValueInput.jsx @@ -13,6 +13,12 @@ import './ParameterValueInput.less'; const { Option } = Select; +const multipleValuesProps = { + maxTagCount: 3, + maxTagTextLength: 10, + maxTagPlaceholder: num => `+${num.length} more`, +}; + export class ParameterValueInput extends React.Component { static propTypes = { type: PropTypes.string, @@ -20,6 +26,7 @@ export class ParameterValueInput extends React.Component { enumOptions: PropTypes.string, queryId: PropTypes.number, parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types + allowMultipleValues: PropTypes.bool, onSelect: PropTypes.func, className: PropTypes.string, }; @@ -30,6 +37,7 @@ export class ParameterValueInput extends React.Component { enumOptions: '', queryId: null, parameter: null, + allowMultipleValues: false, onSelect: () => {}, className: '', }; @@ -88,20 +96,23 @@ export class ParameterValueInput extends React.Component { } renderEnumInput() { - const { value, enumOptions } = this.props; + const { enumOptions, allowMultipleValues } = this.props; + const { value } = this.state; const enumOptionsArray = enumOptions.split('\n').filter(v => v !== ''); return ( @@ -109,15 +120,19 @@ export class ParameterValueInput extends React.Component { } renderQueryBasedInput() { - const { queryId, parameter } = this.props; + const { queryId, parameter, allowMultipleValues } = this.props; const { value } = this.state; return ( ); } @@ -187,6 +202,7 @@ export default function init(ngModule) { parameter="$ctrl.param" enum-options="$ctrl.param.enumOptions" query-id="$ctrl.param.queryId" + allow-multiple-values="!!$ctrl.param.multiValuesOptions" on-select="$ctrl.setValue" > `, diff --git a/client/app/components/QueryBasedParameterInput.jsx b/client/app/components/QueryBasedParameterInput.jsx index 973929688f..28bf772975 100644 --- a/client/app/components/QueryBasedParameterInput.jsx +++ b/client/app/components/QueryBasedParameterInput.jsx @@ -1,4 +1,4 @@ -import { find, isFunction, toString } from 'lodash'; +import { find, isFunction, isArray, isEqual, toString, map, intersection } from 'lodash'; import React from 'react'; import PropTypes from 'prop-types'; import { react2angular } from 'react2angular'; @@ -10,6 +10,7 @@ export class QueryBasedParameterInput extends React.Component { static propTypes = { parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types value: PropTypes.any, // eslint-disable-line react/forbid-prop-types + mode: PropTypes.oneOf(['default', 'multiple']), queryId: PropTypes.number, onSelect: PropTypes.func, className: PropTypes.string, @@ -17,6 +18,7 @@ export class QueryBasedParameterInput extends React.Component { static defaultProps = { value: null, + mode: 'default', parameter: null, queryId: null, onSelect: () => {}, @@ -50,16 +52,24 @@ export class QueryBasedParameterInput extends React.Component { if (this.props.queryId === queryId) { 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); + if (this.props.mode === 'multiple' && isArray(this.props.value)) { + const optionValues = map(options, option => option.value); + const validValues = intersection(this.props.value, optionValues); + if (!isEqual(this.props.value, validValues)) { + this.props.onSelect(validValues); + } + } else { + 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 { className, value, mode, onSelect, ...otherProps } = this.props; const { loading, options } = this.state; return ( @@ -67,14 +77,15 @@ export class QueryBasedParameterInput extends React.Component { className={className} disabled={loading || (options.length === 0)} loading={loading} - value={toString(value)} + mode={mode} + value={isArray(value) ? value : toString(value)} onChange={onSelect} dropdownMatchSelectWidth={false} dropdownClassName="ant-dropdown-in-bootstrap-modal" - showSearch - style={{ minWidth: 60 }} optionFilterProp="children" + showSearch notFoundContent={null} + {...otherProps} > {options.map(option => ())} diff --git a/client/app/components/dashboards/TextboxDialog.jsx b/client/app/components/dashboards/TextboxDialog.jsx index 2d970565d4..b85509a822 100644 --- a/client/app/components/dashboards/TextboxDialog.jsx +++ b/client/app/components/dashboards/TextboxDialog.jsx @@ -6,6 +6,7 @@ import Modal from 'antd/lib/modal'; import Input from 'antd/lib/input'; import Tooltip from 'antd/lib/tooltip'; import Divider from 'antd/lib/divider'; +import HtmlContent from '@/components/HtmlContent'; import { wrap as wrapDialog, DialogPropType } from '@/components/DialogWrapper'; import notification from '@/services/notification'; @@ -100,10 +101,7 @@ class TextboxDialog extends React.Component { Preview: -

+ {this.state.preview} )}

diff --git a/client/app/components/dashboards/widget.html b/client/app/components/dashboards/widget.html index f9af6fc3d2..65c65ef381 100644 --- a/client/app/components/dashboards/widget.html +++ b/client/app/components/dashboards/widget.html @@ -65,8 +65,8 @@
- - + + @@ -76,7 +76,9 @@ {{$ctrl.widget.getQueryResult().getUpdatedAt() | dateTime}} - +
diff --git a/client/app/components/dashboards/widget.js b/client/app/components/dashboards/widget.js index 9d6a4e73bb..95b9d6380d 100644 --- a/client/app/components/dashboards/widget.js +++ b/client/app/components/dashboards/widget.js @@ -89,11 +89,14 @@ function DashboardWidgetCtrl($scope, $location, $uibModal, $window, $rootScope, this.load = (refresh = false) => { const maxAge = $location.search().maxAge; - this.widget.load(refresh, maxAge); + return this.widget.load(refresh, maxAge); }; - this.refresh = () => { - this.load(true); + this.refresh = (buttonId) => { + this.refreshClickButtonId = buttonId; + this.load(true).finally(() => { + this.refreshClickButtonId = undefined; + }); }; if (this.widget.visualization) { diff --git a/client/app/components/dynamic-parameters/DateParameter.jsx b/client/app/components/dynamic-parameters/DateParameter.jsx index f9b8d25f2c..0776196eca 100644 --- a/client/app/components/dynamic-parameters/DateParameter.jsx +++ b/client/app/components/dynamic-parameters/DateParameter.jsx @@ -4,8 +4,8 @@ import classNames from 'classnames'; import moment from 'moment'; import { includes } from 'lodash'; import { isDynamicDate, getDynamicDate } from '@/services/query'; -import { DateInput } from '@/components/DateInput'; -import { DateTimeInput } from '@/components/DateTimeInput'; +import DateInput from '@/components/DateInput'; +import DateTimeInput from '@/components/DateTimeInput'; import DynamicButton from '@/components/dynamic-parameters/DynamicButton'; import './DynamicParameters.less'; @@ -36,6 +36,11 @@ class DateParameter extends React.Component { onSelect: () => {}, }; + constructor(props) { + super(props); + this.dateComponentRef = React.createRef(); + } + onDynamicValueSelect = (dynamicValue) => { const { onSelect, parameter } = this.props; if (dynamicValue === 'static') { @@ -48,6 +53,8 @@ class DateParameter extends React.Component { } else { onSelect(dynamicValue.value); } + // give focus to the DatePicker to get keyboard shortcuts to work + this.dateComponentRef.current.focus(); }; render() { @@ -77,6 +84,7 @@ class DateParameter extends React.Component { return ( {}, }; + constructor(props) { + super(props); + this.dateRangeComponentRef = React.createRef(); + } + onDynamicValueSelect = (dynamicValue) => { const { onSelect, parameter } = this.props; if (dynamicValue === 'static') { @@ -77,6 +82,8 @@ class DateRangeParameter extends React.Component { } else { onSelect(dynamicValue.value); } + // give focus to the DatePicker to get keyboard shortcuts to work + this.dateRangeComponentRef.current.focus(); }; render() { @@ -107,6 +114,7 @@ class DateRangeParameter extends React.Component { return ( ); + const containerRef = useRef(null); + return ( - e.stopPropagation()}> - - )} - data-test="DynamicButton" - /> - + ); } diff --git a/client/app/components/dynamic-table/default-cell/index.js b/client/app/components/dynamic-table/default-cell/index.js deleted file mode 100644 index 34a6732fcc..0000000000 --- a/client/app/components/dynamic-table/default-cell/index.js +++ /dev/null @@ -1,47 +0,0 @@ -import { includes, identity } from 'lodash'; -import { renderDefault, renderImage, renderLink } from './utils'; -import template from './template.html'; - -const renderFunctions = { - image: renderImage, - link: renderLink, -}; - -export default function init(ngModule) { - ngModule.directive('dynamicTableDefaultCell', $sanitize => ({ - template, - restrict: 'E', - replace: true, - scope: { - column: '=', - row: '=', - }, - link: ($scope) => { - // `dynamicTable` will recreate all table cells if some columns changed. - // This means two things: - // 1. `column` object will be always "fresh" - no need to watch it. - // 2. we will always have a column object already available in `link` function. - // Note that `row` may change during this directive's lifetime. - - if ($scope.column.displayAs === 'string') { - $scope.allowHTML = $scope.column.allowHTML; - } else { - $scope.allowHTML = includes(['image', 'link'], $scope.column.displayAs); - } - - const sanitize = $scope.allowHTML ? $sanitize : identity; - - const renderValue = renderFunctions[$scope.column.displayAs] || renderDefault; - - $scope.value = sanitize(renderValue($scope.column, $scope.row)); - - $scope.$watch('row', (newValue, oldValue) => { - if (newValue !== oldValue) { - $scope.value = sanitize(renderValue($scope.column, $scope.row)); - } - }); - }, - })); -} - -init.init = true; diff --git a/client/app/components/dynamic-table/default-cell/template.html b/client/app/components/dynamic-table/default-cell/template.html deleted file mode 100644 index aa85e06f35..0000000000 --- a/client/app/components/dynamic-table/default-cell/template.html +++ /dev/null @@ -1,4 +0,0 @@ - -
-
- diff --git a/client/app/components/dynamic-table/default-cell/utils.js b/client/app/components/dynamic-table/default-cell/utils.js deleted file mode 100644 index beb697154d..0000000000 --- a/client/app/components/dynamic-table/default-cell/utils.js +++ /dev/null @@ -1,66 +0,0 @@ -import { isFunction, extend } from 'lodash'; -import { formatSimpleTemplate } from '@/lib/value-format'; - -function trim(str) { - return str.replace(/^\s+|\s+$/g, ''); -} - -function processTags(str, data, defaultColumn) { - return formatSimpleTemplate(str, extend({ - '@': data[defaultColumn], - }, data)); -} - -export function renderDefault(column, row) { - const value = row[column.name]; - if (isFunction(column.formatFunction)) { - return column.formatFunction(value); - } - return value; -} - -export function renderImage(column, row) { - const url = trim(processTags(column.imageUrlTemplate, row, column.name)); - const width = parseInt(processTags(column.imageWidth, row, column.name), 10); - const height = parseInt(processTags(column.imageHeight, row, column.name), 10); - const title = trim(processTags(column.imageTitleTemplate, row, column.name)); - - const result = []; - if (url !== '') { - result.push(' 0)) { - result.push('width="' + width + '"'); - } - if (isFinite(height) && (height > 0)) { - result.push('height="' + height + '"'); - } - if (title !== '') { - result.push('title="' + title + '"'); - } - - result.push('>'); - } - - return result.join(' '); -} - -export function renderLink(column, row) { - const url = trim(processTags(column.linkUrlTemplate, row, column.name)); - const title = trim(processTags(column.linkTitleTemplate, row, column.name)); - const text = trim(processTags(column.linkTextTemplate, row, column.name)); - - const result = []; - if (url !== '') { - result.push('' + (text === '' ? url : text) + ''); - } - - return result.join(' '); -} diff --git a/client/app/components/dynamic-table/dynamic-table-row.js b/client/app/components/dynamic-table/dynamic-table-row.js deleted file mode 100644 index 8f87d1c046..0000000000 --- a/client/app/components/dynamic-table/dynamic-table-row.js +++ /dev/null @@ -1,30 +0,0 @@ -import { isFunction } from 'lodash'; - -export default function init(ngModule) { - ngModule.directive('dynamicTableRow', () => ({ - template: '', - // AngularJS has a strange love to table-related tags, therefore - // we should use this directive as an attribute - restrict: 'A', - replace: false, - scope: { - columns: '=', - row: '=', - render: '=', - }, - link: ($scope, $element) => { - $scope.$watch('render', () => { - if (isFunction($scope.render)) { - $scope.render($scope, (clonedElement) => { - $element - .empty() - .append(clonedElement) - .append(''); - }); - } - }); - }, - })); -} - -init.init = true; diff --git a/client/app/components/dynamic-table/dynamic-table.html b/client/app/components/dynamic-table/dynamic-table.html deleted file mode 100644 index ad4c516dd0..0000000000 --- a/client/app/components/dynamic-table/dynamic-table.html +++ /dev/null @@ -1,35 +0,0 @@ -
- - - - - - - - - - - - - -
- {{ $ctrl.orderByColumnsIndex[column.name] }} - {{column.title}} - -
- -
-
- diff --git a/client/app/components/dynamic-table/dynamic-table.less b/client/app/components/dynamic-table/dynamic-table.less deleted file mode 100644 index 7594deb178..0000000000 --- a/client/app/components/dynamic-table/dynamic-table.less +++ /dev/null @@ -1,64 +0,0 @@ -.dynamic-table-container { - &[data-has-pagination="true"] { - overflow-x: auto; // enable internal scroll so pagination stays put - } - - th { - white-space: nowrap; - span { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .sort-order-indicator { - @size: 12px; - - display: inline-block; - vertical-align: middle; - min-width: @size; - height: @size; - font-size: @size * 3/4; - border-radius: @size / 2; - background: #c0c0c0; - text-align: center; - line-height: @size; - color: #fff; - padding: 0 @size * 1/4; - } - } - - th, td { - &.content-align-left { - text-align: left; - } - &.content-align-right { - text-align: right; - } - &.content-align-center { - text-align: center; - } - } - - .table { - > thead, - > tbody, - > tfoot { - > tr { - > th.dynamic-table-spacer, - > td.dynamic-table-spacer { - padding-left: 0; - padding-right: 5px; - } - } - } - } - - .display-as-number, - .display-as-boolean, - .display-as-datetime, - .display-as-image { - width: 1%; - white-space: nowrap; - } -} diff --git a/client/app/components/dynamic-table/index.js b/client/app/components/dynamic-table/index.js deleted file mode 100644 index a18ce0a86c..0000000000 --- a/client/app/components/dynamic-table/index.js +++ /dev/null @@ -1,257 +0,0 @@ -import { find, filter, map, each } from 'lodash'; -import template from './dynamic-table.html'; -import './dynamic-table.less'; - -function isNullOrUndefined(v) { - return v === null || v === undefined; -} - -function filterRows(rows, searchTerm, columns) { - if (searchTerm === '' || columns.length === 0 || rows.length === 0) { - return rows; - } - searchTerm = searchTerm.toUpperCase(); - return filter(rows, (row) => { - for (let i = 0; i < columns.length; i += 1) { - const columnName = columns[i].name; - const formatFunction = columns[i].formatFunction; - if (row[columnName] !== undefined) { - let value = formatFunction ? formatFunction(row[columnName]) : row[columnName]; - value = ('' + value).toUpperCase(); - if (value.indexOf(searchTerm) >= 0) { - return true; - } - } - } - return false; - }); -} - -function sortRows(rows, orderBy) { - if (orderBy.length === 0 || rows.length === 0) { - return rows; - } - // Create a copy of array before sorting, because .sort() will modify original array - return [].concat(rows).sort((a, b) => { - let va; - let vb; - for (let i = 0; i < orderBy.length; i += 1) { - va = a[orderBy[i].name]; - vb = b[orderBy[i].name]; - if (isNullOrUndefined(va) || va < vb) { - // if a < b - we should return -1, but take in account direction - return orderBy[i].direction * -1; - } - if (va > vb || isNullOrUndefined(vb)) { - // if a > b - we should return 1, but take in account direction - return orderBy[i].direction * 1; - } - } - return 0; - }); -} - -function validateItemsPerPage(value, defaultValue) { - defaultValue = defaultValue || 25; - value = parseInt(value, 10) || defaultValue; - return value > 0 ? value : defaultValue; -} - -// Optimized rendering -// Instead of using two nested `ng-repeat`s by rows and columns, -// we'll create a template for row (and update it when columns changed), -// compile it, and then use `ng-repeat` by rows and bind this template -// to each row's scope. The goal is to reduce amount of scopes and watchers -// from `count(rows) * count(cols)` to `count(rows)`. The major disadvantage -// is that cell markup should be specified here instead of template. -function createRowRenderTemplate(columns, $compile) { - const rowTemplate = map(columns, (column, index) => { - switch (column.displayAs) { - case 'json': - return ` - - `; - default: - return ` - - `; - } - }).join(''); - return $compile(rowTemplate); -} - -class DynamicTablePaginatorAdapter { - constructor($ctrl) { - this.$ctrl = $ctrl; - } - - get page() { - return this.$ctrl.currentPage; - } - - get itemsPerPage() { - return this.$ctrl.itemsPerPage; - } - - get totalCount() { - return this.$ctrl.preparedRows.length; - } - - get hasPagination() { - return this.totalCount > this.itemsPerPage; // same condition as in Paginator.jsx - } - - setPage(page) { - this.$ctrl.onPageChanged(page); - } -} - -function DynamicTable($scope, $compile) { - 'ngInject'; - - this.paginatorAdapter = new DynamicTablePaginatorAdapter(this); - - this.itemsPerPage = validateItemsPerPage(this.itemsPerPage); - this.currentPage = 1; - this.searchTerm = ''; - - this.columns = []; - this.rows = []; - this.preparedRows = []; - this.rowsToDisplay = []; - this.orderBy = []; - this.orderByColumnsIndex = {}; - this.orderByColumnsDirection = {}; - - this.searchColumns = []; - - const updateOrderByColumnsInfo = () => { - this.orderByColumnsIndex = {}; - this.orderByColumnsDirection = {}; - each(this.orderBy, (column, index) => { - this.orderByColumnsIndex[column.name] = index + 1; - this.orderByColumnsDirection[column.name] = column.direction; - }); - }; - - const updateRowsToDisplay = (performFilterAndSort) => { - if (performFilterAndSort) { - this.preparedRows = sortRows(filterRows(this.rows, this.searchTerm, this.searchColumns), this.orderBy); - } - const first = (this.currentPage - 1) * this.itemsPerPage; - const last = first + this.itemsPerPage; - this.rowsToDisplay = this.preparedRows.slice(first, last); - }; - - const setColumns = (columns) => { - // 1. reset sorting - // 2. reset current page - // 3. reset search - // 4. get columns for search - // 5. update row rendering template - // 6. prepare rows - - this.columns = columns; - updateOrderByColumnsInfo(); - this.orderBy = []; - this.currentPage = 1; - this.searchTerm = ''; - this.searchColumns = filter(this.columns, 'allowSearch'); - this.renderSingleRow = createRowRenderTemplate(this.columns, $compile); - updateRowsToDisplay(true); - }; - - const setRows = (rows) => { - // 1. reset current page - // 2. prepare rows - - this.rows = rows; - this.currentPage = 1; - updateRowsToDisplay(true); - }; - - this.renderSingleRow = null; - - this.onColumnHeaderClick = ($event, column) => { - const orderBy = find(this.orderBy, item => item.name === column.name); - if (orderBy) { - // ASC -> DESC -> off - if (orderBy.direction === 1) { - orderBy.direction = -1; - if (!$event.shiftKey) { - this.orderBy = [orderBy]; - } - } else { - if ($event.shiftKey) { - this.orderBy = filter(this.orderBy, item => item.name !== column.name); - } else { - this.orderBy = []; - } - } - } else { - if (!$event.shiftKey) { - this.orderBy = []; - } - this.orderBy.push({ - name: column.name, - direction: 1, - }); - } - updateOrderByColumnsInfo(); - updateRowsToDisplay(true); - - // Remove text selection - may occur accidentally - if ($event.shiftKey) { - document.getSelection().removeAllRanges(); - } - }; - - this.onPageChanged = (page) => { - this.currentPage = page; - updateRowsToDisplay(false); - $scope.$applyAsync(); - }; - - this.onSearchTermChanged = () => { - this.preparedRows = sortRows(filterRows(this.rows, this.searchTerm, this.searchColumns), this.orderBy); - this.currentPage = 1; - updateRowsToDisplay(true); - }; - - this.$onChanges = (changes) => { - if (changes.columns) { - if (changes.rows) { - // if rows also changed - temporarily set if to empty array - to avoid - // filtering and sorting - this.rows = []; - } - setColumns(changes.columns.currentValue); - } - - if (changes.rows) { - setRows(changes.rows.currentValue); - } - - if (changes.itemsPerPage) { - this.itemsPerPage = validateItemsPerPage(this.itemsPerPage); - this.currentPage = 1; - updateRowsToDisplay(false); - } - }; -} - -export default function init(ngModule) { - ngModule.component('dynamicTable', { - template, - controller: DynamicTable, - bindings: { - rows: '<', - columns: '<', - itemsPerPage: '<', - }, - }); -} - -init.init = true; diff --git a/client/app/components/dynamic-table/json-cell/index.js b/client/app/components/dynamic-table/json-cell/index.js deleted file mode 100644 index 200825dac3..0000000000 --- a/client/app/components/dynamic-table/json-cell/index.js +++ /dev/null @@ -1,44 +0,0 @@ -import { isUndefined, isString } from 'lodash'; -import renderJsonView from './json-view-interactive'; -import template from './template.html'; - -function parseValue(value, clientConfig) { - if (isString(value) && value.length <= clientConfig.tableCellMaxJSONSize) { - try { - return JSON.parse(value); - } catch (e) { - return undefined; - } - } -} - -function DynamicTableJsonCell(clientConfig) { - return { - template, - restrict: 'E', - replace: true, - scope: { - column: '=', - value: '=', - }, - link: ($scope, $element) => { - const container = $element.find('.json-cell-valid'); - - $scope.isValid = false; - $scope.parsedValue = null; - - $scope.$watch('value', () => { - $scope.parsedValue = parseValue($scope.value, clientConfig); - $scope.isValid = !isUndefined($scope.parsedValue); - container.empty(); - renderJsonView(container, $scope.parsedValue); - }); - }, - }; -} - -export default function init(ngModule) { - ngModule.directive('dynamicTableJsonCell', DynamicTableJsonCell); -} - -init.init = true; diff --git a/client/app/components/dynamic-table/json-cell/json-view-interactive.js b/client/app/components/dynamic-table/json-cell/json-view-interactive.js deleted file mode 100644 index 4c11f9165d..0000000000 --- a/client/app/components/dynamic-table/json-cell/json-view-interactive.js +++ /dev/null @@ -1,176 +0,0 @@ -import { isFunction, isArray, isObject, isString, isNumber, isUndefined, each, keys, filter } from 'lodash'; -import $ from 'jquery'; -import './json-view-interactive.less'; - -function isPrimitive(value) { - return (value === null) || (value === false) || (value === true) || - (isNumber(value) && isFinite(value)); -} - -function combine(...functions) { - functions = filter(functions, isFunction); - return (...args) => { - each(functions, (fn) => { - fn(...args); - }); - }; -} - -function initToggle(toggle, toggleBlockFn) { - if (isFunction(toggleBlockFn)) { - let visible = false; - const icon = $('').addClass('fa fa-caret-right').appendTo(toggle.empty()); - toggleBlockFn(visible); - toggle.on('click', () => { - visible = !visible; - icon.toggleClass('fa-caret-right fa-caret-down'); - toggleBlockFn(visible); - }); - } else { - toggle.addClass('hidden'); - } -} - -function createRenderNestedBlock(block, ellipsis, values, renderKeys) { - return (show) => { - if (show) { - ellipsis.addClass('hidden'); - block.removeClass('hidden').empty(); - - let firstItem = null; - let lastItem = null; - each(values, (val, key) => { - const nestedBlock = $('').addClass('jvi-item').appendTo(block); - firstItem = firstItem || nestedBlock; - lastItem = nestedBlock; - - const toggle = $('').addClass('jvi-toggle').appendTo(nestedBlock); - - if (renderKeys) { - const keyWrapper = $('').addClass('jvi-object-key').appendTo(nestedBlock); - // eslint-disable-next-line no-use-before-define - renderString(keyWrapper, key); - $('').addClass('jvi-punctuation').text(': ').appendTo(nestedBlock); - } - // eslint-disable-next-line no-use-before-define - const toggleBlockFn = renderValue(nestedBlock, val, true); - initToggle(toggle, toggleBlockFn); - }); - - if (firstItem) { - firstItem.addClass('jvi-nested-first'); - } - if (lastItem) { - lastItem.addClass('jvi-nested-last'); - } - } else { - block.addClass('hidden').empty(); - ellipsis.removeClass('hidden'); - } - }; -} - -function renderComma($element) { - return $('').addClass('jvi-punctuation jvi-comma').text(',').appendTo($element); -} - -function renderEllipsis($element) { - const result = $('') - .addClass('jvi-punctuation jvi-ellipsis') - .html('…') - .appendTo($element) - .on('click', () => { - result.parents('.jvi-item').eq(0).find('.jvi-toggle').trigger('click'); - }); - return result; -} - -function renderPrimitive($element, value, comma) { - $('').addClass('jvi-value jvi-primitive').text('' + value).appendTo($element); - if (comma) { - renderComma($element); - } - return null; -} - -function renderString($element, value, comma) { - $('').addClass('jvi-punctuation jvi-string').text('"').appendTo($element); - $('').addClass('jvi-value jvi-string').text(value).appendTo($element); - $('').addClass('jvi-punctuation jvi-string').text('"').appendTo($element); - if (comma) { - renderComma($element); - } - return null; -} - -function renderComment($element, count) { - const text = ' // ' + count + ' ' + (count === 1 ? 'item' : 'items'); - const comment = $('').addClass('jvi-comment').text(text).appendTo($element); - return (show) => { - if (show) { - comment.addClass('hidden'); - } else { - comment.removeClass('hidden'); - } - }; -} - -function renderBrace($element, isForArray, isOpening) { - const openingBrace = isForArray ? '[' : '{'; - const closingBrace = isForArray ? ']' : '}'; - const brace = isOpening ? openingBrace : closingBrace; - return $('').addClass('jvi-punctuation jvi-braces').text(brace).appendTo($element); -} - -function renderWithNested($element, values, comma, valuesIsArray) { - const count = valuesIsArray ? values.length : keys(values).length; - let result = null; - - renderBrace($element, valuesIsArray, true); - if (count > 0) { - const ellipsis = renderEllipsis($element); - const block = $('').addClass('jvi-block hidden').appendTo($element); - result = createRenderNestedBlock(block, ellipsis, values, !valuesIsArray); - } - renderBrace($element, valuesIsArray, false); - - if (comma) { - renderComma($element); - } - - if (count > 0) { - result = combine(renderComment($element, count), result); - } - return result; -} - -function renderArray($element, values, comma) { - return renderWithNested($element, values, comma, true); -} - -function renderObject($element, value, comma) { - return renderWithNested($element, value, comma, false); -} - -function renderValue($element, value, comma) { - $element = $('').appendTo($element); - - if (isPrimitive(value)) { - return renderPrimitive($element, value, comma); - } else if (isString(value)) { - return renderString($element, value, comma); - } else if (isArray(value)) { - return renderArray($element, value, comma); - } else if (isObject(value)) { - return renderObject($element, value, comma); - } -} - -export default function renderJsonView(container, value) { - if ((container instanceof $) && !isUndefined(value) && !isFunction(value)) { - const block = $('').addClass('jvi-item').appendTo(container); - const toggle = $('').addClass('jvi-toggle').appendTo(block); - const toggleBlockFn = renderValue(block, value); - initToggle(toggle, toggleBlockFn); - } -} diff --git a/client/app/components/dynamic-table/json-cell/template.html b/client/app/components/dynamic-table/json-cell/template.html deleted file mode 100644 index 8488d80f45..0000000000 --- a/client/app/components/dynamic-table/json-cell/template.html +++ /dev/null @@ -1,4 +0,0 @@ - -
{{ value }}
-
- diff --git a/client/app/components/json-view-interactive/JsonViewInteractive.jsx b/client/app/components/json-view-interactive/JsonViewInteractive.jsx new file mode 100644 index 0000000000..eab70ec51c --- /dev/null +++ b/client/app/components/json-view-interactive/JsonViewInteractive.jsx @@ -0,0 +1,103 @@ +/* eslint-disable react/prop-types */ + +import { isFinite, isString, isArray, isObject, keys, map } from 'lodash'; +import React, { useState } from 'react'; +import cx from 'classnames'; +import PropTypes from 'prop-types'; + +import './json-view-interactive.less'; + +function JsonBlock({ value, children, openingBrace, closingBrace, withKeys }) { + const [isExpanded, setIsExpanded] = useState(false); + + const objectKeys = keys(value); + const count = objectKeys.length; + + return ( + + {(count > 0) && ( + setIsExpanded(!isExpanded)}> + + + )} + {openingBrace} + {!isExpanded && (count > 0) && ( + setIsExpanded(true)}>… + )} + {isExpanded && ( + + {map(objectKeys, (key, index) => { + const isFirst = index === 0; + const isLast = index === count - 1; + const comma = isLast ? null : ,; + return ( + + {withKeys && ( + + + : + + + )} + {comma} + + ); + })} + + )} + {closingBrace} + {children} + {!isExpanded && ( + {' // ' + count + ' ' + (count === 1 ? 'item' : 'items')} + )} + + ); +} + +function JsonValue({ value, children }) { + if ((value === null) || (value === false) || (value === true) || isFinite(value)) { + return ( + + {'' + value} + {children} + + ); + } + if (isString(value)) { + return ( + + " + {value} + " + {children} + + ); + } + if (isArray(value)) { + return {children}; + } + if (isObject(value)) { + return {children}; + } + return null; +} + +export default function JsonViewInteractive({ value }) { + return ( + + + + ); +} + +JsonViewInteractive.propTypes = { + value: PropTypes.any, // eslint-disable-line react/forbid-prop-types +}; + +JsonViewInteractive.defaultProps = { + // `null` will be rendered as "null" because it is a valid JSON value, so use `undefined` for no value + value: undefined, +}; diff --git a/client/app/components/dynamic-table/json-cell/json-view-interactive.less b/client/app/components/json-view-interactive/json-view-interactive.less similarity index 89% rename from client/app/components/dynamic-table/json-cell/json-view-interactive.less rename to client/app/components/json-view-interactive/json-view-interactive.less index 6bc1bb90c4..7fa28fbf29 100644 --- a/client/app/components/dynamic-table/json-cell/json-view-interactive.less +++ b/client/app/components/json-view-interactive/json-view-interactive.less @@ -1,17 +1,18 @@ -@import (reference) "~bootstrap/less/variables.less"; +@import (reference, less) "~bootstrap/less/variables.less"; +@import (reference, less) "~@/assets/less/main.less"; @jvi-gutter: 20px; @jvi-spacing: 2px; +.jvi-root { + display: block; + font-family: @font-family-monospace; +} + .jvi-block { display: block; border-left: 1px dotted @table-border-color; margin: 0 0 0 2px; - font-family: @font-family-monospace; - - &.hidden { - display: none; - } } .jvi-item { @@ -100,6 +101,7 @@ .jvi-comment { color: @text-muted; + font-family: @redash-font; font-style: italic; margin: 0 0 0 2 * @jvi-spacing; opacity: 0.5; diff --git a/client/app/components/parameters.js b/client/app/components/parameters.js index a0db51cefc..515abe5df5 100644 --- a/client/app/components/parameters.js +++ b/client/app/components/parameters.js @@ -58,7 +58,7 @@ function ParametersDirective($location, KeyboardShortcuts) { EditParameterSettingsDialog .showModal({ parameter }) .result.then((updated) => { - scope.parameters[index] = extend(parameter, updated); + scope.parameters[index] = extend(parameter, updated).setValue(updated.value); scope.onUpdated(); }); }; diff --git a/client/app/lib/value-format.js b/client/app/lib/value-format.js index a68e289e1a..4d73741109 100644 --- a/client/app/lib/value-format.js +++ b/client/app/lib/value-format.js @@ -1,6 +1,6 @@ import moment from 'moment/moment'; import numeral from 'numeral'; -import _ from 'lodash'; +import { isString, isArray, isUndefined, isNil, toString } from 'lodash'; numeral.options.scalePercentBy100 = false; @@ -9,36 +9,36 @@ const urlPattern = /(^|[\s\n]|)((?:https?|ftp):\/\/[\-A-Z0-9+\u0026\u2019 const hasOwnProperty = Object.prototype.hasOwnProperty; -function createDefaultFormatter(highlightLinks) { +export function createTextFormatter(highlightLinks) { if (highlightLinks) { return (value) => { - if (_.isString(value)) { + if (isString(value)) { value = value.replace(urlPattern, '$1$2'); } - return value; + return toString(value); }; } - return value => value; + return value => toString(value); } -function createDateTimeFormatter(format) { - if (_.isString(format) && (format !== '')) { +export function createDateTimeFormatter(format) { + if (isString(format) && (format !== '')) { return (value) => { if (value && moment.isMoment(value)) { return value.format(format); } - return value; + return toString(value); }; } - return value => value; + return value => toString(value); } -function createBooleanFormatter(values) { - if (_.isArray(values)) { +export function createBooleanFormatter(values) { + if (isArray(values)) { if (values.length >= 2) { // Both `true` and `false` specified return (value) => { - if (value === null || value === undefined) { + if (isNil(value)) { return ''; } return '' + values[value ? 1 : 0]; @@ -49,19 +49,19 @@ function createBooleanFormatter(values) { } } return (value) => { - if (value === null || value === undefined) { + if (isNil(value)) { return ''; } return value ? 'true' : 'false'; }; } -function createNumberFormatter(format) { - if (_.isString(format) && (format !== '')) { +export function createNumberFormatter(format) { + if (isString(format) && (format !== '')) { const n = numeral(0); // cache `numeral` instance return value => (value === null || value === '' ? '' : n.set(value).format(format)); } - return value => value; + return value => toString(value); } export function createFormatter(column) { @@ -69,16 +69,16 @@ export function createFormatter(column) { case 'number': return createNumberFormatter(column.numberFormat); case 'boolean': return createBooleanFormatter(column.booleanValues); case 'datetime': return createDateTimeFormatter(column.dateTimeFormat); - default: return createDefaultFormatter(column.allowHTML && column.highlightLinks); + default: return createTextFormatter(column.allowHTML && column.highlightLinks); } } export function formatSimpleTemplate(str, data) { - if (!_.isString(str)) { + if (!isString(str)) { return ''; } return str.replace(/{{\s*([^\s]+?)\s*}}/g, (match, prop) => { - if (hasOwnProperty.call(data, prop) && !_.isUndefined(data[prop])) { + if (hasOwnProperty.call(data, prop) && !isUndefined(data[prop])) { return data[prop]; } return match; diff --git a/client/app/pages/queries/query.html b/client/app/pages/queries/query.html index 9adf1fdacf..e2e50ae0ea 100644 --- a/client/app/pages/queries/query.html +++ b/client/app/pages/queries/query.html @@ -239,7 +239,7 @@

New Visualization -
+
@@ -288,8 +288,14 @@

- diff --git a/client/app/services/dashboard.js b/client/app/services/dashboard.js index 7cab5b20bb..509ab387cf 100644 --- a/client/app/services/dashboard.js +++ b/client/app/services/dashboard.js @@ -6,9 +6,9 @@ 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) => { + _.each(queryResults, (queryResult) => { + const queryFilters = queryResult ? queryResult.getFilters() : []; + _.each(queryFilters, (queryFilter) => { const hasQueryStringValue = _.has(urlParams, queryFilter.name); if (!(hasQueryStringValue || dashboard.dashboard_filters_enabled)) { diff --git a/client/app/services/query.js b/client/app/services/query.js index 6669106da6..317da77c11 100644 --- a/client/app/services/query.js +++ b/client/app/services/query.js @@ -2,9 +2,8 @@ import moment from 'moment'; import debug from 'debug'; import Mustache from 'mustache'; import { - zipObject, isEmpty, map, filter, includes, union, uniq, has, - isNull, isUndefined, isArray, isObject, identity, extend, each, - startsWith, some, + zipObject, isEmpty, map, filter, includes, union, uniq, has, get, intersection, + isNull, isUndefined, isArray, isObject, identity, extend, each, join, some, startsWith, } from 'lodash'; Mustache.escape = identity; // do not html-escape values @@ -138,6 +137,7 @@ export class Parameter { this.useCurrentDateTime = parameter.useCurrentDateTime; this.global = parameter.global; // backward compatibility in Widget service this.enumOptions = parameter.enumOptions; + this.multiValuesOptions = parameter.multiValuesOptions; this.queryId = parameter.queryId; this.parentQueryId = parentQueryId; @@ -164,6 +164,10 @@ export class Parameter { return isNull(this.getValue()); } + getValue(extra = {}) { + return this.constructor.getValue(this, extra); + } + get hasDynamicValue() { if (isDateParameter(this.type)) { return isDynamicDate(this.value); @@ -184,13 +188,9 @@ export class Parameter { return false; } - getValue() { - return this.constructor.getValue(this); - } - - static getValue(param) { - const { value, type, useCurrentDateTime } = param; - const isEmptyValue = isNull(value) || isUndefined(value) || (value === ''); + static getValue(param, extra = {}) { + const { value, type, useCurrentDateTime, multiValuesOptions } = param; + const isEmptyValue = isNull(value) || isUndefined(value) || (value === '') || (isArray(value) && value.length === 0); if (isDateRangeParameter(type) && param.hasDynamicValue) { const { dynamicValue } = param; if (dynamicValue) { @@ -224,10 +224,32 @@ export class Parameter { if (type === 'number') { return normalizeNumericValue(value, null); // normalize empty value } + + // join array in frontend when query is executed as a text + const { joinListValues } = extra; + if (includes(['enum', 'query'], type) && multiValuesOptions && isArray(value) && joinListValues) { + const separator = get(multiValuesOptions, 'separator', ','); + const prefix = get(multiValuesOptions, 'prefix', ''); + const suffix = get(multiValuesOptions, 'suffix', ''); + const parameterValues = map(value, v => `${prefix}${v}${suffix}`); + return join(parameterValues, separator); + } return value; } setValue(value) { + if (this.type === 'enum') { + const enumOptionsArray = this.enumOptions && this.enumOptions.split('\n') || []; + if (this.multiValuesOptions) { + if (!isArray(value)) { + value = [value]; + } + value = intersection(value, enumOptionsArray); + } else if (!value || isArray(value) || !includes(enumOptionsArray, value)) { + value = enumOptionsArray[0]; + } + } + if (isDateRangeParameter(this.type)) { this.value = null; this.$$value = null; @@ -331,6 +353,9 @@ export class Parameter { [`${prefix}${this.name}`]: null, }; } + if (this.multiValuesOptions && isArray(this.value)) { + return { [`${prefix}${this.name}`]: JSON.stringify(this.value) }; + } return { [`${prefix}${this.name}`]: this.value, [`${prefix}${this.name}.start`]: null, @@ -352,7 +377,15 @@ export class Parameter { } else { const key = `${prefix}${this.name}`; if (has(query, key)) { - this.setValue(query[key]); + if (this.multiValuesOptions) { + try { + this.setValue(JSON.parse(query[key])); + } catch (e) { + this.setValue(query[key]); + } + } else { + this.setValue(query[key]); + } } } } @@ -460,9 +493,9 @@ class Parameters { return !isEmpty(this.get()); } - getValues() { + getValues(extra = {}) { const params = this.get(); - return zipObject(map(params, i => i.name), map(params, i => i.getValue())); + return zipObject(map(params, i => i.name), map(params, i => i.getValue(extra))); } hasPendingValues() { @@ -710,7 +743,7 @@ function QueryResource( return new QueryResultError("Can't execute empty query."); } - const parameters = this.getParameters().getValues(); + const parameters = this.getParameters().getValues({ joinListValues: true }); const execute = () => QueryResult.get(this.data_source_id, queryText, parameters, maxAge, this.id); return this.prepareQueryResultExecution(execute, maxAge); }; diff --git a/client/app/visualizations/VisualizationRenderer.jsx b/client/app/visualizations/VisualizationRenderer.jsx index e6a45b52d5..9b6e3db69a 100644 --- a/client/app/visualizations/VisualizationRenderer.jsx +++ b/client/app/visualizations/VisualizationRenderer.jsx @@ -1,5 +1,5 @@ -import { map, find } from 'lodash'; -import React, { useState, useMemo, useEffect } from 'react'; +import { isEqual, map, find } from 'lodash'; +import React, { useState, useMemo, useEffect, useRef } from 'react'; import PropTypes from 'prop-types'; import { react2angular } from 'react2angular'; import useQueryResult from '@/lib/hooks/useQueryResult'; @@ -27,6 +27,7 @@ function combineFilters(localFilters, globalFilters) { export function VisualizationRenderer(props) { const data = useQueryResult(props.queryResult); const [filters, setFilters] = useState(data.filters); + const lastOptions = useRef(); // Reset local filters when query results updated useEffect(() => { @@ -45,13 +46,27 @@ export function VisualizationRenderer(props) { const { showFilters, visualization } = props; const { Renderer, getOptions } = registeredVisualizations[visualization.type]; - const options = getOptions(visualization.options, data); + + // Avoid unnecessary updates (which may be expensive or cause issues with + // internal state of some visualizations like Table) - compare options deeply + // and use saved reference if nothing changed + // More details: https://github.com/getredash/redash/pull/3963#discussion_r306935810 + let options = getOptions(visualization.options, data); + if (isEqual(lastOptions.current, options)) { + options = lastOptions.current; + } + lastOptions.current = options; return ( {showFilters && }
- +
); diff --git a/client/app/visualizations/counter/counter.html b/client/app/visualizations/counter/counter.html index 7320f02be3..d7493f4378 100644 --- a/client/app/visualizations/counter/counter.html +++ b/client/app/visualizations/counter/counter.html @@ -1,5 +1,5 @@
- {{stringPrefix}}{{counterValue | number}}{{stringSuffix}} - {{stringPrefix}}{{counterValue}}{{stringSuffix}} - ({{targetValue | number}}) + {{ counterValue }} + ({{ targetValue }}) {{counterLabel}}
diff --git a/client/app/visualizations/counter/index.js b/client/app/visualizations/counter/index.js index b930576f0d..6a1476e274 100644 --- a/client/app/visualizations/counter/index.js +++ b/client/app/visualizations/counter/index.js @@ -1,5 +1,5 @@ -import numberFormat from 'underscore.string/numberFormat'; -import { isNumber } from 'lodash'; +import { isNumber, toString } from 'lodash'; +import numeral from 'numeral'; import { angular2react } from 'angular2react'; import { registerVisualization } from '@/visualizations'; @@ -14,8 +14,51 @@ const DEFAULT_OPTIONS = { stringDecimal: 0, stringDecChar: '.', stringThouSep: ',', + tooltipFormat: '0,0.000', // TODO: Show in editor }; +// TODO: allow user to specify number format string instead of delimiters only +// It will allow to remove this function (move all that weird formatting logic to a migration +// that will set number format for all existing counter visualization) +function numberFormat(value, decimalPoints, decimalDelimiter, thousandsDelimiter) { + // Temporarily update locale data (restore defaults after formatting) + const locale = numeral.localeData(); + const savedDelimiters = locale.delimiters; + + // Mimic old behavior - AngularJS `number` filter defaults: + // - `,` as thousands delimiter + // - `.` as decimal delimiter + // - three decimal points + locale.delimiters = { + thousands: ',', + decimal: '.', + }; + let formatString = '0,0.000'; + if ( + (Number.isFinite(decimalPoints) && (decimalPoints >= 0)) || + decimalDelimiter || + thousandsDelimiter + ) { + locale.delimiters = { + thousands: thousandsDelimiter, + decimal: decimalDelimiter || '.', + }; + + formatString = '0,0'; + if (decimalPoints > 0) { + formatString += '.'; + while (decimalPoints > 0) { + formatString += '0'; + decimalPoints -= 1; + } + } + } + const result = numeral(value).format(formatString); + + locale.delimiters = savedDelimiters; + return result; +} + // TODO: Need to review this function, it does not properly handle edge cases. function getRowNumber(index, size) { if (index >= 0) { @@ -29,6 +72,21 @@ function getRowNumber(index, size) { return size + index; } +function formatValue(value, { stringPrefix, stringSuffix, stringDecimal, stringDecChar, stringThouSep }) { + if (isNumber(value)) { + value = numberFormat(value, stringDecimal, stringDecChar, stringThouSep); + return toString(stringPrefix) + value + toString(stringSuffix); + } + return toString(value); +} + +function formatTooltip(value, formatString) { + if (isNumber(value)) { + return numeral(value).format(formatString); + } + return toString(value); +} + const CounterRenderer = { template: counterTemplate, bindings: { @@ -69,33 +127,25 @@ const CounterRenderer = { } else if (counterColName) { $scope.counterValue = data[rowNumber][counterColName]; } + + $scope.showTrend = false; if (targetColName) { $scope.targetValue = data[targetRowNumber][targetColName]; - if ($scope.targetValue) { - $scope.delta = $scope.counterValue - $scope.targetValue; - $scope.trendPositive = $scope.delta >= 0; + if (Number.isFinite($scope.counterValue) && Number.isFinite($scope.targetValue)) { + const delta = $scope.counterValue - $scope.targetValue; + $scope.showTrend = true; + $scope.trendPositive = delta >= 0; } } else { $scope.targetValue = null; } - $scope.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; - } + $scope.counterValueTooltip = formatTooltip($scope.counterValue, options.tooltipFormat); + $scope.targetValueTooltip = formatTooltip($scope.targetValue, options.tooltipFormat); + + $scope.counterValue = formatValue($scope.counterValue, options); + $scope.targetValue = formatValue($scope.targetValue, options); } $timeout(() => { diff --git a/client/app/visualizations/index.js b/client/app/visualizations/index.js index 9d062c53cc..ba731d9cad 100644 --- a/client/app/visualizations/index.js +++ b/client/app/visualizations/index.js @@ -13,6 +13,7 @@ const Data = PropTypes.shape({ }); export const VisualizationType = PropTypes.shape({ + id: PropTypes.number, type: PropTypes.string.isRequired, name: PropTypes.string.isRequired, options: VisualizationOptions.isRequired, // eslint-disable-line react/forbid-prop-types diff --git a/client/app/visualizations/table/Renderer.jsx b/client/app/visualizations/table/Renderer.jsx new file mode 100644 index 0000000000..2880bb81dc --- /dev/null +++ b/client/app/visualizations/table/Renderer.jsx @@ -0,0 +1,79 @@ +import { filter } from 'lodash'; +import React, { useMemo, useState, useRef, useCallback, useEffect } from 'react'; +import Table from 'antd/lib/table'; +import Input from 'antd/lib/input'; +import { RendererPropTypes } from '@/visualizations'; + +import { prepareColumns, filterRows, sortRows } from './utils'; + +import './renderer.less'; + +export default function Renderer({ options, data }) { + const [rowKeyPrefix, setRowKeyPrefix] = useState(`row:1:${options.itemsPerPage}:`); + const [searchTerm, setSearchTerm] = useState(''); + const [orderBy, setOrderBy] = useState([]); + + const searchColumns = useMemo( + () => filter(options.columns, 'allowSearch'), + [options.columns], + ); + + const searchInputRef = useRef(); + const onSearchInputChange = useCallback(event => setSearchTerm(event.target.value), [setSearchTerm]); + + const tableColumns = useMemo( + () => { + const searchInput = searchColumns.length > 0 ? ( + + ) : null; + + return prepareColumns(options.columns, searchInput, orderBy, (newOrderBy) => { + setOrderBy(newOrderBy); + // Remove text selection - may occur accidentally + document.getSelection().removeAllRanges(); + }); + }, + [options.columns, searchColumns, searchInputRef, onSearchInputChange, orderBy, setOrderBy], + ); + + const preparedRows = useMemo( + () => sortRows(filterRows(data.rows, searchTerm, searchColumns), orderBy), + [data.rows, searchTerm, searchColumns, orderBy], + ); + + // If data or config columns change - reset sorting and search + useEffect(() => { + setSearchTerm(''); + // Do not use `` because it leads to many renderings and lags on user + // input. This is the only place where we need to change search input's value from "outer world", + // so let's use this "hack" for better performance. + if (searchInputRef.current) { + // pass value and fake event-like object + searchInputRef.current.input.setValue('', { target: { value: '' } }); + } + setOrderBy([]); + }, [options.columns, data.columns, searchInputRef]); + + if (data.rows.length === 0) { + return null; + } + + return ( +
+ rowKeyPrefix + index} + pagination={{ + position: 'bottom', + pageSize: options.itemsPerPage, + hideOnSinglePage: true, + onChange: (page, pageSize) => setRowKeyPrefix(`row:${page}:${pageSize}:`), + }} + /> + + ); +} + +Renderer.propTypes = RendererPropTypes; diff --git a/client/app/visualizations/table/columns/boolean.js b/client/app/visualizations/table/columns/boolean.js new file mode 100644 index 0000000000..c096dd91ec --- /dev/null +++ b/client/app/visualizations/table/columns/boolean.js @@ -0,0 +1,23 @@ +/* eslint-disable react/prop-types */ +import { createBooleanFormatter } from '@/lib/value-format'; + +export default function initBooleanColumn(column) { + const format = createBooleanFormatter(column.booleanValues); + + function prepareData(row) { + return { + text: format(row[column.name]), + }; + } + + function BooleanColumn({ row }) { + const { text } = prepareData(row); + return text; + } + + BooleanColumn.prepareData = prepareData; + + return BooleanColumn; +} + +initBooleanColumn.friendlyName = 'Boolean'; diff --git a/client/app/visualizations/table/columns/datetime.js b/client/app/visualizations/table/columns/datetime.js new file mode 100644 index 0000000000..6aca69bd57 --- /dev/null +++ b/client/app/visualizations/table/columns/datetime.js @@ -0,0 +1,23 @@ +/* eslint-disable react/prop-types */ +import { createDateTimeFormatter } from '@/lib/value-format'; + +export default function initDateTimeColumn(column) { + const format = createDateTimeFormatter(column.dateTimeFormat); + + function prepareData(row) { + return { + text: format(row[column.name]), + }; + } + + function DateTimeColumn({ row }) { + const { text } = prepareData(row); + return text; + } + + DateTimeColumn.prepareData = prepareData; + + return DateTimeColumn; +} + +initDateTimeColumn.friendlyName = 'Date/Time'; diff --git a/client/app/visualizations/table/columns/image.js b/client/app/visualizations/table/columns/image.js new file mode 100644 index 0000000000..ef7db34acc --- /dev/null +++ b/client/app/visualizations/table/columns/image.js @@ -0,0 +1,46 @@ +/* eslint-disable react/prop-types */ +import { extend, trim } from 'lodash'; +import React from 'react'; +import { formatSimpleTemplate } from '@/lib/value-format'; + +export default function initImageColumn(column) { + function prepareData(row) { + row = extend({ '@': row[column.name] }, row); + + const src = trim(formatSimpleTemplate(column.imageUrlTemplate, row)); + if (src === '') { + return {}; + } + + const width = parseInt(formatSimpleTemplate(column.imageWidth, row), 10); + const height = parseInt(formatSimpleTemplate(column.imageHeight, row), 10); + const title = trim(formatSimpleTemplate(column.imageTitleTemplate, row)); + + const result = { src }; + + if (Number.isFinite(width) && (width > 0)) { + result.width = width; + } + if (Number.isFinite(height) && (height > 0)) { + result.height = height; + } + if (title !== '') { + result.text = title; // `text` is used for search + result.title = title; + result.alt = title; + } + + return result; + } + + function ImageColumn({ row }) { + const { text, ...props } = prepareData(row); + return ; + } + + ImageColumn.prepareData = prepareData; + + return ImageColumn; +} + +initImageColumn.friendlyName = 'Image'; diff --git a/client/app/visualizations/table/columns/json.js b/client/app/visualizations/table/columns/json.js new file mode 100644 index 0000000000..c92d589e96 --- /dev/null +++ b/client/app/visualizations/table/columns/json.js @@ -0,0 +1,38 @@ +/* eslint-disable react/prop-types */ +import { isString, isUndefined } from 'lodash'; +import React from 'react'; +import JsonViewInteractive from '@/components/json-view-interactive/JsonViewInteractive'; +import { clientConfig } from '@/services/auth'; + +export default function initJsonColumn(column) { + function prepareData(row) { + const text = row[column.name]; + if (isString(text) && (text.length <= clientConfig.tableCellMaxJSONSize)) { + try { + return { text, value: JSON.parse(text) }; + } catch (e) { + // ignore `JSON.parse` error and return default value + } + } + return { text, value: undefined }; + } + + function JsonColumn({ row }) { + const { text, value } = prepareData(row); + if (isUndefined(value)) { + return
{'' + text}
; + } + + return ( +
+ +
+ ); + } + + JsonColumn.prepareData = prepareData; + + return JsonColumn; +} + +initJsonColumn.friendlyName = 'JSON'; diff --git a/client/app/visualizations/table/columns/link.js b/client/app/visualizations/table/columns/link.js new file mode 100644 index 0000000000..dffc06342d --- /dev/null +++ b/client/app/visualizations/table/columns/link.js @@ -0,0 +1,43 @@ +/* eslint-disable react/prop-types */ +import { extend, trim } from 'lodash'; +import React from 'react'; +import { formatSimpleTemplate } from '@/lib/value-format'; + +export default function initLinkColumn(column) { + function prepareData(row) { + row = extend({ '@': row[column.name] }, row); + + const href = trim(formatSimpleTemplate(column.linkUrlTemplate, row)); + if (href === '') { + return {}; + } + + const title = trim(formatSimpleTemplate(column.linkTitleTemplate, row)); + const text = trim(formatSimpleTemplate(column.linkTextTemplate, row)); + + const result = { + href, + text: text !== '' ? text : href, + }; + + if (title !== '') { + result.title = title; + } + if (column.linkOpenInNewTab) { + result.target = '_blank'; + } + + return result; + } + + function LinkColumn({ row }) { + const { text, ...props } = prepareData(row); + return {text}; + } + + LinkColumn.prepareData = prepareData; + + return LinkColumn; +} + +initLinkColumn.friendlyName = 'Link'; diff --git a/client/app/visualizations/table/columns/number.js b/client/app/visualizations/table/columns/number.js new file mode 100644 index 0000000000..869d4c80ae --- /dev/null +++ b/client/app/visualizations/table/columns/number.js @@ -0,0 +1,23 @@ +/* eslint-disable react/prop-types */ +import { createNumberFormatter } from '@/lib/value-format'; + +export default function initNumberColumn(column) { + const format = createNumberFormatter(column.numberFormat); + + function prepareData(row) { + return { + text: format(row[column.name]), + }; + } + + function NumberColumn({ row }) { + const { text } = prepareData(row); + return text; + } + + NumberColumn.prepareData = prepareData; + + return NumberColumn; +} + +initNumberColumn.friendlyName = 'Number'; diff --git a/client/app/visualizations/table/columns/text.js b/client/app/visualizations/table/columns/text.js new file mode 100644 index 0000000000..2ee99f3edc --- /dev/null +++ b/client/app/visualizations/table/columns/text.js @@ -0,0 +1,25 @@ +/* eslint-disable react/prop-types */ +import React from 'react'; +import HtmlContent from '@/components/HtmlContent'; +import { createTextFormatter } from '@/lib/value-format'; + +export default function initTextColumn(column) { + const format = createTextFormatter(column.allowHTML && column.highlightLinks); + + function prepareData(row) { + return { + text: format(row[column.name]), + }; + } + + function TextColumn({ row }) { + const { text } = prepareData(row); + return column.allowHTML ? {text} : text; + } + + TextColumn.prepareData = prepareData; + + return TextColumn; +} + +initTextColumn.friendlyName = 'Text'; diff --git a/client/app/visualizations/table/index.js b/client/app/visualizations/table/index.js index c80921a118..6a8b5dfc99 100644 --- a/client/app/visualizations/table/index.js +++ b/client/app/visualizations/table/index.js @@ -2,23 +2,14 @@ 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'; -const ALLOWED_ITEM_PER_PAGE = [5, 10, 15, 20, 25, 50, 100, 150, 200, 250]; +import Renderer from './Renderer'; +import { ColumnTypes } from './utils'; -const DISPLAY_AS_OPTIONS = [ - { name: 'Text', value: 'string' }, - { name: 'Number', value: 'number' }, - { name: 'Date/Time', value: 'datetime' }, - { name: 'Boolean', value: 'boolean' }, - { name: 'JSON', value: 'json' }, - { name: 'Image', value: 'image' }, - { name: 'Link', value: 'link' }, -]; +const ALLOWED_ITEM_PER_PAGE = [5, 10, 15, 20, 25, 50, 100, 150, 200, 250]; const DEFAULT_OPTIONS = { itemsPerPage: 25, @@ -119,43 +110,6 @@ function getColumnsOptions(columns, visualizationColumns) { return _.sortBy(options, 'order'); } -function getColumnsToDisplay(columns, options) { - columns = _.fromPairs(_.map(columns, col => [col.name, col])); - let result = _.map(options, col => _.extend( - getDefaultFormatOptions(col), - col, - columns[col.name], - )); - - result = _.map(result, col => _.extend(col, { - formatFunction: createFormatter(col), - })); - - return _.sortBy(_.filter(result, 'visible'), 'order'); -} - -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('$ctrl.data', update); - $scope.$watch('$ctrl.options', update, true); - }, -}; - const GridEditor = { bindings: { data: '<', @@ -165,7 +119,7 @@ const GridEditor = { template: editorTemplate, controller($scope) { this.allowedItemsPerPage = ALLOWED_ITEM_PER_PAGE; - this.displayAsOptions = DISPLAY_AS_OPTIONS; + this.displayAsOptions = _.map(ColumnTypes, ({ friendlyName: name }, value) => ({ name, value })); this.currentTab = 'columns'; this.setCurrentTab = (tab) => { @@ -185,7 +139,6 @@ const GridEditor = { }; export default function init(ngModule) { - ngModule.component('gridRenderer', GridRenderer); ngModule.component('gridEditor', GridEditor); ngModule.run(($injector) => { @@ -200,7 +153,7 @@ export default function init(ngModule) { ); return options; }, - Renderer: angular2react('gridRenderer', GridRenderer, $injector), + Renderer, Editor: angular2react('gridEditor', GridEditor, $injector), autoHeight: true, diff --git a/client/app/visualizations/table/renderer.less b/client/app/visualizations/table/renderer.less new file mode 100644 index 0000000000..05dcb66017 --- /dev/null +++ b/client/app/visualizations/table/renderer.less @@ -0,0 +1,103 @@ +@import (reference, less) "~@/assets/less/ant.less"; + +.table-visualization-container { + .ant-pagination.ant-table-pagination { + float: none; + display: block; + text-align: center; + margin-bottom: 0; + } + + .ant-table-body { + overflow-x: auto; + } + + table { + border-top: @border-width-base @border-style-base @border-color-split; + border-bottom: 0; + + .display-as-number, + .display-as-boolean, + .display-as-datetime, + .display-as-image { + width: 1%; + white-space: nowrap; + } + + .table-visualization-spacer { + padding-left: 0; + padding-right: 0; + + & > div:before { + content: none !important; + } + } + + tbody tr:last-child td { + border-bottom: 0; + } + + thead { + .anticon.off { + opacity: 0; + } + + &:hover .anticon.off, + .table-visualization-column-is-sorted .anticon.off { + opacity: 1; + } + + th { + white-space: nowrap; + + &.table-visualization-search { + padding-top: 0; + + .ant-table-header-column { + display: block; + } + } + + .ant-input-search { + font-weight: normal; + + .ant-input-suffix .anticon { + cursor: auto; + } + } + + // optimize room for th content + &.ant-table-column-has-actions.ant-table-column-has-sorters { + padding-right: 3px; + } + + .table-visualization-heading { + display: inline-block; + max-width: 200px; + vertical-align: middle; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &[data-sort-column-index]:before { + @size: 12px; + + content: attr(data-sort-column-index); + display: inline-block; + vertical-align: middle; + min-width: @size; + height: @size; + font-size: @size * 3/4; + border-radius: @size / 2; + background: #c0c0c0; + text-align: center; + line-height: @size; + color: #fff; + padding: 0 @size * 1/4; + margin: 0 5px 0 0; + } + } + } + } + } +} diff --git a/client/app/visualizations/table/table.html b/client/app/visualizations/table/table.html deleted file mode 100644 index 96f4a331f1..0000000000 --- a/client/app/visualizations/table/table.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/client/app/visualizations/table/utils.js b/client/app/visualizations/table/utils.js new file mode 100644 index 0000000000..7c335af4e4 --- /dev/null +++ b/client/app/visualizations/table/utils.js @@ -0,0 +1,198 @@ +import { isNil, map, filter, each, sortBy, some, findIndex, toString } from 'lodash'; +import React from 'react'; +import cx from 'classnames'; +import Icon from 'antd/lib/icon'; +import Tooltip from 'antd/lib/tooltip'; + +import initTextColumn from './columns/text'; +import initNumberColumn from './columns/number'; +import initDateTimeColumn from './columns/datetime'; +import initBooleanColumn from './columns/boolean'; +import initLinkColumn from './columns/link'; +import initImageColumn from './columns/image'; +import initJsonColumn from './columns/json'; + +// this map should contain all possible values for `column.displayAs` property +export const ColumnTypes = { + string: initTextColumn, + number: initNumberColumn, + datetime: initDateTimeColumn, + boolean: initBooleanColumn, + link: initLinkColumn, + image: initImageColumn, + json: initJsonColumn, +}; + +function nextOrderByDirection(direction) { + switch (direction) { + case 'ascend': return 'descend'; + case 'descend': return null; + default: return 'ascend'; + } +} + +function toggleOrderBy(columnName, orderBy = [], multiColumnSort = false) { + const index = findIndex(orderBy, i => i.name === columnName); + const item = { name: columnName, direction: 'ascend' }; + if (index >= 0) { + item.direction = nextOrderByDirection(orderBy[index].direction); + } + + if (multiColumnSort) { + if (!item.direction) { + return filter(orderBy, i => i.name !== columnName); + } + if (index >= 0) { + orderBy[index] = item; + } else { + orderBy.push(item); + } + return [...orderBy]; + } + return item.direction ? [item] : []; +} + +function getOrderByInfo(orderBy) { + const result = {}; + each(orderBy, ({ name, direction }, index) => { + result[name] = { direction, index: index + 1 }; + }); + return result; +} + +export function prepareColumns(columns, searchInput, orderBy, onOrderByChange) { + columns = filter(columns, 'visible'); + columns = sortBy(columns, 'order'); + + const isMultiColumnSort = orderBy.length > 1; + const orderByInfo = getOrderByInfo(orderBy); + + let tableColumns = map(columns, (column) => { + const isAscend = orderByInfo[column.name] && (orderByInfo[column.name].direction === 'ascend'); + const isDescend = orderByInfo[column.name] && (orderByInfo[column.name].direction === 'descend'); + + const sortColumnIndex = isMultiColumnSort && orderByInfo[column.name] ? orderByInfo[column.name].index : null; + + const result = { + key: column.name, // set this because we don't use `dataIndex` + // Column name may contain any characters (or be empty at all), therefore + // we cannot use `dataIndex` because it has special syntax and will not work + // for all possible column names. Instead, we'll generate row key dynamically + // based on row index + dataIndex: null, + align: column.alignContent, + title: ( + + +
{column.title}
+
+ +
+ + +
+
+
+ ), + onHeaderCell: () => ({ + className: cx( + 'ant-table-column-has-actions ant-table-column-has-sorters', + { 'table-visualization-column-is-sorted': isAscend || isDescend }, + ), + onClick: event => onOrderByChange(toggleOrderBy(column.name, orderBy, event.shiftKey)), + }), + }; + + const initColumn = ColumnTypes[column.displayAs]; + const Component = initColumn(column); + result.render = (unused, row) => ({ + children: , + props: { className: `display-as-${column.displayAs}` }, + }); + + return result; + }); + + tableColumns.push({ + key: '###Redash::Visualizations::Table::Spacer###', + dataIndex: null, + title: '', + className: 'table-visualization-spacer', + render: () => '', + onHeaderCell: () => ({ className: 'table-visualization-spacer' }), + }); + + if (searchInput) { + // We need a merged head cell through entire row. With Ant's Table the only way to do it + // is to add a single child to every column move `dataIndex` property to it and set + // `colSpan` to 0 for every child cell except of the 1st one - which should be expanded. + tableColumns = map(tableColumns, ({ title, align, key, onHeaderCell, ...rest }, index) => ({ + key: key + '(parent)', + title, + align, + onHeaderCell, + children: [{ + ...rest, + key: key + '(child)', + align, + colSpan: index === 0 ? tableColumns.length : 0, + title: index === 0 ? searchInput : null, + onHeaderCell: () => ({ className: 'table-visualization-search' }), + }], + })); + } + + return tableColumns; +} + +export function filterRows(rows, searchTerm, searchColumns) { + if ((searchTerm !== '') && (searchColumns.length > 0)) { + searchTerm = searchTerm.toUpperCase(); + const matchFields = map(searchColumns, (column) => { + const initColumn = ColumnTypes[column.displayAs]; + const { prepareData } = initColumn(column); + return (row) => { + const { text } = prepareData(row); + return toString(text).toUpperCase().indexOf(searchTerm) >= 0; + }; + }); + + return filter(rows, row => some(matchFields, match => match(row))); + } + return rows; +} + +export function sortRows(rows, orderBy) { + if ((orderBy.length === 0) || (rows.length === 0)) { + return rows; + } + + const directions = { ascend: 1, descend: -1 }; + + // Create a copy of array before sorting, because .sort() will modify original array + return [...rows].sort((a, b) => { + let va; + let vb; + for (let i = 0; i < orderBy.length; i += 1) { + va = a[orderBy[i].name]; + vb = b[orderBy[i].name]; + if (isNil(va) || va < vb) { + // if a < b - we should return -1, but take in account direction + return -1 * directions[orderBy[i].direction]; + } + if (va > vb || isNil(vb)) { + // if a > b - we should return 1, but take in account direction + return 1 * directions[orderBy[i].direction]; + } + } + return 0; + }); +} diff --git a/client/cypress/integration/dashboard/sharing_spec.js b/client/cypress/integration/dashboard/sharing_spec.js index 53ea9a4eec..e5f394e358 100644 --- a/client/cypress/integration/dashboard/sharing_spec.js +++ b/client/cypress/integration/dashboard/sharing_spec.js @@ -54,7 +54,7 @@ describe('Dashboard Sharing', () => { shareDashboard().then((secretAddress) => { cy.logout(); cy.visit(secretAddress); - cy.getByTestId('DynamicTable', { timeout: 10000 }).should('exist'); + cy.getByTestId('TableVisualization', { timeout: 10000 }).should('exist'); cy.percySnapshot('Successfully Shared Unparameterized Dashboard'); }); }); @@ -79,7 +79,7 @@ describe('Dashboard Sharing', () => { shareDashboard().then((secretAddress) => { cy.logout(); cy.visit(secretAddress); - cy.getByTestId('DynamicTable', { timeout: 10000 }).should('exist'); + cy.getByTestId('TableVisualization', { timeout: 10000 }).should('exist'); cy.percySnapshot('Successfully Shared Parameterized Dashboard'); }); }); @@ -113,7 +113,7 @@ describe('Dashboard Sharing', () => { cy.visit(this.dashboardUrl); cy.logout(); cy.visit(secretAddress); - cy.getByTestId('DynamicTable', { timeout: 10000 }).should('exist'); + cy.getByTestId('TableVisualization', { timeout: 10000 }).should('exist'); cy.contains('.alert', 'This query contains potentially unsafe parameters' + ' and cannot be executed on a shared dashboard or an embedded visualization.'); cy.percySnapshot('Successfully Shared Parameterized Dashboard With Some Unsafe Queries'); diff --git a/client/cypress/integration/query/create_query_spec.js b/client/cypress/integration/query/create_query_spec.js index 3b70bdf58f..5259b18845 100644 --- a/client/cypress/integration/query/create_query_spec.js +++ b/client/cypress/integration/query/create_query_spec.js @@ -15,7 +15,7 @@ describe('Create Query', () => { cy.getByTestId('ExecuteButton').click(); - cy.getByTestId('DynamicTable').should('exist'); + cy.getByTestId('TableVisualization').should('exist'); cy.percySnapshot('Edit Query'); cy.getByTestId('SaveButton').click(); diff --git a/client/cypress/integration/query/parameter_spec.js b/client/cypress/integration/query/parameter_spec.js index ede5929e10..a1e2bb656f 100644 --- a/client/cypress/integration/query/parameter_spec.js +++ b/client/cypress/integration/query/parameter_spec.js @@ -45,7 +45,7 @@ describe('Parameter', () => { cy.getByTestId('ParameterApplyButton') .click(); - cy.getByTestId('DynamicTable') + cy.getByTestId('TableVisualization') .should('contain', 'Redash'); }); @@ -82,7 +82,7 @@ describe('Parameter', () => { cy.getByTestId('ParameterApplyButton') .click(); - cy.getByTestId('DynamicTable') + cy.getByTestId('TableVisualization') .should('contain', 42); cy.getByTestId('ParameterName-test-parameter') @@ -92,7 +92,7 @@ describe('Parameter', () => { cy.getByTestId('ParameterApplyButton') .click(); - cy.getByTestId('DynamicTable') + cy.getByTestId('TableVisualization') .should('contain', 31415); }); @@ -121,7 +121,7 @@ describe('Parameter', () => { }; createQuery(queryData, false) - .then(({ id }) => cy.visit(`/queries/${id}`)); + .then(({ id }) => cy.visit(`/queries/${id}/source`)); }); it('updates the results after selecting a value', () => { @@ -129,14 +129,43 @@ describe('Parameter', () => { .find('.ant-select') .click(); - cy.contains('li.ant-select-dropdown-menu-item', 'value1') + cy.contains('li.ant-select-dropdown-menu-item', 'value2') + .click(); + + cy.getByTestId('ParameterApplyButton') .click(); + cy.getByTestId('TableVisualization') + .should('contain', 'value2'); + }); + + it('supports multi-selection', () => { + cy.clickThrough(` + ParameterSettings-test-parameter + AllowMultipleValuesCheckbox + QuotationSelect + DoubleQuotationMarkOption + SaveParameterSettings + `); + + cy.getByTestId('ParameterName-test-parameter') + .find('.ant-select') + .click(); + + // select all unselected options + cy.get('li.ant-select-dropdown-menu-item').each(($option) => { + if (!$option.hasClass('ant-select-dropdown-menu-item-selected')) { + cy.wrap($option).click(); + } + }); + + cy.getByTestId('QueryEditor').click(); // just to close the select menu + cy.getByTestId('ParameterApplyButton') .click(); - cy.getByTestId('DynamicTable') - .should('contain', 'value1'); + cy.getByTestId('TableVisualization') + .should('contain', '"value1","value2","value3"'); }); it('sets dirty state when edited', () => { @@ -145,7 +174,7 @@ describe('Parameter', () => { .find('.ant-select') .click(); - cy.contains('li.ant-select-dropdown-menu-item', 'value1') + cy.contains('li.ant-select-dropdown-menu-item', 'value2') .click(); }); }); @@ -197,7 +226,7 @@ describe('Parameter', () => { cy.getByTestId('ParameterApplyButton') .click(); - cy.getByTestId('DynamicTable') + cy.getByTestId('TableVisualization') .should('contain', Cypress.moment(this.now).format('15/MM/YY')); }); @@ -213,7 +242,7 @@ describe('Parameter', () => { cy.getByTestId('ParameterApplyButton') .click(); - cy.getByTestId('DynamicTable') + cy.getByTestId('TableVisualization') .should('contain', Cypress.moment(this.now).format('DD/MM/YY')); }); @@ -271,7 +300,7 @@ describe('Parameter', () => { cy.getByTestId('ParameterApplyButton') .click(); - cy.getByTestId('DynamicTable') + cy.getByTestId('TableVisualization') .should('contain', Cypress.moment(this.now).format('YYYY-MM-15 HH:mm')); }); @@ -289,7 +318,7 @@ describe('Parameter', () => { cy.getByTestId('ParameterApplyButton') .click(); - cy.getByTestId('DynamicTable') + cy.getByTestId('TableVisualization') .should('contain', Cypress.moment(this.now).format('YYYY-MM-DD HH:mm')); }); @@ -305,7 +334,7 @@ describe('Parameter', () => { cy.getByTestId('ParameterApplyButton') .click(); - cy.getByTestId('DynamicTable') + cy.getByTestId('TableVisualization') .should('contain', Cypress.moment(this.now).format('YYYY-MM-DD HH:mm')); }); @@ -374,7 +403,7 @@ describe('Parameter', () => { .click(); const now = Cypress.moment(this.now); - cy.getByTestId('DynamicTable') + cy.getByTestId('TableVisualization') .should('contain', now.format('YYYY-MM-15') + ' - ' + now.format('YYYY-MM-20')); }); @@ -391,7 +420,7 @@ describe('Parameter', () => { .click(); const lastMonth = Cypress.moment(this.now).subtract(1, 'month'); - cy.getByTestId('DynamicTable') + cy.getByTestId('TableVisualization') .should('contain', lastMonth.startOf('month').format('YYYY-MM-DD') + ' - ' + lastMonth.endOf('month').format('YYYY-MM-DD')); }); diff --git a/client/cypress/integration/visualizations/table/.mocks/all-cell-types.js b/client/cypress/integration/visualizations/table/.mocks/all-cell-types.js new file mode 100644 index 0000000000..f5a7e195c5 --- /dev/null +++ b/client/cypress/integration/visualizations/table/.mocks/all-cell-types.js @@ -0,0 +1,69 @@ +export const query = ` + SELECT + 314159.265359 AS num, + 'test' AS str, + 'hello, world' AS html, + 'hello, world' AS html2, + 'Link: http://example.com' AS html3, + '1995-09-03T07:45' AS "date", + true AS bool, + '[{"a": 3.14, "b": "test", "c": [], "d": {}}, false, [null, 123], "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."]' AS json, + 'ukr' AS img, + 'redash' AS link +`; + +export const config = { + itemsPerPage: 25, + columns: [ + { + name: 'num', + displayAs: 'number', + numberFormat: '0.000', + }, { + name: 'str', + displayAs: 'string', + allowHTML: true, + highlightLinks: false, + }, { + name: 'html', + displayAs: 'string', + allowHTML: true, + highlightLinks: false, + }, { + name: 'html2', + displayAs: 'string', + allowHTML: false, + highlightLinks: false, + }, { + name: 'html3', + displayAs: 'string', + allowHTML: true, + highlightLinks: true, + }, { + name: 'date', + displayAs: 'datetime', + dateTimeFormat: 'D MMMM YYYY, h:mm A', + }, { + name: 'bool', + displayAs: 'boolean', + booleanValues: ['No', 'Yes'], + }, { + name: 'json', + displayAs: 'json', + }, { + name: 'img', + displayAs: 'image', + imageUrlTemplate: 'https://github.com/raw/linssen/country-flag-icons/master/images/png/{{ @ }}.png', + imageTitleTemplate: 'ISO: {{ @ }}', + imageWidth: '30', + imageHeight: '', + }, { + name: 'link', + displayAs: 'link', + linkUrlTemplate: 'https://www.google.com.ua/search?q={{ @ }}', + linkTextTemplate: 'Search for \'{{ @ }}\'', + linkTitleTemplate: 'Search for \'{{ @ }}\'', + linkOpenInNewTab: true, + }, + ], +}; diff --git a/client/cypress/integration/visualizations/table/.mocks/large-dataset.js b/client/cypress/integration/visualizations/table/.mocks/large-dataset.js new file mode 100644 index 0000000000..4b294fefc5 --- /dev/null +++ b/client/cypress/integration/visualizations/table/.mocks/large-dataset.js @@ -0,0 +1,33 @@ +const loremIpsum = 'Lorem ipsum dolor sit amet consectetur adipiscing elit' + + 'sed do eiusmod tempor incididunt ut labore et dolore magna aliqua'; + +function pseudoRandom(seed) { + const x = Math.sin(seed) * 10000; + return x - Math.floor(x); +} + +function randomString(index) { + const n = pseudoRandom(index); + const offset = Math.floor(n * loremIpsum.length); + const length = Math.floor(n * 15) + 1; + return loremIpsum.substr(offset, length).trim(); +} + +export const query = new Array(400) + .fill(null) // we actually don't need these values, but `.map()` ignores undefined elements + .map((unused, index) => `SELECT ${index} AS a, '${randomString(index)}' as b`) + .join(' UNION ALL\n'); + +export const config = { + itemsPerPage: 10, + columns: [ + { + name: 'a', + displayAs: 'number', + numberFormat: '0', + }, { + name: 'b', + displayAs: 'string', + }, + ], +}; diff --git a/client/cypress/integration/visualizations/table/.mocks/multi-column-sort.js b/client/cypress/integration/visualizations/table/.mocks/multi-column-sort.js new file mode 100644 index 0000000000..dde2fb4f8e --- /dev/null +++ b/client/cypress/integration/visualizations/table/.mocks/multi-column-sort.js @@ -0,0 +1,30 @@ +export const query = ` + SELECT 3 AS a, 1 AS b, 'h' AS c UNION ALL + SELECT 1 AS a, 1 AS b, 'b' AS c UNION ALL + SELECT 2 AS a, 1 AS b, 'e' AS c UNION ALL + SELECT 1 AS a, 3 AS b, 'd' AS c UNION ALL + SELECT 2 AS a, 2 AS b, 'f' AS c UNION ALL + SELECT 1 AS a, 1 AS b, 'a' AS c UNION ALL + SELECT 3 AS a, 2 AS b, 'i' AS c UNION ALL + SELECT 2 AS a, 3 AS b, 'g' AS c UNION ALL + SELECT 1 AS a, 2 AS b, 'c' AS c UNION ALL + SELECT 3 AS a, 3 AS b, 'j' AS c +`; + +export const config = { + itemsPerPage: 25, + columns: [ + { + name: 'a', + displayAs: 'number', + numberFormat: '0', + }, { + name: 'b', + displayAs: 'number', + numberFormat: '0', + }, { + name: 'c', + displayAs: 'string', + }, + ], +}; diff --git a/client/cypress/integration/visualizations/table/.mocks/search-in-data.js b/client/cypress/integration/visualizations/table/.mocks/search-in-data.js new file mode 100644 index 0000000000..920426dff6 --- /dev/null +++ b/client/cypress/integration/visualizations/table/.mocks/search-in-data.js @@ -0,0 +1,24 @@ +export const query = ` + SELECT 'contains test' AS a, 'random string' AS b, 'another string' AS c UNION ALL + SELECT 'contains test' AS a, 'also contains Test' AS b, '' AS c UNION ALL + SELECT 'lorem ipsum' AS a, 'but TEST is here' AS b, 'none' AS c UNION ALL + SELECT 'should not appear' AS a, 'because' AS b, '"test" is here' AS c +`; + +export const config = { + itemsPerPage: 25, + columns: [ + { + name: 'a', + displayAs: 'string', + allowSearch: true, + }, { + name: 'b', + displayAs: 'string', + allowSearch: true, + }, { + name: 'c', + allowSearch: false, + }, + ], +}; diff --git a/client/cypress/integration/visualizations/table/table_spec.js b/client/cypress/integration/visualizations/table/table_spec.js new file mode 100644 index 0000000000..fc222d678a --- /dev/null +++ b/client/cypress/integration/visualizations/table/table_spec.js @@ -0,0 +1,123 @@ +/* global cy, Cypress */ + +/* + This test suite relies on Percy (does not validate rendered visualizations) +*/ + +import { createQuery, createVisualization } from '../../../support/redash-api'; +import * as AllCellTypes from './.mocks/all-cell-types'; +import * as MultiColumnSort from './.mocks/multi-column-sort'; +import * as SearchInData from './.mocks/search-in-data'; +import * as LargeDataset from './.mocks/large-dataset'; + +function prepareVisualization(query, type, name, options) { + return createQuery({ query }) + .then(({ id }) => createVisualization(id, type, name, options)) + .then(({ id: visualizationId, query_id: queryId }) => { + // use data-only view because we don't need editor features, but it will + // free more space for visualizations. Also, we'll hide schema browser (via shortcut) + cy.visit(`queries/${queryId}`); + + cy.getByTestId('ExecuteButton').click(); + cy.get('body').type('{alt}D'); + + // do some pre-checks here to ensure that visualization was created and is visible + cy.getByTestId('QueryPageVisualizationTabs').find(`li[tab-id=${visualizationId}]`) + .should('exist').click(); + cy.getByTestId(`QueryPageVisualization${visualizationId}`).should('exist') + .find('table').should('exist'); + + return cy.then(() => ({ queryId, visualizationId })); + }); +} + +describe('Table', () => { + const viewportWidth = Cypress.config('viewportWidth'); + + beforeEach(() => { + cy.login(); + }); + + it('renders all cell types', () => { + const { query, config } = AllCellTypes; + prepareVisualization(query, 'TABLE', 'All cell types', config) + .then(() => { + // expand JSON cell + cy.get('.jvi-item.jvi-root .jvi-toggle').should('exist').click(); + cy.get('.jvi-item.jvi-root .jvi-item .jvi-toggle').should('exist').click({ multiple: true }); + + cy.percySnapshot('Visualizations - Table (All cell types)', { widths: [viewportWidth] }); + }); + }); + + describe('Sorting data', () => { + beforeEach(function () { + const { query, config } = MultiColumnSort; + prepareVisualization(query, 'TABLE', 'Sort data', config) + .then(({ queryId, visualizationId }) => { + this.queryId = queryId; + this.visualizationId = visualizationId; + }); + }); + + it('sorts data by a single column', function () { + const { visualizationId } = this; + + cy.getByTestId(`QueryPageVisualization${visualizationId}`) + .find('table th').contains('c').should('exist') + .click(); + cy.percySnapshot('Visualizations - Table (Single-column sort)', { widths: [viewportWidth] }); + }); + + it('sorts data by a multiple columns', function () { + const { visualizationId } = this; + + cy.getByTestId(`QueryPageVisualization${visualizationId}`) + .find('table th').contains('a').should('exist') + .click(); + + cy.get('body').type('{shift}', { release: false }); + cy.getByTestId(`QueryPageVisualization${visualizationId}`) + .find('table th').contains('b').should('exist') + .click(); + + cy.percySnapshot('Visualizations - Table (Multi-column sort)', { widths: [viewportWidth] }); + }); + + it('sorts data in reverse order', function () { + const { visualizationId } = this; + + cy.getByTestId(`QueryPageVisualization${visualizationId}`) + .find('table th').contains('c').should('exist') + .click() + .click(); + cy.percySnapshot('Visualizations - Table (Single-column reverse sort)', { widths: [viewportWidth] }); + }); + }); + + it('searches in multiple columns', () => { + const { query, config } = SearchInData; + prepareVisualization(query, 'TABLE', 'Search', config) + .then(({ visualizationId }) => { + cy.getByTestId(`QueryPageVisualization${visualizationId}`) + .find('table input').should('exist') + .type('test'); + cy.percySnapshot('Visualizations - Table (Search in data)', { widths: [viewportWidth] }); + }); + }); + + it('shows pagination and navigates to third page', () => { + const { query, config } = LargeDataset; + prepareVisualization(query, 'TABLE', 'With pagination', config) + .then(({ visualizationId }) => { + cy.getByTestId(`QueryPageVisualization${visualizationId}`) + .find('.ant-table-pagination').should('exist') + .find('li') + .contains('3') + .should('exist') + .click(); + + cy.percySnapshot('Visualizations - Table (Pagination)', { widths: [viewportWidth] }); + }); + }); +}); diff --git a/client/cypress/support/redash-api/index.js b/client/cypress/support/redash-api/index.js index ab834c81f8..8d7905e1dd 100644 --- a/client/cypress/support/redash-api/index.js +++ b/client/cypress/support/redash-api/index.js @@ -30,6 +30,14 @@ export function createQuery(data, shouldPublish = true) { return request; } +export function createVisualization(queryId, type, name, options) { + const data = { query_id: queryId, type, name, options }; + return cy.request('POST', '/api/visualizations', data).then(({ body }) => ({ + query_id: queryId, + ...body, + })); +} + export function addTextbox(dashboardId, text = 'text', options = {}) { const defaultOptions = { position: { col: 0, row: 0, sizeX: 3, sizeY: 3 }, diff --git a/package-lock.json b/package-lock.json index 83e97c9f89..00020ef7fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2518,7 +2518,7 @@ }, "bl": { "version": "1.2.2", - "resolved": "https://registry.npmjs.org/bl/-/bl-1.2.2.tgz", + "resolved": "http://registry.npmjs.org/bl/-/bl-1.2.2.tgz", "integrity": "sha512-e8tQYnZodmebYDWGH7KMRvtzKXaJHx3BbilrgZCfvyLUYdKpK1t5PSPmpkny/SgiTSCnjfLW7v5rlONXVFkQEA==", "requires": { "readable-stream": "^2.3.5", @@ -2532,7 +2532,7 @@ }, "readable-stream": { "version": "2.3.6", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", "requires": { "core-util-is": "~1.0.0", @@ -2750,7 +2750,7 @@ }, "readable-stream": { "version": "2.3.6", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", "requires": { "core-util-is": "~1.0.0", @@ -6365,7 +6365,7 @@ }, "finalhandler": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.1.tgz", + "resolved": "http://registry.npmjs.org/finalhandler/-/finalhandler-1.1.1.tgz", "integrity": "sha512-Y1GUDo39ez4aHAw7MysnUD5JzYX+WaIj8I57kO3aEPT1fFRL4sr7mjei97FgnwhAyyzRYmQZaTHb2+9uZ1dPtg==", "dev": true, "requires": { @@ -8871,7 +8871,7 @@ }, "readable-stream": { "version": "2.3.6", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", "requires": { "core-util-is": "~1.0.0", @@ -9203,7 +9203,7 @@ }, "readable-stream": { "version": "2.3.6", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", "dev": true, "requires": { @@ -9297,7 +9297,7 @@ }, "html-webpack-plugin": { "version": "3.2.0", - "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-3.2.0.tgz", + "resolved": "http://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-3.2.0.tgz", "integrity": "sha1-sBq71yOsqqeze2r0SS69oD2d03s=", "dev": true, "requires": { @@ -9945,7 +9945,7 @@ }, "is-obj": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", + "resolved": "http://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", "integrity": "sha1-PkcprB9f3gJc19g6iW2rn09n2w8=" }, "is-observable": { @@ -11503,7 +11503,7 @@ }, "magic-string": { "version": "0.22.5", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.22.5.tgz", + "resolved": "http://registry.npmjs.org/magic-string/-/magic-string-0.22.5.tgz", "integrity": "sha512-oreip9rJZkzvA8Qzk9HFs8fZGF/u7H/gtrE8EN6RjKJ9kh2HlC+yQ2QezifqTZfGyiuAV0dRv5a+y/8gBb1m9w==", "requires": { "vlq": "^0.2.2" @@ -11615,12 +11615,12 @@ }, "minimist": { "version": "0.0.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "resolved": "http://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=" }, "readable-stream": { "version": "2.3.6", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", "requires": { "core-util-is": "~1.0.0", @@ -11819,7 +11819,7 @@ }, "readable-stream": { "version": "2.3.6", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", "dev": true, "requires": { @@ -12015,7 +12015,7 @@ }, "readable-stream": { "version": "2.3.6", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", "dev": true, "requires": { @@ -12516,7 +12516,7 @@ }, "readable-stream": { "version": "2.3.6", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", "dev": true, "requires": { @@ -12918,7 +12918,7 @@ "dependencies": { "minimist": { "version": "0.0.10", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz", + "resolved": "http://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz", "integrity": "sha1-3j+YVD2/lggr5IrRoMfNqDYwHc8=", "dev": true }, @@ -16112,7 +16112,7 @@ "dependencies": { "minimist": { "version": "0.0.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.5.tgz", + "resolved": "http://registry.npmjs.org/minimist/-/minimist-0.0.5.tgz", "integrity": "sha1-16oye87PUY+RBqxrjwA/o7zqhWY=" } } @@ -16600,7 +16600,7 @@ }, "split": { "version": "0.2.10", - "resolved": "https://registry.npmjs.org/split/-/split-0.2.10.tgz", + "resolved": "http://registry.npmjs.org/split/-/split-0.2.10.tgz", "integrity": "sha1-Zwl8YB1pfOE2j0GPBs0gHPBSGlc=", "requires": { "through": "2" @@ -16958,7 +16958,7 @@ "dependencies": { "readable-stream": { "version": "1.1.14", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", + "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", "integrity": "sha1-fPTFTvZI44EwhMY23SB54WbAgdk=", "requires": { "core-util-is": "~1.0.0", @@ -17092,7 +17092,7 @@ }, "strip-ansi": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "resolved": "http://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", "dev": true, "requires": { @@ -17863,15 +17863,6 @@ "integrity": "sha1-5z3T17DXxe2G+6xrCufYxqadUPo=", "dev": true }, - "underscore.string": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/underscore.string/-/underscore.string-3.3.5.tgz", - "integrity": "sha512-g+dpmgn+XBneLmXXo+sGlW5xQEt4ErkS3mgeN2GFbremYeMBSJKr9Wf2KJplQVaiPY/f7FN6atosWYNm9ovrYg==", - "requires": { - "sprintf-js": "^1.0.3", - "util-deprecate": "^1.0.2" - } - }, "unicode-canonical-property-names-ecmascript": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz", diff --git a/package.json b/package.json index ef74dfee44..d011d2573d 100644 --- a/package.json +++ b/package.json @@ -83,8 +83,7 @@ "react-dom": "^16.8.3", "react-grid-layout": "git+https://github.com/getredash/react-grid-layout.git", "react2angular": "^3.2.1", - "ui-select": "^0.19.8", - "underscore.string": "^3.3.4" + "ui-select": "^0.19.8" }, "devDependencies": { "@babel/core": "^7.2.2", diff --git a/redash/handlers/widgets.py b/redash/handlers/widgets.py index 0949cc59fb..85be9c0cbb 100644 --- a/redash/handlers/widgets.py +++ b/redash/handlers/widgets.py @@ -24,12 +24,11 @@ def post(self): :>json object widget: The created widget """ widget_properties = request.get_json(force=True) - dashboard = models.Dashboard.get_by_id_and_org(widget_properties.pop('dashboard_id'), self.current_org) + dashboard = models.Dashboard.get_by_id_and_org(widget_properties.get('dashboard_id'), self.current_org) require_object_modify_permission(dashboard, self.current_user) widget_properties['options'] = json_dumps(widget_properties['options']) widget_properties.pop('id', None) - widget_properties['dashboard'] = dashboard visualization_id = widget_properties.pop('visualization_id') if visualization_id: diff --git a/redash/models/parameterized_query.py b/redash/models/parameterized_query.py index 333749627c..5587bd3f45 100644 --- a/redash/models/parameterized_query.py +++ b/redash/models/parameterized_query.py @@ -36,6 +36,21 @@ def dropdown_values(query_id): return map(pluck, data["rows"]) +def join_parameter_list_values(parameters, schema): + updated_parameters = {} + for (key, value) in parameters.iteritems(): + if isinstance(value, list): + definition = next((definition for definition in schema if definition["name"] == key), {}) + multi_values_options = definition.get('multiValuesOptions', {}) + separator = str(multi_values_options.get('separator', ',')) + prefix = str(multi_values_options.get('prefix', '')) + suffix = str(multi_values_options.get('suffix', '')) + updated_parameters[key] = separator.join(map(lambda v: prefix + v + suffix, value)) + else: + updated_parameters[key] = value + return updated_parameters + + def _collect_key_names(nodes): keys = [] for node in nodes._parse_tree: @@ -92,6 +107,12 @@ def _is_date_range(obj): return False +def _is_value_within_options(value, dropdown_options, allow_list=False): + if isinstance(value, list): + return allow_list and set(map(unicode, value)).issubset(set(dropdown_options)) + return unicode(value) in dropdown_options + + class ParameterizedQuery(object): def __init__(self, template, schema=None): self.schema = schema or [] @@ -105,7 +126,7 @@ def apply(self, parameters): raise InvalidParameterError(invalid_parameter_names) else: self.parameters.update(parameters) - self.query = mustache_render(self.template, self.parameters) + self.query = mustache_render(self.template, join_parameter_list_values(parameters, self.schema)) return self @@ -118,11 +139,22 @@ def _valid(self, name, value): if not definition: return False + enum_options = definition.get('enumOptions') + query_id = definition.get('queryId') + allow_multiple_values = isinstance(definition.get('multiValuesOptions'), dict) + + if isinstance(enum_options, basestring): + enum_options = enum_options.split('\n') + validators = { "text": lambda value: isinstance(value, basestring), "number": _is_number, - "enum": lambda value: value in definition["enumOptions"], - "query": lambda value: unicode(value) in [v["value"] for v in dropdown_values(definition["queryId"])], + "enum": lambda value: _is_value_within_options(value, + enum_options, + allow_multiple_values), + "query": lambda value: _is_value_within_options(value, + [v["value"] for v in dropdown_values(query_id)], + allow_multiple_values), "date": _is_date, "datetime-local": _is_date, "datetime-with-seconds": _is_date, diff --git a/redash/tasks/queries.py b/redash/tasks/queries.py index e923639bce..455a9735b7 100644 --- a/redash/tasks/queries.py +++ b/redash/tasks/queries.py @@ -193,7 +193,7 @@ def refresh_queries(): if query.options and len(query.options.get('parameters', [])) > 0: query_params = {p['name']: p.get('value') for p in query.options['parameters']} - query_text = mustache_render(query.query_text, query_params) + query_text = query.parameterized.apply(query_params).query else: query_text = query.query_text diff --git a/requirements_all_ds.txt b/requirements_all_ds.txt index 5e827115ef..9dd00e4c8b 100644 --- a/requirements_all_ds.txt +++ b/requirements_all_ds.txt @@ -26,7 +26,7 @@ qds-sdk>=1.9.6 ibm-db>=2.0.9 pydruid==0.4 requests_aws_sign==0.1.4 -snowflake_connector_python==1.6.10 +snowflake_connector_python==1.8.6 phoenixdb==0.7 # certifi is needed to support MongoDB and SSL: certifi diff --git a/tests/models/test_parameterized_query.py b/tests/models/test_parameterized_query.py index bbce9ba608..ab3a66dad5 100644 --- a/tests/models/test_parameterized_query.py +++ b/tests/models/test_parameterized_query.py @@ -119,6 +119,18 @@ def test_raises_on_unlisted_enum_value_parameters(self): with pytest.raises(InvalidParameterError): query.apply({"bar": "shlomo"}) + def test_raises_on_unlisted_enum_list_value_parameters(self): + schema = [{ + "name": "bar", + "type": "enum", + "enumOptions": ["baz", "qux"], + "multiValuesOptions": {"separator": ",", "prefix": "", "suffix": ""} + }] + query = ParameterizedQuery("foo", schema) + + with pytest.raises(InvalidParameterError): + query.apply({"bar": ["shlomo", "baz"]}) + def test_validates_enum_parameters(self): schema = [{"name": "bar", "type": "enum", "enumOptions": ["baz", "qux"]}] query = ParameterizedQuery("foo {{bar}}", schema) @@ -127,6 +139,19 @@ def test_validates_enum_parameters(self): self.assertEquals("foo baz", query.text) + def test_validates_enum_list_value_parameters(self): + schema = [{ + "name": "bar", + "type": "enum", + "enumOptions": ["baz", "qux"], + "multiValuesOptions": {"separator": ",", "prefix": "'", "suffix": "'"} + }] + query = ParameterizedQuery("foo {{bar}}", schema) + + query.apply({"bar": ["qux", "baz"]}) + + self.assertEquals("foo 'qux','baz'", query.text) + @patch('redash.models.parameterized_query.dropdown_values', return_value=[{"value": "1"}]) def test_validation_accepts_integer_values_for_dropdowns(self, _): schema = [{"name": "bar", "type": "query", "queryId": 1}] diff --git a/webpack.config.js b/webpack.config.js index a413079ea7..d99fbbaca2 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -225,6 +225,9 @@ const config = { modules: false, chunkModules: false } + }, + performance: { + hints: false } };