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 @@
-
-
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 @@
-