mirror of https://github.com/apache/superset.git
[Explore] Streamlined metric definitions for SQLA and Druid (#4663)
* adding streamlined metric editing * addressing lint issues on new metrics control * enabling druid
This commit is contained in:
parent
7e1b6b7363
commit
68dec24542
|
@ -38,5 +38,8 @@
|
|||
"react/no-unescaped-entities": 0,
|
||||
"react/no-unused-prop-types": 0,
|
||||
"react/no-string-refs": 0,
|
||||
"indent": 0,
|
||||
"no-multi-spaces": 0,
|
||||
"padded-blocks": 0,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,16 +2,20 @@ import React from 'react';
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
const propTypes = {
|
||||
type: PropTypes.string.isRequired,
|
||||
type: PropTypes.string,
|
||||
};
|
||||
|
||||
export default function ColumnTypeLabel({ type }) {
|
||||
let stringIcon = '';
|
||||
if (type === '' || type === 'expression') {
|
||||
if (typeof type !== 'string') {
|
||||
stringIcon = '?';
|
||||
} else if (type === '' || type === 'expression') {
|
||||
stringIcon = 'ƒ';
|
||||
} else if (type === 'aggregate') {
|
||||
stringIcon = 'AGG';
|
||||
} else if (type.match(/.*char.*/i) || type.match(/string.*/i) || type.match(/.*text.*/i)) {
|
||||
stringIcon = 'ABC';
|
||||
} else if (type.match(/.*int.*/i) || type === 'LONG' || type === 'DOUBLE') {
|
||||
} else if (type.match(/.*int.*/i) || type === 'LONG' || type === 'DOUBLE' || type === 'FLOAT') {
|
||||
stringIcon = '#';
|
||||
} else if (type.match(/.*bool.*/i)) {
|
||||
stringIcon = 'T/F';
|
||||
|
|
|
@ -49,8 +49,8 @@ export default class OnPasteSelect extends React.Component {
|
|||
render() {
|
||||
const SelectComponent = this.props.selectWrap;
|
||||
const refFunc = (ref) => {
|
||||
if (this.props.ref) {
|
||||
this.props.ref(ref);
|
||||
if (this.props.refFunc) {
|
||||
this.props.refFunc(ref);
|
||||
}
|
||||
this.pasteInput = ref;
|
||||
};
|
||||
|
@ -68,7 +68,7 @@ export default class OnPasteSelect extends React.Component {
|
|||
OnPasteSelect.propTypes = {
|
||||
separator: PropTypes.string.isRequired,
|
||||
selectWrap: PropTypes.func.isRequired,
|
||||
ref: PropTypes.func,
|
||||
refFunc: PropTypes.func,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
valueKey: PropTypes.string.isRequired,
|
||||
labelKey: PropTypes.string.isRequired,
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable camelcase */
|
||||
import { combineReducers } from 'redux';
|
||||
import d3 from 'd3';
|
||||
import shortid from 'shortid';
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
export default class AdhocMetric {
|
||||
constructor(adhocMetric) {
|
||||
this.column = adhocMetric.column;
|
||||
this.aggregate = adhocMetric.aggregate;
|
||||
this.hasCustomLabel = !!(adhocMetric.hasCustomLabel && adhocMetric.label);
|
||||
this.fromFormData = !!adhocMetric.optionName;
|
||||
this.label = this.hasCustomLabel ? adhocMetric.label : this.getDefaultLabel();
|
||||
|
||||
this.optionName = adhocMetric.optionName ||
|
||||
`metric_${Math.random().toString(36).substring(2, 15)}_${Math.random().toString(36).substring(2, 15)}`;
|
||||
}
|
||||
|
||||
getDefaultLabel() {
|
||||
return `${this.aggregate || ''}(${(this.column && this.column.column_name) || ''})`;
|
||||
}
|
||||
|
||||
duplicateWith(nextFields) {
|
||||
return new AdhocMetric({
|
||||
...this,
|
||||
...nextFields,
|
||||
});
|
||||
}
|
||||
|
||||
equals(adhocMetric) {
|
||||
return adhocMetric.label === this.label &&
|
||||
adhocMetric.aggregate === this.aggregate &&
|
||||
(
|
||||
(adhocMetric.column && adhocMetric.column.column_name) ===
|
||||
(this.column && this.column.column_name)
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,141 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Button, ControlLabel, FormGroup, Popover } from 'react-bootstrap';
|
||||
import VirtualizedSelect from 'react-virtualized-select';
|
||||
|
||||
import { AGGREGATES } from '../constants';
|
||||
import { t } from '../../locales';
|
||||
import VirtualizedRendererWrap from '../../components/VirtualizedRendererWrap';
|
||||
import OnPasteSelect from '../../components/OnPasteSelect';
|
||||
import AdhocMetricEditPopoverTitle from './AdhocMetricEditPopoverTitle';
|
||||
import columnType from '../propTypes/columnType';
|
||||
import AdhocMetric from '../AdhocMetric';
|
||||
import ColumnOption from '../../components/ColumnOption';
|
||||
|
||||
const propTypes = {
|
||||
adhocMetric: PropTypes.instanceOf(AdhocMetric).isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
columns: PropTypes.arrayOf(columnType),
|
||||
datasourceType: PropTypes.string,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
columns: [],
|
||||
};
|
||||
|
||||
export default class AdhocMetricEditPopover extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.onSave = this.onSave.bind(this);
|
||||
this.onColumnChange = this.onColumnChange.bind(this);
|
||||
this.onAggregateChange = this.onAggregateChange.bind(this);
|
||||
this.onLabelChange = this.onLabelChange.bind(this);
|
||||
this.state = { adhocMetric: this.props.adhocMetric };
|
||||
this.selectProps = {
|
||||
multi: false,
|
||||
name: 'select-column',
|
||||
labelKey: 'label',
|
||||
autosize: false,
|
||||
clearable: true,
|
||||
selectWrap: VirtualizedSelect,
|
||||
};
|
||||
}
|
||||
|
||||
onSave() {
|
||||
this.props.onChange(this.state.adhocMetric);
|
||||
this.props.onClose();
|
||||
}
|
||||
|
||||
onColumnChange(column) {
|
||||
this.setState({ adhocMetric: this.state.adhocMetric.duplicateWith({ column }) });
|
||||
}
|
||||
|
||||
onAggregateChange(aggregate) {
|
||||
// we construct this object explicitly to overwrite the value in the case aggregate is null
|
||||
this.setState({
|
||||
adhocMetric: this.state.adhocMetric.duplicateWith({
|
||||
aggregate: aggregate && aggregate.aggregate,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
onLabelChange(e) {
|
||||
this.setState({
|
||||
adhocMetric: this.state.adhocMetric.duplicateWith({
|
||||
label: e.target.value, hasCustomLabel: true,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { adhocMetric, columns, onChange, onClose, datasourceType, ...popoverProps } = this.props;
|
||||
|
||||
const columnSelectProps = {
|
||||
placeholder: t('%s column(s)', columns.length),
|
||||
options: columns,
|
||||
value: this.state.adhocMetric.column && this.state.adhocMetric.column.column_name,
|
||||
onChange: this.onColumnChange,
|
||||
optionRenderer: VirtualizedRendererWrap(option => (
|
||||
<ColumnOption column={option} showType />
|
||||
)),
|
||||
valueRenderer: column => column.column_name,
|
||||
valueKey: 'column_name',
|
||||
};
|
||||
|
||||
const aggregateSelectProps = {
|
||||
placeholder: t('%s aggregates(s)', Object.keys(AGGREGATES).length),
|
||||
options: Object.keys(AGGREGATES).map(aggregate => ({ aggregate })),
|
||||
value: this.state.adhocMetric.aggregate,
|
||||
onChange: this.onAggregateChange,
|
||||
optionRenderer: VirtualizedRendererWrap(aggregate => aggregate.aggregate),
|
||||
valueRenderer: aggregate => aggregate.aggregate,
|
||||
valueKey: 'aggregate',
|
||||
};
|
||||
|
||||
if (this.props.datasourceType === 'druid') {
|
||||
aggregateSelectProps.options = aggregateSelectProps.options.filter((
|
||||
option => option.aggregate !== 'AVG'
|
||||
));
|
||||
}
|
||||
|
||||
const popoverTitle = (
|
||||
<AdhocMetricEditPopoverTitle
|
||||
adhocMetric={this.state.adhocMetric}
|
||||
onChange={this.onLabelChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const stateIsValid = this.state.adhocMetric.column && this.state.adhocMetric.aggregate;
|
||||
const hasUnsavedChanges = this.state.adhocMetric.equals(this.props.adhocMetric);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
id="metrics-edit-popover"
|
||||
title={popoverTitle}
|
||||
{...popoverProps}
|
||||
>
|
||||
<FormGroup>
|
||||
<ControlLabel><strong>column</strong></ControlLabel>
|
||||
<OnPasteSelect {...this.selectProps} {...columnSelectProps} />
|
||||
</FormGroup>
|
||||
<FormGroup>
|
||||
<ControlLabel><strong>aggregate</strong></ControlLabel>
|
||||
<OnPasteSelect {...this.selectProps} {...aggregateSelectProps} />
|
||||
</FormGroup>
|
||||
<Button
|
||||
disabled={!stateIsValid}
|
||||
bsStyle={(hasUnsavedChanges || !stateIsValid) ? 'default' : 'primary'}
|
||||
bsSize="small"
|
||||
className="m-r-5"
|
||||
onClick={this.onSave}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button bsSize="small" onClick={this.props.onClose}>Close</Button>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
}
|
||||
AdhocMetricEditPopover.propTypes = propTypes;
|
||||
AdhocMetricEditPopover.defaultProps = defaultProps;
|
|
@ -0,0 +1,79 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormControl, OverlayTrigger, Tooltip } from 'react-bootstrap';
|
||||
import AdhocMetric from '../AdhocMetric';
|
||||
|
||||
const propTypes = {
|
||||
adhocMetric: PropTypes.instanceOf(AdhocMetric),
|
||||
onChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default class AdhocMetricEditPopoverTitle extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.onMouseOver = this.onMouseOver.bind(this);
|
||||
this.onMouseOut = this.onMouseOut.bind(this);
|
||||
this.onClick = this.onClick.bind(this);
|
||||
this.onBlur = this.onBlur.bind(this);
|
||||
this.state = {
|
||||
isHovered: false,
|
||||
isEditable: false,
|
||||
};
|
||||
}
|
||||
|
||||
onMouseOver() {
|
||||
this.setState({ isHovered: true });
|
||||
}
|
||||
|
||||
onMouseOut() {
|
||||
this.setState({ isHovered: false });
|
||||
}
|
||||
|
||||
onClick() {
|
||||
this.setState({ isEditable: true });
|
||||
}
|
||||
|
||||
onBlur() {
|
||||
this.setState({ isEditable: false });
|
||||
}
|
||||
|
||||
refFunc(ref) {
|
||||
if (ref) {
|
||||
ref.focus();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { adhocMetric, onChange } = this.props;
|
||||
|
||||
const editPrompt = <Tooltip id="edit-metric-label-tooltip">Click to edit label</Tooltip>;
|
||||
|
||||
return (
|
||||
<OverlayTrigger
|
||||
placement="top"
|
||||
overlay={editPrompt}
|
||||
onMouseOver={this.onMouseOver}
|
||||
onMouseOut={this.onMouseOut}
|
||||
onClick={this.onClick}
|
||||
onBlur={this.onBlur}
|
||||
>
|
||||
{this.state.isEditable ?
|
||||
<FormControl
|
||||
className="metric-edit-popover-label-input"
|
||||
type="text"
|
||||
placeholder={adhocMetric.label}
|
||||
value={adhocMetric.hasCustomLabel ? adhocMetric.label : ''}
|
||||
onChange={onChange}
|
||||
inputRef={this.refFunc}
|
||||
/> :
|
||||
<span>
|
||||
{adhocMetric.hasCustomLabel ? adhocMetric.label : 'My Metric'}
|
||||
|
||||
<i className="fa fa-pencil" style={{ color: this.state.isHovered ? 'black' : 'grey' }} />
|
||||
</span>
|
||||
}
|
||||
</OverlayTrigger>
|
||||
);
|
||||
}
|
||||
}
|
||||
AdhocMetricEditPopoverTitle.propTypes = propTypes;
|
|
@ -0,0 +1,60 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Label, OverlayTrigger } from 'react-bootstrap';
|
||||
|
||||
import AdhocMetricEditPopover from './AdhocMetricEditPopover';
|
||||
import AdhocMetric from '../AdhocMetric';
|
||||
import columnType from '../propTypes/columnType';
|
||||
|
||||
const propTypes = {
|
||||
adhocMetric: PropTypes.instanceOf(AdhocMetric),
|
||||
onMetricEdit: PropTypes.func.isRequired,
|
||||
columns: PropTypes.arrayOf(columnType),
|
||||
multi: PropTypes.bool,
|
||||
datasourceType: PropTypes.string,
|
||||
};
|
||||
|
||||
export default class AdhocMetricOption extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.closeMetricEditOverlay = this.closeMetricEditOverlay.bind(this);
|
||||
}
|
||||
|
||||
closeMetricEditOverlay() {
|
||||
this.refs.overlay.hide();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { adhocMetric } = this.props;
|
||||
const overlay = (
|
||||
<AdhocMetricEditPopover
|
||||
adhocMetric={adhocMetric}
|
||||
onChange={this.props.onMetricEdit}
|
||||
onClose={this.closeMetricEditOverlay}
|
||||
columns={this.props.columns}
|
||||
datasourceType={this.props.datasourceType}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<OverlayTrigger
|
||||
ref="overlay"
|
||||
placement="right"
|
||||
trigger="click"
|
||||
disabled
|
||||
overlay={overlay}
|
||||
rootClose
|
||||
defaultOverlayShown={!adhocMetric.fromFormData}
|
||||
>
|
||||
<Label style={{ margin: this.props.multi ? 0 : 3, cursor: 'pointer' }}>
|
||||
<div onMouseDownCapture={(e) => { e.stopPropagation(); }}>
|
||||
<span className="m-r-5 option-label">
|
||||
{adhocMetric.label}
|
||||
</span>
|
||||
</div>
|
||||
</Label>
|
||||
</OverlayTrigger>
|
||||
);
|
||||
}
|
||||
}
|
||||
AdhocMetricOption.propTypes = propTypes;
|
|
@ -0,0 +1,22 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import ColumnTypeLabel from '../../components/ColumnTypeLabel';
|
||||
import aggregateOptionType from '../propTypes/aggregateOptionType';
|
||||
|
||||
const propTypes = {
|
||||
aggregate: aggregateOptionType,
|
||||
showType: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default function AggregateOption({ aggregate, showType }) {
|
||||
return (
|
||||
<div>
|
||||
{showType && <ColumnTypeLabel type="aggregate" />}
|
||||
<span className="m-r-5 option-label">
|
||||
{aggregate.aggregate_name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
AggregateOption.propTypes = propTypes;
|
|
@ -0,0 +1,36 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import MetricOption from '../../components/MetricOption';
|
||||
import ColumnOption from '../../components/ColumnOption';
|
||||
import AggregateOption from './AggregateOption';
|
||||
import columnType from '../propTypes/columnType';
|
||||
import savedMetricType from '../propTypes/savedMetricType';
|
||||
import aggregateOptionType from '../propTypes/aggregateOptionType';
|
||||
|
||||
const propTypes = {
|
||||
option: PropTypes.oneOfType([
|
||||
columnType,
|
||||
savedMetricType,
|
||||
aggregateOptionType,
|
||||
]).isRequired,
|
||||
};
|
||||
|
||||
export default function MetricDefinitionOption({ option }) {
|
||||
if (option.metric_name) {
|
||||
return (
|
||||
<MetricOption metric={option} showType />
|
||||
);
|
||||
} else if (option.column_name) {
|
||||
return (
|
||||
<ColumnOption column={option} showType />
|
||||
);
|
||||
} else if (option.aggregate_name) {
|
||||
return (
|
||||
<AggregateOption aggregate={option} showType />
|
||||
);
|
||||
}
|
||||
notify.error('You must supply either a saved metric, column or aggregate to MetricDefinitionOption');
|
||||
return null;
|
||||
}
|
||||
MetricDefinitionOption.propTypes = propTypes;
|
|
@ -0,0 +1,47 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import AdhocMetricOption from './AdhocMetricOption';
|
||||
import AdhocMetric from '../AdhocMetric';
|
||||
import columnType from '../propTypes/columnType';
|
||||
import MetricOption from '../../components/MetricOption';
|
||||
import savedMetricType from '../propTypes/savedMetricType';
|
||||
import adhocMetricType from '../propTypes/adhocMetricType';
|
||||
|
||||
const propTypes = {
|
||||
option: PropTypes.oneOfType([
|
||||
savedMetricType,
|
||||
adhocMetricType,
|
||||
]).isRequired,
|
||||
onMetricEdit: PropTypes.func,
|
||||
columns: PropTypes.arrayOf(columnType),
|
||||
multi: PropTypes.bool,
|
||||
datasourceType: PropTypes.string,
|
||||
};
|
||||
|
||||
export default function MetricDefinitionValue({
|
||||
option,
|
||||
onMetricEdit,
|
||||
columns,
|
||||
multi,
|
||||
datasourceType,
|
||||
}) {
|
||||
if (option.metric_name) {
|
||||
return (
|
||||
<MetricOption metric={option} />
|
||||
);
|
||||
} else if (option instanceof AdhocMetric) {
|
||||
return (
|
||||
<AdhocMetricOption
|
||||
adhocMetric={option}
|
||||
onMetricEdit={onMetricEdit}
|
||||
columns={columns}
|
||||
multi={multi}
|
||||
datasourceType={datasourceType}
|
||||
/>
|
||||
);
|
||||
}
|
||||
notify.error('You must supply either a saved metric or adhoc metric to MetricDefinitionValue');
|
||||
return null;
|
||||
}
|
||||
MetricDefinitionValue.propTypes = propTypes;
|
|
@ -0,0 +1,256 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import VirtualizedSelect from 'react-virtualized-select';
|
||||
import ControlHeader from '../ControlHeader';
|
||||
import { t } from '../../../locales';
|
||||
import VirtualizedRendererWrap from '../../../components/VirtualizedRendererWrap';
|
||||
import OnPasteSelect from '../../../components/OnPasteSelect';
|
||||
import MetricDefinitionOption from '../MetricDefinitionOption';
|
||||
import MetricDefinitionValue from '../MetricDefinitionValue';
|
||||
import AdhocMetric from '../../AdhocMetric';
|
||||
import columnType from '../../propTypes/columnType';
|
||||
import savedMetricType from '../../propTypes/savedMetricType';
|
||||
import adhocMetricType from '../../propTypes/adhocMetricType';
|
||||
import { AGGREGATES } from '../../constants';
|
||||
|
||||
const propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func,
|
||||
value: PropTypes.oneOfType([
|
||||
PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, adhocMetricType])),
|
||||
PropTypes.oneOfType([PropTypes.string, adhocMetricType]),
|
||||
]),
|
||||
columns: PropTypes.arrayOf(columnType),
|
||||
savedMetrics: PropTypes.arrayOf(savedMetricType),
|
||||
multi: PropTypes.bool,
|
||||
datasourceType: PropTypes.string,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
onChange: () => {},
|
||||
};
|
||||
|
||||
function isDictionaryForAdhocMetric(value) {
|
||||
return value && !(value instanceof AdhocMetric) && value.column && value.aggregate && value.label;
|
||||
}
|
||||
|
||||
// adhoc metrics are stored as dictionaries in URL params. We convert them back into the
|
||||
// AdhocMetric class for typechecking, consistency and instance method access.
|
||||
function coerceAdhocMetrics(value) {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
if (!Array.isArray(value)) {
|
||||
if (isDictionaryForAdhocMetric(value)) {
|
||||
return [new AdhocMetric(value)];
|
||||
}
|
||||
return [value];
|
||||
}
|
||||
return value.map((val) => {
|
||||
if (isDictionaryForAdhocMetric(val)) {
|
||||
return new AdhocMetric(val);
|
||||
}
|
||||
return val;
|
||||
});
|
||||
}
|
||||
|
||||
function getDefaultAggregateForColumn(column) {
|
||||
const type = column.type;
|
||||
if (typeof type !== 'string') {
|
||||
return AGGREGATES.COUNT;
|
||||
} else if (type === '' || type === 'expression') {
|
||||
return AGGREGATES.SUM;
|
||||
} else if (type.match(/.*char.*/i) || type.match(/string.*/i) || type.match(/.*text.*/i)) {
|
||||
return AGGREGATES.COUNT_DISTINCT;
|
||||
} else if (type.match(/.*int.*/i) || type === 'LONG' || type === 'DOUBLE' || type === 'FLOAT') {
|
||||
return AGGREGATES.SUM;
|
||||
} else if (type.match(/.*bool.*/i)) {
|
||||
return AGGREGATES.MAX;
|
||||
} else if (type.match(/.*time.*/i)) {
|
||||
return AGGREGATES.COUNT;
|
||||
} else if (type.match(/unknown/i)) {
|
||||
return AGGREGATES.COUNT;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const autoGeneratedMetricRegex = /^(LONG|DOUBLE|FLOAT)?(SUM|AVG|MAX|MIN|COUNT)\([A-Z_][A-Z0-9_]*\)$/i;
|
||||
function isAutoGeneratedMetric(savedMetric) {
|
||||
return (
|
||||
autoGeneratedMetricRegex.test(savedMetric.expression) ||
|
||||
autoGeneratedMetricRegex.test(savedMetric.verbose_name)
|
||||
);
|
||||
}
|
||||
|
||||
export default class MetricsControl extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.onChange = this.onChange.bind(this);
|
||||
this.onMetricEdit = this.onMetricEdit.bind(this);
|
||||
this.checkIfAggregateInInput = this.checkIfAggregateInInput.bind(this);
|
||||
this.optionsForSelect = this.optionsForSelect.bind(this);
|
||||
this.selectFilterOption = this.selectFilterOption.bind(this);
|
||||
this.optionRenderer = VirtualizedRendererWrap(option => (
|
||||
<MetricDefinitionOption option={option} />
|
||||
), { ignoreAutogeneratedMetrics: true });
|
||||
this.valueRenderer = option => (
|
||||
<MetricDefinitionValue
|
||||
option={option}
|
||||
onMetricEdit={this.onMetricEdit}
|
||||
columns={this.props.columns}
|
||||
multi={this.props.multi}
|
||||
datasourceType={this.props.datasourceType}
|
||||
/>
|
||||
);
|
||||
this.refFunc = (ref) => {
|
||||
if (ref) {
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
this.select = ref._selectRef;
|
||||
}
|
||||
};
|
||||
this.state = {
|
||||
aggregateInInput: null,
|
||||
options: this.optionsForSelect(this.props),
|
||||
value: coerceAdhocMetrics(this.props.value),
|
||||
};
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (
|
||||
this.props.columns !== nextProps.columns ||
|
||||
this.props.savedMetrics !== nextProps.savedMetrics
|
||||
) {
|
||||
this.setState({ options: this.optionsForSelect(nextProps) });
|
||||
this.props.onChange([]);
|
||||
}
|
||||
if (this.props.value !== nextProps.value) {
|
||||
this.setState({ value: coerceAdhocMetrics(nextProps.value) });
|
||||
}
|
||||
}
|
||||
|
||||
onMetricEdit(changedMetric) {
|
||||
let newValue = this.state.value.map((value) => {
|
||||
if (value.optionName === changedMetric.optionName) {
|
||||
return changedMetric;
|
||||
}
|
||||
return value;
|
||||
});
|
||||
if (!this.props.multi) {
|
||||
newValue = newValue[0];
|
||||
}
|
||||
this.props.onChange(newValue);
|
||||
}
|
||||
|
||||
onChange(opts) {
|
||||
let transformedOpts = opts;
|
||||
if (!this.props.multi) {
|
||||
transformedOpts = [opts].filter(option => option);
|
||||
}
|
||||
let optionValues = transformedOpts.map((option) => {
|
||||
if (option.metric_name) {
|
||||
return option.metric_name;
|
||||
} else if (option.column_name) {
|
||||
const clearedAggregate = this.clearedAggregateInInput;
|
||||
this.clearedAggregateInInput = null;
|
||||
return new AdhocMetric({
|
||||
column: option,
|
||||
aggregate: clearedAggregate || getDefaultAggregateForColumn(option),
|
||||
});
|
||||
} else if (option instanceof AdhocMetric) {
|
||||
return option;
|
||||
} else if (option.aggregate_name) {
|
||||
const newValue = `${option.aggregate_name}()`;
|
||||
this.select.setInputValue(newValue);
|
||||
this.select.handleInputChange({ target: { value: newValue } });
|
||||
// we need to set a timeout here or the selectionWill be overwritten
|
||||
// by some browsers (e.g. Chrome)
|
||||
setTimeout(() => {
|
||||
this.select.input.input.selectionStart = newValue.length - 1;
|
||||
this.select.input.input.selectionEnd = newValue.length - 1;
|
||||
}, 0);
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}).filter(option => option);
|
||||
if (!this.props.multi) {
|
||||
optionValues = optionValues[0];
|
||||
}
|
||||
this.props.onChange(optionValues);
|
||||
}
|
||||
|
||||
checkIfAggregateInInput(input) {
|
||||
let nextState = { aggregateInInput: null };
|
||||
Object.keys(AGGREGATES).forEach((aggregate) => {
|
||||
if (input.toLowerCase().startsWith(aggregate.toLowerCase() + '(')) {
|
||||
nextState = { aggregateInInput: aggregate };
|
||||
}
|
||||
});
|
||||
this.clearedAggregateInInput = this.state.aggregateInInput;
|
||||
this.setState(nextState);
|
||||
}
|
||||
|
||||
optionsForSelect(props) {
|
||||
const options = [
|
||||
...props.columns,
|
||||
...Object.keys(AGGREGATES).map(aggregate => ({ aggregate_name: aggregate })),
|
||||
...props.savedMetrics,
|
||||
];
|
||||
|
||||
return options.map((option) => {
|
||||
if (option.metric_name) {
|
||||
return { ...option, optionName: option.metric_name };
|
||||
} else if (option.column_name) {
|
||||
return { ...option, optionName: '_col_' + option.column_name };
|
||||
} else if (option.aggregate_name) {
|
||||
return { ...option, optionName: '_aggregate_' + option.aggregate_name };
|
||||
}
|
||||
notify.error(`provided invalid option to MetricsControl, ${option}`);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
selectFilterOption(option, filterValue) {
|
||||
if (this.state.aggregateInInput) {
|
||||
let endIndex = filterValue.length;
|
||||
if (filterValue.endsWith(')')) {
|
||||
endIndex = filterValue.length - 1;
|
||||
}
|
||||
const valueAfterAggregate = filterValue.substring(filterValue.indexOf('(') + 1, endIndex);
|
||||
return option.column_name &&
|
||||
(option.column_name.toLowerCase().indexOf(valueAfterAggregate.toLowerCase()) >= 0);
|
||||
}
|
||||
return option.optionName &&
|
||||
(!option.metric_name || !isAutoGeneratedMetric(option)) &&
|
||||
(option.optionName.toLowerCase().indexOf(filterValue.toLowerCase()) >= 0);
|
||||
}
|
||||
|
||||
render() {
|
||||
// TODO figure out why the dropdown isnt appearing as soon as a metric is selected
|
||||
return (
|
||||
<div className="metrics-select">
|
||||
<ControlHeader {...this.props} />
|
||||
<OnPasteSelect
|
||||
multi={this.props.multi}
|
||||
name={`select-${this.props.name}`}
|
||||
placeholder={t('choose a column or aggregate function')}
|
||||
options={this.state.options}
|
||||
value={this.props.multi ? this.state.value : this.state.value[0]}
|
||||
labelKey="label"
|
||||
valueKey="optionName"
|
||||
clearable
|
||||
closeOnSelect
|
||||
onChange={this.onChange}
|
||||
optionRenderer={this.optionRenderer}
|
||||
valueRenderer={this.valueRenderer}
|
||||
onInputChange={this.checkIfAggregateInInput}
|
||||
filterOption={this.selectFilterOption}
|
||||
refFunc={this.refFunc}
|
||||
selectWrap={VirtualizedSelect}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
MetricsControl.propTypes = propTypes;
|
||||
MetricsControl.defaultProps = defaultProps;
|
|
@ -17,6 +17,7 @@ import TextControl from './TextControl';
|
|||
import TimeSeriesColumnControl from './TimeSeriesColumnControl';
|
||||
import ViewportControl from './ViewportControl';
|
||||
import VizTypeControl from './VizTypeControl';
|
||||
import MetricsControl from './MetricsControl';
|
||||
|
||||
const controlMap = {
|
||||
AnnotationLayerControl,
|
||||
|
@ -38,5 +39,6 @@ const controlMap = {
|
|||
TimeSeriesColumnControl,
|
||||
ViewportControl,
|
||||
VizTypeControl,
|
||||
MetricsControl,
|
||||
};
|
||||
export default controlMap;
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
export const AGGREGATES = {
|
||||
AVG: 'AVG',
|
||||
COUNT: 'COUNT ',
|
||||
COUNT_DISTINCT: 'COUNT_DISTINCT',
|
||||
MAX: 'MAX',
|
||||
MIN: 'MIN',
|
||||
SUM: 'SUM',
|
||||
};
|
||||
|
|
@ -39,6 +39,10 @@
|
|||
width: 100px;
|
||||
}
|
||||
|
||||
.control-panel-section .metrics-select .Select-multi-value-wrapper .Select-input > input {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.background-transparent {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
import { AGGREGATES } from '../constants';
|
||||
import columnType from './columnType';
|
||||
|
||||
export default PropTypes.shape({
|
||||
column: columnType.isRequired,
|
||||
aggregate: PropTypes.oneOf(Object.keys(AGGREGATES)).isRequired,
|
||||
label: PropTypes.string.isRequired,
|
||||
});
|
|
@ -0,0 +1,5 @@
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
export default PropTypes.shape({
|
||||
aggregate_name: PropTypes.string.isRequired,
|
||||
});
|
|
@ -0,0 +1,6 @@
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
export default PropTypes.shape({
|
||||
column_name: PropTypes.string.isRequired,
|
||||
type: PropTypes.string,
|
||||
});
|
|
@ -0,0 +1,6 @@
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
export default PropTypes.shape({
|
||||
metric_name: PropTypes.string.isRequired,
|
||||
expression: PropTypes.string.isRequired,
|
||||
});
|
|
@ -129,19 +129,18 @@ export const controls = {
|
|||
},
|
||||
|
||||
metrics: {
|
||||
type: 'SelectControl',
|
||||
type: 'MetricsControl',
|
||||
multi: true,
|
||||
label: t('Metrics'),
|
||||
validators: [v.nonEmpty],
|
||||
valueKey: 'metric_name',
|
||||
optionRenderer: m => <MetricOption metric={m} showType />,
|
||||
valueRenderer: m => <MetricOption metric={m} />,
|
||||
default: (c) => {
|
||||
const metric = mainMetric(c.options);
|
||||
return metric ? [metric] : null;
|
||||
},
|
||||
mapStateToProps: state => ({
|
||||
options: (state.datasource) ? state.datasource.metrics : [],
|
||||
columns: state.datasource ? state.datasource.columns : [],
|
||||
savedMetrics: state.datasource ? state.datasource.metrics : [],
|
||||
datasourceType: state.datasource && state.datasource.type,
|
||||
}),
|
||||
description: t('One or many metrics to display'),
|
||||
},
|
||||
|
@ -219,17 +218,16 @@ export const controls = {
|
|||
},
|
||||
|
||||
metric: {
|
||||
type: 'SelectControl',
|
||||
type: 'MetricsControl',
|
||||
multi: false,
|
||||
label: t('Metric'),
|
||||
clearable: false,
|
||||
description: t('Choose the metric'),
|
||||
validators: [v.nonEmpty],
|
||||
optionRenderer: m => <MetricOption metric={m} showType />,
|
||||
valueRenderer: m => <MetricOption metric={m} />,
|
||||
default: c => mainMetric(c.options),
|
||||
valueKey: 'metric_name',
|
||||
mapStateToProps: state => ({
|
||||
options: (state.datasource) ? state.datasource.metrics : [],
|
||||
columns: state.datasource ? state.datasource.columns : [],
|
||||
savedMetrics: state.datasource ? state.datasource.metrics : [],
|
||||
datasourceType: state.datasource && state.datasource.type,
|
||||
}),
|
||||
},
|
||||
|
||||
|
|
|
@ -139,7 +139,8 @@ export const visTypes = {
|
|||
label: t('Query'),
|
||||
expanded: true,
|
||||
controlSetRows: [
|
||||
['metrics', 'groupby'],
|
||||
['metrics'],
|
||||
['groupby'],
|
||||
['limit'],
|
||||
],
|
||||
},
|
||||
|
|
|
@ -96,7 +96,7 @@
|
|||
"react-map-gl": "^3.0.4",
|
||||
"react-redux": "^5.0.2",
|
||||
"react-resizable": "^1.3.3",
|
||||
"react-select": "1.0.0-rc.10",
|
||||
"react-select": "1.2.1",
|
||||
"react-select-fast-filter-options": "^0.2.1",
|
||||
"react-sortable-hoc": "^0.6.7",
|
||||
"react-split-pane": "^0.1.66",
|
||||
|
@ -127,7 +127,7 @@
|
|||
"clean-webpack-plugin": "^0.1.16",
|
||||
"css-loader": "^0.28.0",
|
||||
"enzyme": "^2.0.0",
|
||||
"eslint": "^3.19.0",
|
||||
"eslint": "^4.19.0",
|
||||
"eslint-config-airbnb": "^15.0.1",
|
||||
"eslint-plugin-import": "^2.2.0",
|
||||
"eslint-plugin-jsx-a11y": "^5.1.1",
|
||||
|
|
|
@ -0,0 +1,85 @@
|
|||
import { expect } from 'chai';
|
||||
import { describe, it } from 'mocha';
|
||||
|
||||
import AdhocMetric from '../../../javascripts/explore/AdhocMetric';
|
||||
import { AGGREGATES } from '../../../javascripts/explore/constants';
|
||||
|
||||
const valueColumn = { type: 'DOUBLE', column_name: 'value' };
|
||||
|
||||
describe('AdhocMetric', () => {
|
||||
it('sets label, hasCustomLabel and optionName in constructor', () => {
|
||||
const adhocMetric = new AdhocMetric({
|
||||
column: valueColumn,
|
||||
aggregate: AGGREGATES.SUM,
|
||||
});
|
||||
expect(adhocMetric.optionName.length).to.be.above(10);
|
||||
expect(adhocMetric).to.deep.equal({
|
||||
column: valueColumn,
|
||||
aggregate: AGGREGATES.SUM,
|
||||
fromFormData: false,
|
||||
label: 'SUM(value)',
|
||||
hasCustomLabel: false,
|
||||
optionName: adhocMetric.optionName,
|
||||
});
|
||||
});
|
||||
|
||||
it('can create altered duplicates', () => {
|
||||
const adhocMetric1 = new AdhocMetric({
|
||||
column: valueColumn,
|
||||
aggregate: AGGREGATES.SUM,
|
||||
});
|
||||
const adhocMetric2 = adhocMetric1.duplicateWith({ aggregate: AGGREGATES.AVG });
|
||||
|
||||
expect(adhocMetric1.column).to.equal(adhocMetric2.column);
|
||||
expect(adhocMetric1.column).to.equal(valueColumn);
|
||||
|
||||
expect(adhocMetric1.aggregate).to.equal(AGGREGATES.SUM);
|
||||
expect(adhocMetric2.aggregate).to.equal(AGGREGATES.AVG);
|
||||
});
|
||||
|
||||
it('can verify equality', () => {
|
||||
const adhocMetric1 = new AdhocMetric({
|
||||
column: valueColumn,
|
||||
aggregate: AGGREGATES.SUM,
|
||||
});
|
||||
const adhocMetric2 = adhocMetric1.duplicateWith({});
|
||||
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
expect(adhocMetric1.equals(adhocMetric2)).to.be.true;
|
||||
});
|
||||
|
||||
it('can verify inequality', () => {
|
||||
const adhocMetric1 = new AdhocMetric({
|
||||
column: valueColumn,
|
||||
aggregate: AGGREGATES.SUM,
|
||||
label: 'old label',
|
||||
hasCustomLabel: true,
|
||||
});
|
||||
const adhocMetric2 = adhocMetric1.duplicateWith({ label: 'new label' });
|
||||
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
expect(adhocMetric1.equals(adhocMetric2)).to.be.false;
|
||||
});
|
||||
|
||||
it('updates label if hasCustomLabel is false', () => {
|
||||
const adhocMetric1 = new AdhocMetric({
|
||||
column: valueColumn,
|
||||
aggregate: AGGREGATES.SUM,
|
||||
});
|
||||
const adhocMetric2 = adhocMetric1.duplicateWith({ aggregate: AGGREGATES.AVG });
|
||||
|
||||
expect(adhocMetric2.label).to.equal('AVG(value)');
|
||||
});
|
||||
|
||||
it('keeps label if hasCustomLabel is true', () => {
|
||||
const adhocMetric1 = new AdhocMetric({
|
||||
column: valueColumn,
|
||||
aggregate: AGGREGATES.SUM,
|
||||
hasCustomLabel: true,
|
||||
label: 'label1',
|
||||
});
|
||||
const adhocMetric2 = adhocMetric1.duplicateWith({ aggregate: AGGREGATES.AVG });
|
||||
|
||||
expect(adhocMetric2.label).to.equal('label1');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,48 @@
|
|||
/* eslint-disable no-unused-expressions */
|
||||
import React from 'react';
|
||||
import sinon from 'sinon';
|
||||
import { expect } from 'chai';
|
||||
import { describe, it } from 'mocha';
|
||||
import { shallow } from 'enzyme';
|
||||
import { OverlayTrigger } from 'react-bootstrap';
|
||||
|
||||
import AdhocMetric from '../../../../javascripts/explore/AdhocMetric';
|
||||
import AdhocMetricEditPopoverTitle from '../../../../javascripts/explore/components/AdhocMetricEditPopoverTitle';
|
||||
import { AGGREGATES } from '../../../../javascripts/explore/constants';
|
||||
|
||||
const columns = [
|
||||
{ type: 'VARCHAR(255)', column_name: 'source' },
|
||||
{ type: 'VARCHAR(255)', column_name: 'target' },
|
||||
{ type: 'DOUBLE', column_name: 'value' },
|
||||
];
|
||||
|
||||
const sumValueAdhocMetric = new AdhocMetric({
|
||||
column: columns[2],
|
||||
aggregate: AGGREGATES.SUM,
|
||||
});
|
||||
|
||||
function setup(overrides) {
|
||||
const onChange = sinon.spy();
|
||||
const props = {
|
||||
adhocMetric: sumValueAdhocMetric,
|
||||
onChange,
|
||||
...overrides,
|
||||
};
|
||||
const wrapper = shallow(<AdhocMetricEditPopoverTitle {...props} />);
|
||||
return { wrapper, onChange };
|
||||
}
|
||||
|
||||
describe('AdhocMetricEditPopoverTitle', () => {
|
||||
it('renders an OverlayTrigger wrapper with the title', () => {
|
||||
const { wrapper } = setup();
|
||||
expect(wrapper.find(OverlayTrigger)).to.have.lengthOf(1);
|
||||
expect(wrapper.find(OverlayTrigger).dive().text()).to.equal('My Metric\xa0');
|
||||
});
|
||||
|
||||
it('transfers to edit mode when clicked', () => {
|
||||
const { wrapper } = setup();
|
||||
expect(wrapper.state('isEditable')).to.be.false;
|
||||
wrapper.simulate('click');
|
||||
expect(wrapper.state('isEditable')).to.be.true;
|
||||
});
|
||||
});
|
|
@ -0,0 +1,90 @@
|
|||
/* eslint-disable no-unused-expressions */
|
||||
import React from 'react';
|
||||
import sinon from 'sinon';
|
||||
import { expect } from 'chai';
|
||||
import { describe, it } from 'mocha';
|
||||
import { shallow } from 'enzyme';
|
||||
import { Button, FormGroup, Popover } from 'react-bootstrap';
|
||||
|
||||
import AdhocMetric from '../../../../javascripts/explore/AdhocMetric';
|
||||
import AdhocMetricEditPopover from '../../../../javascripts/explore/components/AdhocMetricEditPopover';
|
||||
import { AGGREGATES } from '../../../../javascripts/explore/constants';
|
||||
|
||||
const columns = [
|
||||
{ type: 'VARCHAR(255)', column_name: 'source' },
|
||||
{ type: 'VARCHAR(255)', column_name: 'target' },
|
||||
{ type: 'DOUBLE', column_name: 'value' },
|
||||
];
|
||||
|
||||
const sumValueAdhocMetric = new AdhocMetric({
|
||||
column: columns[2],
|
||||
aggregate: AGGREGATES.SUM,
|
||||
});
|
||||
|
||||
function setup(overrides) {
|
||||
const onChange = sinon.spy();
|
||||
const onClose = sinon.spy();
|
||||
const props = {
|
||||
adhocMetric: sumValueAdhocMetric,
|
||||
onChange,
|
||||
onClose,
|
||||
columns,
|
||||
...overrides,
|
||||
};
|
||||
const wrapper = shallow(<AdhocMetricEditPopover {...props} />);
|
||||
return { wrapper, onChange, onClose };
|
||||
}
|
||||
|
||||
describe('AdhocMetricEditPopover', () => {
|
||||
it('renders a popover with edit metric form contents', () => {
|
||||
const { wrapper } = setup();
|
||||
expect(wrapper.find(Popover)).to.have.lengthOf(1);
|
||||
expect(wrapper.find(FormGroup)).to.have.lengthOf(2);
|
||||
expect(wrapper.find(Button)).to.have.lengthOf(2);
|
||||
});
|
||||
|
||||
it('overwrites the adhocMetric in state with onColumnChange', () => {
|
||||
const { wrapper } = setup();
|
||||
wrapper.instance().onColumnChange(columns[0]);
|
||||
expect(wrapper.state('adhocMetric')).to.deep.equal(sumValueAdhocMetric.duplicateWith({ column: columns[0] }));
|
||||
});
|
||||
|
||||
it('overwrites the adhocMetric in state with onAggregateChange', () => {
|
||||
const { wrapper } = setup();
|
||||
wrapper.instance().onAggregateChange({ aggregate: AGGREGATES.AVG });
|
||||
expect(wrapper.state('adhocMetric')).to.deep.equal(sumValueAdhocMetric.duplicateWith({ aggregate: AGGREGATES.AVG }));
|
||||
});
|
||||
|
||||
it('overwrites the adhocMetric in state with onLabelChange', () => {
|
||||
const { wrapper } = setup();
|
||||
wrapper.instance().onLabelChange({ target: { value: 'new label' } });
|
||||
expect(wrapper.state('adhocMetric').label).to.equal('new label');
|
||||
expect(wrapper.state('adhocMetric').hasCustomLabel).to.be.true;
|
||||
});
|
||||
|
||||
it('returns to default labels when the custom label is cleared', () => {
|
||||
const { wrapper } = setup();
|
||||
wrapper.instance().onLabelChange({ target: { value: 'new label' } });
|
||||
wrapper.instance().onLabelChange({ target: { value: '' } });
|
||||
expect(wrapper.state('adhocMetric').label).to.equal('SUM(value)');
|
||||
expect(wrapper.state('adhocMetric').hasCustomLabel).to.be.false;
|
||||
});
|
||||
|
||||
it('prevents saving if no column or aggregate is chosen', () => {
|
||||
const { wrapper } = setup();
|
||||
expect(wrapper.find(Button).find({ disabled: true })).to.have.lengthOf(0);
|
||||
wrapper.instance().onColumnChange(null);
|
||||
expect(wrapper.find(Button).find({ disabled: true })).to.have.lengthOf(1);
|
||||
wrapper.instance().onColumnChange({ column: columns[0] });
|
||||
expect(wrapper.find(Button).find({ disabled: true })).to.have.lengthOf(0);
|
||||
wrapper.instance().onAggregateChange(null);
|
||||
expect(wrapper.find(Button).find({ disabled: true })).to.have.lengthOf(1);
|
||||
});
|
||||
|
||||
it('highlights save if changes are present', () => {
|
||||
const { wrapper } = setup();
|
||||
expect(wrapper.find(Button).find({ bsStyle: 'primary' })).to.have.lengthOf(0);
|
||||
wrapper.instance().onColumnChange({ column: columns[1] });
|
||||
expect(wrapper.find(Button).find({ bsStyle: 'primary' })).to.have.lengthOf(1);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,42 @@
|
|||
/* eslint-disable no-unused-expressions */
|
||||
import React from 'react';
|
||||
import sinon from 'sinon';
|
||||
import { expect } from 'chai';
|
||||
import { describe, it } from 'mocha';
|
||||
import { shallow } from 'enzyme';
|
||||
import { Label, OverlayTrigger } from 'react-bootstrap';
|
||||
|
||||
import AdhocMetric from '../../../../javascripts/explore/AdhocMetric';
|
||||
import AdhocMetricOption from '../../../../javascripts/explore/components/AdhocMetricOption';
|
||||
import { AGGREGATES } from '../../../../javascripts/explore/constants';
|
||||
|
||||
const columns = [
|
||||
{ type: 'VARCHAR(255)', column_name: 'source' },
|
||||
{ type: 'VARCHAR(255)', column_name: 'target' },
|
||||
{ type: 'DOUBLE', column_name: 'value' },
|
||||
];
|
||||
|
||||
const sumValueAdhocMetric = new AdhocMetric({
|
||||
column: columns[2],
|
||||
aggregate: AGGREGATES.SUM,
|
||||
});
|
||||
|
||||
function setup(overrides) {
|
||||
const onMetricEdit = sinon.spy();
|
||||
const props = {
|
||||
adhocMetric: sumValueAdhocMetric,
|
||||
onMetricEdit,
|
||||
columns,
|
||||
...overrides,
|
||||
};
|
||||
const wrapper = shallow(<AdhocMetricOption {...props} />);
|
||||
return { wrapper, onMetricEdit };
|
||||
}
|
||||
|
||||
describe('AdhocMetricOption', () => {
|
||||
it('renders an overlay trigger wrapper for the label', () => {
|
||||
const { wrapper } = setup();
|
||||
expect(wrapper.find(OverlayTrigger)).to.have.lengthOf(1);
|
||||
expect(wrapper.find(Label)).to.have.lengthOf(1);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,14 @@
|
|||
/* eslint-disable no-unused-expressions */
|
||||
import React from 'react';
|
||||
import { expect } from 'chai';
|
||||
import { describe, it } from 'mocha';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import AggregateOption from '../../../../javascripts/explore/components/AggregateOption';
|
||||
|
||||
describe('AggregateOption', () => {
|
||||
it('renders the aggregate', () => {
|
||||
const wrapper = shallow(<AggregateOption aggregate={{ aggregate_name: 'SUM' }} />);
|
||||
expect(wrapper.text()).to.equal('SUM');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,27 @@
|
|||
/* eslint-disable no-unused-expressions */
|
||||
import React from 'react';
|
||||
import { expect } from 'chai';
|
||||
import { describe, it } from 'mocha';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import MetricDefinitionOption from '../../../../javascripts/explore/components/MetricDefinitionOption';
|
||||
import MetricOption from '../../../../javascripts/components/MetricOption';
|
||||
import ColumnOption from '../../../../javascripts/components/ColumnOption';
|
||||
import AggregateOption from '../../../../javascripts/explore/components/AggregateOption';
|
||||
|
||||
describe('MetricDefinitionOption', () => {
|
||||
it('renders a MetricOption given a saved metric', () => {
|
||||
const wrapper = shallow(<MetricDefinitionOption option={{ metric_name: 'a_saved_metric' }} />);
|
||||
expect(wrapper.find(MetricOption)).to.have.lengthOf(1);
|
||||
});
|
||||
|
||||
it('renders a ColumnOption given a column', () => {
|
||||
const wrapper = shallow(<MetricDefinitionOption option={{ column_name: 'a_column' }} />);
|
||||
expect(wrapper.find(ColumnOption)).to.have.lengthOf(1);
|
||||
});
|
||||
|
||||
it('renders an AggregateOption given an aggregate metric', () => {
|
||||
const wrapper = shallow(<MetricDefinitionOption option={{ aggregate_name: 'an_aggregate' }} />);
|
||||
expect(wrapper.find(AggregateOption)).to.have.lengthOf(1);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,30 @@
|
|||
/* eslint-disable no-unused-expressions */
|
||||
import React from 'react';
|
||||
import { expect } from 'chai';
|
||||
import { describe, it } from 'mocha';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import MetricDefinitionValue from '../../../../javascripts/explore/components/MetricDefinitionValue';
|
||||
import MetricOption from '../../../../javascripts/components/MetricOption';
|
||||
import AdhocMetricOption from '../../../../javascripts/explore/components/AdhocMetricOption';
|
||||
import AdhocMetric from '../../../../javascripts/explore/AdhocMetric';
|
||||
import { AGGREGATES } from '../../../../javascripts/explore/constants';
|
||||
|
||||
const sumValueAdhocMetric = new AdhocMetric({
|
||||
column: { type: 'DOUBLE', column_name: 'value' },
|
||||
aggregate: AGGREGATES.SUM,
|
||||
});
|
||||
|
||||
describe('MetricDefinitionValue', () => {
|
||||
it('renders a MetricOption given a saved metric', () => {
|
||||
const wrapper = shallow(<MetricDefinitionValue option={{ metric_name: 'a_saved_metric' }} />);
|
||||
expect(wrapper.find(MetricOption)).to.have.lengthOf(1);
|
||||
});
|
||||
|
||||
it('renders an AdhocMetricOption given an adhoc metric', () => {
|
||||
const wrapper = shallow((
|
||||
<MetricDefinitionValue onMetricEdit={() => {}} option={sumValueAdhocMetric} />
|
||||
));
|
||||
expect(wrapper.find(AdhocMetricOption)).to.have.lengthOf(1);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,250 @@
|
|||
/* eslint-disable no-unused-expressions */
|
||||
import React from 'react';
|
||||
import sinon from 'sinon';
|
||||
import { expect } from 'chai';
|
||||
import { describe, it } from 'mocha';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import MetricsControl from '../../../../javascripts/explore/components/controls/MetricsControl';
|
||||
import { AGGREGATES } from '../../../../javascripts/explore/constants';
|
||||
import OnPasteSelect from '../../../../javascripts/components/OnPasteSelect';
|
||||
import AdhocMetric from '../../../../javascripts/explore/AdhocMetric';
|
||||
|
||||
const defaultProps = {
|
||||
name: 'metrics',
|
||||
label: 'Metrics',
|
||||
value: undefined,
|
||||
multi: true,
|
||||
columns: [
|
||||
{ type: 'VARCHAR(255)', column_name: 'source' },
|
||||
{ type: 'VARCHAR(255)', column_name: 'target' },
|
||||
{ type: 'DOUBLE', column_name: 'value' },
|
||||
],
|
||||
savedMetrics: [
|
||||
{ metric_name: 'sum__value', expression: 'SUM(energy_usage.value)' },
|
||||
{ metric_name: 'avg__value', expression: 'AVG(energy_usage.value)' },
|
||||
],
|
||||
};
|
||||
|
||||
function setup(overrides) {
|
||||
const onChange = sinon.spy();
|
||||
const props = {
|
||||
onChange,
|
||||
...defaultProps,
|
||||
...overrides,
|
||||
};
|
||||
const wrapper = shallow(<MetricsControl {...props} />);
|
||||
return { wrapper, onChange };
|
||||
}
|
||||
|
||||
const valueColumn = { type: 'DOUBLE', column_name: 'value' };
|
||||
|
||||
const sumValueAdhocMetric = new AdhocMetric({
|
||||
column: valueColumn,
|
||||
aggregate: AGGREGATES.SUM,
|
||||
label: 'SUM(value)',
|
||||
});
|
||||
|
||||
describe('MetricsControl', () => {
|
||||
|
||||
it('renders an OnPasteSelect', () => {
|
||||
const { wrapper } = setup();
|
||||
expect(wrapper.find(OnPasteSelect)).to.have.lengthOf(1);
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
|
||||
it('unifies options for the dropdown select with aggregates', () => {
|
||||
const { wrapper } = setup();
|
||||
expect(wrapper.state('options')).to.deep.equal([
|
||||
{ optionName: '_col_source', type: 'VARCHAR(255)', column_name: 'source' },
|
||||
{ optionName: '_col_target', type: 'VARCHAR(255)', column_name: 'target' },
|
||||
{ optionName: '_col_value', type: 'DOUBLE', column_name: 'value' },
|
||||
...Object.keys(AGGREGATES).map(
|
||||
aggregate => ({ aggregate_name: aggregate, optionName: '_aggregate_' + aggregate }),
|
||||
),
|
||||
{ optionName: 'sum__value', metric_name: 'sum__value', expression: 'SUM(energy_usage.value)' },
|
||||
{ optionName: 'avg__value', metric_name: 'avg__value', expression: 'AVG(energy_usage.value)' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('coerces Adhoc Metrics from form data into instances of the AdhocMetric class and leaves saved metrics', () => {
|
||||
const { wrapper } = setup({
|
||||
value: [
|
||||
{
|
||||
column: { type: 'double', column_name: 'value' },
|
||||
aggregate: AGGREGATES.SUM,
|
||||
label: 'SUM(value)',
|
||||
optionName: 'blahblahblah',
|
||||
},
|
||||
'avg__value',
|
||||
],
|
||||
});
|
||||
|
||||
const adhocMetric = wrapper.state('value')[0];
|
||||
expect(adhocMetric instanceof AdhocMetric).to.be.true;
|
||||
expect(adhocMetric.optionName.length).to.be.above(10);
|
||||
expect(wrapper.state('value')).to.deep.equal([
|
||||
{
|
||||
column: { type: 'double', column_name: 'value' },
|
||||
aggregate: AGGREGATES.SUM,
|
||||
fromFormData: true,
|
||||
label: 'SUM(value)',
|
||||
hasCustomLabel: false,
|
||||
optionName: 'blahblahblah',
|
||||
},
|
||||
'avg__value',
|
||||
]);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('onChange', () => {
|
||||
|
||||
it('handles saved metrics being selected', () => {
|
||||
const { wrapper, onChange } = setup();
|
||||
const select = wrapper.find(OnPasteSelect);
|
||||
select.simulate('change', [{ metric_name: 'sum__value' }]);
|
||||
expect(onChange.lastCall.args).to.deep.equal([['sum__value']]);
|
||||
});
|
||||
|
||||
it('handles columns being selected', () => {
|
||||
const { wrapper, onChange } = setup();
|
||||
const select = wrapper.find(OnPasteSelect);
|
||||
select.simulate('change', [valueColumn]);
|
||||
|
||||
const adhocMetric = onChange.lastCall.args[0][0];
|
||||
expect(adhocMetric instanceof AdhocMetric).to.be.true;
|
||||
expect(onChange.lastCall.args).to.deep.equal([[{
|
||||
column: valueColumn,
|
||||
aggregate: AGGREGATES.SUM,
|
||||
label: 'SUM(value)',
|
||||
fromFormData: false,
|
||||
hasCustomLabel: false,
|
||||
optionName: adhocMetric.optionName,
|
||||
}]]);
|
||||
});
|
||||
|
||||
it('handles aggregates being selected', () => {
|
||||
const { wrapper, onChange } = setup();
|
||||
const select = wrapper.find(OnPasteSelect);
|
||||
|
||||
// mock out the Select ref
|
||||
const setInputSpy = sinon.spy();
|
||||
const handleInputSpy = sinon.spy();
|
||||
wrapper.instance().select = {
|
||||
setInputValue: setInputSpy,
|
||||
handleInputChange: handleInputSpy,
|
||||
input: { input: {} },
|
||||
};
|
||||
|
||||
select.simulate('change', [{ aggregate_name: 'SUM', optionName: 'SUM' }]);
|
||||
|
||||
expect(setInputSpy.calledWith('SUM()')).to.be.true;
|
||||
expect(handleInputSpy.calledWith({ target: { value: 'SUM()' } })).to.be.true;
|
||||
expect(onChange.lastCall.args).to.deep.equal([[]]);
|
||||
});
|
||||
|
||||
it('preserves existing selected AdhocMetrics', () => {
|
||||
const { wrapper, onChange } = setup();
|
||||
const select = wrapper.find(OnPasteSelect);
|
||||
select.simulate('change', [{ metric_name: 'sum__value' }, sumValueAdhocMetric]);
|
||||
expect(onChange.lastCall.args).to.deep.equal([['sum__value', sumValueAdhocMetric]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onMetricEdit', () => {
|
||||
it('accepts an edited metric from an AdhocMetricEditPopover', () => {
|
||||
const { wrapper, onChange } = setup({
|
||||
value: [sumValueAdhocMetric],
|
||||
});
|
||||
|
||||
const editedMetric = sumValueAdhocMetric.duplicateWith({ aggregate: AGGREGATES.AVG });
|
||||
wrapper.instance().onMetricEdit(editedMetric);
|
||||
|
||||
expect(onChange.lastCall.args).to.deep.equal([[
|
||||
editedMetric,
|
||||
]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkIfAggregateInInput', () => {
|
||||
it('handles an aggregate in the input', () => {
|
||||
const { wrapper } = setup();
|
||||
|
||||
expect(wrapper.state('aggregateInInput')).to.be.null;
|
||||
wrapper.instance().checkIfAggregateInInput('AVG(');
|
||||
expect(wrapper.state('aggregateInInput')).to.equal(AGGREGATES.AVG);
|
||||
});
|
||||
|
||||
it('handles no aggregate in the input', () => {
|
||||
const { wrapper } = setup();
|
||||
|
||||
expect(wrapper.state('aggregateInInput')).to.be.null;
|
||||
wrapper.instance().checkIfAggregateInInput('colu');
|
||||
expect(wrapper.state('aggregateInInput')).to.be.null;
|
||||
});
|
||||
});
|
||||
|
||||
describe('option filter', () => {
|
||||
it('includes user defined metrics', () => {
|
||||
const { wrapper } = setup();
|
||||
|
||||
expect(!!wrapper.instance().selectFilterOption(
|
||||
{
|
||||
metric_name: 'a_metric',
|
||||
optionName: 'a_metric',
|
||||
expression: 'SUM(FANCY(metric))',
|
||||
},
|
||||
'a',
|
||||
)).to.be.true;
|
||||
});
|
||||
|
||||
it('includes columns and aggregates', () => {
|
||||
const { wrapper } = setup();
|
||||
|
||||
expect(!!wrapper.instance().selectFilterOption(
|
||||
{ type: 'VARCHAR(255)', column_name: 'source', optionName: '_col_source' },
|
||||
'Sou',
|
||||
)).to.be.true;
|
||||
|
||||
expect(!!wrapper.instance().selectFilterOption(
|
||||
{ aggregate_name: 'AVG', optionName: '_aggregate_AVG' },
|
||||
'av',
|
||||
)).to.be.true;
|
||||
});
|
||||
|
||||
it('excludes auto generated metrics', () => {
|
||||
const { wrapper } = setup();
|
||||
|
||||
expect(!!wrapper.instance().selectFilterOption(
|
||||
{
|
||||
metric_name: 'sum__value',
|
||||
optionName: 'sum__value',
|
||||
expression: 'SUM(value)',
|
||||
},
|
||||
'sum',
|
||||
)).to.be.false;
|
||||
});
|
||||
|
||||
it('filters out metrics if the input begins with an aggregate', () => {
|
||||
const { wrapper } = setup();
|
||||
wrapper.setState({ aggregateInInput: true });
|
||||
|
||||
expect(!!wrapper.instance().selectFilterOption(
|
||||
{ metric_name: 'metric', expression: 'SUM(FANCY(metric))' },
|
||||
'SUM(',
|
||||
)).to.be.false;
|
||||
});
|
||||
|
||||
it('includes columns if the input begins with an aggregate', () => {
|
||||
const { wrapper } = setup();
|
||||
wrapper.setState({ aggregateInInput: true });
|
||||
|
||||
expect(!!wrapper.instance().selectFilterOption(
|
||||
{ type: 'DOUBLE', column_name: 'value' },
|
||||
'SUM(',
|
||||
)).to.be.true;
|
||||
});
|
||||
});
|
||||
});
|
|
@ -449,3 +449,9 @@ g.annotation-container {
|
|||
color: @brand-primary;
|
||||
border-color: @brand-primary;
|
||||
}
|
||||
|
||||
.metric-edit-popover-label-input {
|
||||
border-radius: 4px;
|
||||
height: 30px;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
|
|
@ -897,13 +897,16 @@ class DruidDatasource(Model, BaseDatasource):
|
|||
def metrics_and_post_aggs(metrics, metrics_dict):
|
||||
# Separate metrics into those that are aggregations
|
||||
# and those that are post aggregations
|
||||
agg_names = set()
|
||||
saved_agg_names = set()
|
||||
adhoc_agg_configs = []
|
||||
postagg_names = []
|
||||
for metric_name in metrics:
|
||||
if metrics_dict[metric_name].metric_type != 'postagg':
|
||||
agg_names.add(metric_name)
|
||||
for metric in metrics:
|
||||
if utils.is_adhoc_metric(metric):
|
||||
adhoc_agg_configs.append(metric)
|
||||
elif metrics_dict[metric].metric_type != 'postagg':
|
||||
saved_agg_names.add(metric)
|
||||
else:
|
||||
postagg_names.append(metric_name)
|
||||
postagg_names.append(metric)
|
||||
# Create the post aggregations, maintain order since postaggs
|
||||
# may depend on previous ones
|
||||
post_aggs = OrderedDict()
|
||||
|
@ -912,8 +915,8 @@ class DruidDatasource(Model, BaseDatasource):
|
|||
postagg = metrics_dict[postagg_name]
|
||||
visited_postaggs.add(postagg_name)
|
||||
DruidDatasource.resolve_postagg(
|
||||
postagg, post_aggs, agg_names, visited_postaggs, metrics_dict)
|
||||
return list(agg_names), post_aggs
|
||||
postagg, post_aggs, saved_agg_names, visited_postaggs, metrics_dict)
|
||||
return list(saved_agg_names), adhoc_agg_configs, post_aggs
|
||||
|
||||
def values_for_column(self,
|
||||
column_name,
|
||||
|
@ -968,11 +971,29 @@ class DruidDatasource(Model, BaseDatasource):
|
|||
ret = Filter(type='and', fields=[ff, dim_filter])
|
||||
return ret
|
||||
|
||||
def get_aggregations(self, all_metrics):
|
||||
@staticmethod
|
||||
def druid_type_from_adhoc_metric(adhoc_metric):
|
||||
column_type = adhoc_metric.get('column').get('type').lower()
|
||||
aggregate = adhoc_metric.get('aggregate').lower()
|
||||
if (aggregate == 'count'):
|
||||
return 'count'
|
||||
if (aggregate == 'count_distinct'):
|
||||
return 'cardinality'
|
||||
else:
|
||||
return column_type + aggregate.capitalize()
|
||||
|
||||
def get_aggregations(self, saved_metrics, adhoc_metrics=[]):
|
||||
aggregations = OrderedDict()
|
||||
for m in self.metrics:
|
||||
if m.metric_name in all_metrics:
|
||||
if m.metric_name in saved_metrics:
|
||||
aggregations[m.metric_name] = m.json_obj
|
||||
for adhoc_metric in adhoc_metrics:
|
||||
aggregations[adhoc_metric['label']] = {
|
||||
'fieldName': adhoc_metric['column']['column_name'],
|
||||
'fieldNames': [adhoc_metric['column']['column_name']],
|
||||
'type': self.druid_type_from_adhoc_metric(adhoc_metric),
|
||||
'name': adhoc_metric['label'],
|
||||
}
|
||||
return aggregations
|
||||
|
||||
def check_restricted_metrics(self, aggregations):
|
||||
|
@ -1066,11 +1087,11 @@ class DruidDatasource(Model, BaseDatasource):
|
|||
metrics_dict = {m.metric_name: m for m in self.metrics}
|
||||
columns_dict = {c.column_name: c for c in self.columns}
|
||||
|
||||
all_metrics, post_aggs = DruidDatasource.metrics_and_post_aggs(
|
||||
saved_metrics, adhoc_metrics, post_aggs = DruidDatasource.metrics_and_post_aggs(
|
||||
metrics,
|
||||
metrics_dict)
|
||||
|
||||
aggregations = self.get_aggregations(all_metrics)
|
||||
aggregations = self.get_aggregations(saved_metrics, adhoc_metrics)
|
||||
self.check_restricted_metrics(aggregations)
|
||||
|
||||
# the dimensions list with dimensionSpecs expanded
|
||||
|
@ -1246,6 +1267,7 @@ class DruidDatasource(Model, BaseDatasource):
|
|||
cols += query_obj.get('columns') or []
|
||||
cols += query_obj.get('metrics') or []
|
||||
|
||||
cols = utils.get_metric_names(cols)
|
||||
cols = [col for col in cols if col in df.columns]
|
||||
df = df[cols]
|
||||
|
||||
|
|
|
@ -278,6 +278,15 @@ class SqlaTable(Model, BaseDatasource):
|
|||
export_parent = 'database'
|
||||
export_children = ['metrics', 'columns']
|
||||
|
||||
sqla_aggregations = {
|
||||
'COUNT_DISTINCT': lambda column_name: sa.func.COUNT(sa.distinct(column_name)),
|
||||
'COUNT': sa.func.COUNT,
|
||||
'SUM': sa.func.SUM,
|
||||
'AVG': sa.func.AVG,
|
||||
'MIN': sa.func.MIN,
|
||||
'MAX': sa.func.MAX,
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return self.name
|
||||
|
||||
|
@ -436,6 +445,12 @@ class SqlaTable(Model, BaseDatasource):
|
|||
return TextAsFrom(sa.text(from_sql), []).alias('expr_qry')
|
||||
return self.get_sqla_table()
|
||||
|
||||
def adhoc_metric_to_sa(self, metric):
|
||||
column_name = metric.get('column').get('column_name')
|
||||
sa_metric = self.sqla_aggregations[metric.get('aggregate')](column(column_name))
|
||||
sa_metric = sa_metric.label(metric.get('label'))
|
||||
return sa_metric
|
||||
|
||||
def get_sqla_query( # sqla
|
||||
self,
|
||||
groupby, metrics,
|
||||
|
@ -484,10 +499,14 @@ class SqlaTable(Model, BaseDatasource):
|
|||
'and is required by this type of chart'))
|
||||
if not groupby and not metrics and not columns:
|
||||
raise Exception(_('Empty query?'))
|
||||
metrics_exprs = []
|
||||
for m in metrics:
|
||||
if m not in metrics_dict:
|
||||
if utils.is_adhoc_metric(m):
|
||||
metrics_exprs.append(self.adhoc_metric_to_sa(m))
|
||||
elif m in metrics_dict:
|
||||
metrics_exprs.append(metrics_dict.get(m).sqla_col)
|
||||
else:
|
||||
raise Exception(_("Metric '{}' is not valid".format(m)))
|
||||
metrics_exprs = [metrics_dict.get(m).sqla_col for m in metrics]
|
||||
if metrics_exprs:
|
||||
main_metric_expr = metrics_exprs[0]
|
||||
else:
|
||||
|
|
|
@ -832,8 +832,7 @@ def get_or_create_main_db():
|
|||
dbobj = (
|
||||
db.session.query(models.Database)
|
||||
.filter_by(database_name='main')
|
||||
.first()
|
||||
)
|
||||
.first())
|
||||
if not dbobj:
|
||||
dbobj = models.Database(database_name='main')
|
||||
dbobj.set_sqlalchemy_uri(conf.get('SQLALCHEMY_DATABASE_URI'))
|
||||
|
@ -842,3 +841,14 @@ def get_or_create_main_db():
|
|||
db.session.add(dbobj)
|
||||
db.session.commit()
|
||||
return dbobj
|
||||
|
||||
|
||||
def is_adhoc_metric(metric):
|
||||
return (isinstance(metric, dict) and
|
||||
metric['column'] and
|
||||
metric['aggregate'] and
|
||||
metric['label'])
|
||||
|
||||
|
||||
def get_metric_names(metrics):
|
||||
return [metric['label'] if is_adhoc_metric(metric) else metric for metric in metrics]
|
||||
|
|
|
@ -64,7 +64,14 @@ class BaseViz(object):
|
|||
self.query = ''
|
||||
self.token = self.form_data.get(
|
||||
'token', 'token_' + uuid.uuid4().hex[:8])
|
||||
self.metrics = self.form_data.get('metrics') or []
|
||||
metrics = self.form_data.get('metrics') or []
|
||||
self.metrics = []
|
||||
for metric in metrics:
|
||||
if isinstance(metric, dict):
|
||||
self.metrics.append(metric['label'])
|
||||
else:
|
||||
self.metrics.append(metric)
|
||||
|
||||
self.groupby = self.form_data.get('groupby') or []
|
||||
self.time_shift = timedelta()
|
||||
|
||||
|
@ -1058,12 +1065,12 @@ class NVD3TimeSeriesViz(NVD3Viz):
|
|||
df = df.pivot_table(
|
||||
index=DTTM_ALIAS,
|
||||
columns=fd.get('groupby'),
|
||||
values=fd.get('metrics'))
|
||||
values=utils.get_metric_names(fd.get('metrics')))
|
||||
else:
|
||||
df = df.pivot_table(
|
||||
index=DTTM_ALIAS,
|
||||
columns=fd.get('groupby'),
|
||||
values=fd.get('metrics'),
|
||||
values=utils.get_metric_names(fd.get('metrics')),
|
||||
fill_value=0,
|
||||
aggfunc=sum)
|
||||
|
||||
|
|
|
@ -180,6 +180,51 @@ class DruidFuncTestCase(unittest.TestCase):
|
|||
self.assertIn('post_aggregations', called_args)
|
||||
# restore functions
|
||||
|
||||
def test_run_query_with_adhoc_metric(self):
|
||||
client = Mock()
|
||||
from_dttm = Mock()
|
||||
to_dttm = Mock()
|
||||
from_dttm.replace = Mock(return_value=from_dttm)
|
||||
to_dttm.replace = Mock(return_value=to_dttm)
|
||||
from_dttm.isoformat = Mock(return_value='from')
|
||||
to_dttm.isoformat = Mock(return_value='to')
|
||||
timezone = 'timezone'
|
||||
from_dttm.tzname = Mock(return_value=timezone)
|
||||
ds = DruidDatasource(datasource_name='datasource')
|
||||
metric1 = DruidMetric(metric_name='metric1')
|
||||
metric2 = DruidMetric(metric_name='metric2')
|
||||
ds.metrics = [metric1, metric2]
|
||||
col1 = DruidColumn(column_name='col1')
|
||||
col2 = DruidColumn(column_name='col2')
|
||||
ds.columns = [col1, col2]
|
||||
all_metrics = []
|
||||
post_aggs = ['some_agg']
|
||||
ds._metrics_and_post_aggs = Mock(return_value=(all_metrics, post_aggs))
|
||||
groupby = []
|
||||
metrics = [{
|
||||
'column': {'type': 'DOUBLE', 'column_name': 'col1'},
|
||||
'aggregate': 'SUM',
|
||||
'label': 'My Adhoc Metric',
|
||||
}]
|
||||
|
||||
ds.get_having_filters = Mock(return_value=[])
|
||||
client.query_builder = Mock()
|
||||
client.query_builder.last_query = Mock()
|
||||
client.query_builder.last_query.query_dict = {'mock': 0}
|
||||
# no groupby calls client.timeseries
|
||||
ds.run_query(
|
||||
groupby, metrics, None, from_dttm,
|
||||
to_dttm, client=client, filter=[], row_limit=100,
|
||||
)
|
||||
self.assertEqual(0, len(client.topn.call_args_list))
|
||||
self.assertEqual(0, len(client.groupby.call_args_list))
|
||||
self.assertEqual(1, len(client.timeseries.call_args_list))
|
||||
# check that there is no dimensions entry
|
||||
called_args = client.timeseries.call_args_list[0][1]
|
||||
self.assertNotIn('dimensions', called_args)
|
||||
self.assertIn('post_aggregations', called_args)
|
||||
# restore functions
|
||||
|
||||
def test_run_query_single_groupby(self):
|
||||
client = Mock()
|
||||
from_dttm = Mock()
|
||||
|
@ -467,7 +512,7 @@ class DruidFuncTestCase(unittest.TestCase):
|
|||
depends_on('I', ['H', 'K'])
|
||||
depends_on('J', 'K')
|
||||
depends_on('K', ['m8', 'm9'])
|
||||
all_metrics, postaggs = DruidDatasource.metrics_and_post_aggs(
|
||||
all_metrics, saved_metrics, postaggs = DruidDatasource.metrics_and_post_aggs(
|
||||
metrics, metrics_dict)
|
||||
expected_metrics = set(all_metrics)
|
||||
self.assertEqual(9, len(all_metrics))
|
||||
|
@ -541,25 +586,80 @@ class DruidFuncTestCase(unittest.TestCase):
|
|||
),
|
||||
}
|
||||
|
||||
adhoc_metric = {
|
||||
'column': {'type': 'DOUBLE', 'column_name': 'value'},
|
||||
'aggregate': 'SUM',
|
||||
'label': 'My Adhoc Metric',
|
||||
}
|
||||
|
||||
metrics = ['some_sum']
|
||||
all_metrics, post_aggs = DruidDatasource.metrics_and_post_aggs(
|
||||
saved_metrics, adhoc_metrics, post_aggs = DruidDatasource.metrics_and_post_aggs(
|
||||
metrics, metrics_dict)
|
||||
|
||||
assert all_metrics == ['some_sum']
|
||||
assert saved_metrics == ['some_sum']
|
||||
assert adhoc_metrics == []
|
||||
assert post_aggs == {}
|
||||
|
||||
metrics = [adhoc_metric]
|
||||
saved_metrics, adhoc_metrics, post_aggs = DruidDatasource.metrics_and_post_aggs(
|
||||
metrics, metrics_dict)
|
||||
|
||||
assert saved_metrics == []
|
||||
assert adhoc_metrics == [adhoc_metric]
|
||||
assert post_aggs == {}
|
||||
|
||||
metrics = ['some_sum', adhoc_metric]
|
||||
saved_metrics, adhoc_metrics, post_aggs = DruidDatasource.metrics_and_post_aggs(
|
||||
metrics, metrics_dict)
|
||||
|
||||
assert saved_metrics == ['some_sum']
|
||||
assert adhoc_metrics == [adhoc_metric]
|
||||
assert post_aggs == {}
|
||||
|
||||
metrics = ['quantile_p95']
|
||||
all_metrics, post_aggs = DruidDatasource.metrics_and_post_aggs(
|
||||
saved_metrics, adhoc_metrics, post_aggs = DruidDatasource.metrics_and_post_aggs(
|
||||
metrics, metrics_dict)
|
||||
|
||||
result_postaggs = set(['quantile_p95'])
|
||||
assert all_metrics == ['a_histogram']
|
||||
assert saved_metrics == ['a_histogram']
|
||||
assert adhoc_metrics == []
|
||||
assert set(post_aggs.keys()) == result_postaggs
|
||||
|
||||
metrics = ['aCustomPostAgg']
|
||||
all_metrics, post_aggs = DruidDatasource.metrics_and_post_aggs(
|
||||
saved_metrics, adhoc_metrics, post_aggs = DruidDatasource.metrics_and_post_aggs(
|
||||
metrics, metrics_dict)
|
||||
|
||||
result_postaggs = set(['aCustomPostAgg'])
|
||||
assert all_metrics == ['aCustomMetric']
|
||||
assert saved_metrics == ['aCustomMetric']
|
||||
assert adhoc_metrics == []
|
||||
assert set(post_aggs.keys()) == result_postaggs
|
||||
|
||||
def test_druid_type_from_adhoc_metric(self):
|
||||
|
||||
druid_type = DruidDatasource.druid_type_from_adhoc_metric({
|
||||
'column': {'type': 'DOUBLE', 'column_name': 'value'},
|
||||
'aggregate': 'SUM',
|
||||
'label': 'My Adhoc Metric',
|
||||
})
|
||||
assert(druid_type == 'doubleSum')
|
||||
|
||||
druid_type = DruidDatasource.druid_type_from_adhoc_metric({
|
||||
'column': {'type': 'LONG', 'column_name': 'value'},
|
||||
'aggregate': 'MAX',
|
||||
'label': 'My Adhoc Metric',
|
||||
})
|
||||
assert(druid_type == 'longMax')
|
||||
|
||||
druid_type = DruidDatasource.druid_type_from_adhoc_metric({
|
||||
'column': {'type': 'VARCHAR(255)', 'column_name': 'value'},
|
||||
'aggregate': 'COUNT',
|
||||
'label': 'My Adhoc Metric',
|
||||
})
|
||||
assert(druid_type == 'count')
|
||||
|
||||
druid_type = DruidDatasource.druid_type_from_adhoc_metric({
|
||||
'column': {'type': 'VARCHAR(255)', 'column_name': 'value'},
|
||||
'aggregate': 'COUNT_DISTINCT',
|
||||
'label': 'My Adhoc Metric',
|
||||
})
|
||||
assert(druid_type == 'cardinality')
|
||||
|
|
Loading…
Reference in New Issue