blob: 81405e57c216a8a85aebfe300ebdbbe712fd06a1 [file] [log] [blame]
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/* eslint-disable camelcase */
import React from 'react';
import PropTypes from 'prop-types';
import { FormGroup } from 'react-bootstrap';
import Tabs from 'src/common/components/Tabs';
import Button from 'src/components/Button';
import { Select } from 'src/common/components/Select';
import { styled, t } from '@superset-ui/core';
import { ColumnOption, MetricOption } from '@superset-ui/chart-controls';
import FormLabel from 'src/components/FormLabel';
import { SQLEditor } from 'src/components/AsyncAceEditor';
import sqlKeywords from 'src/SqlLab/utils/sqlKeywords';
import { noOp } from 'src/utils/common';
import { AGGREGATES_OPTIONS } from 'src/explore/constants';
import columnType from 'src/explore/propTypes/columnType';
import savedMetricType from './savedMetricType';
import AdhocMetric, { EXPRESSION_TYPES } from './AdhocMetric';
const propTypes = {
adhocMetric: PropTypes.instanceOf(AdhocMetric).isRequired,
onChange: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
onResize: PropTypes.func.isRequired,
getCurrentTab: PropTypes.func,
getCurrentLabel: PropTypes.func,
columns: PropTypes.arrayOf(columnType),
savedMetricsOptions: PropTypes.arrayOf(savedMetricType),
savedMetric: savedMetricType,
datasourceType: PropTypes.string,
};
const defaultProps = {
columns: [],
getCurrentTab: noOp,
};
const ResizeIcon = styled.i`
margin-left: ${({ theme }) => theme.gridUnit * 2}px;
`;
const ColumnOptionStyle = styled.span`
.option-label {
display: inline;
}
`;
export const SAVED_TAB_KEY = 'SAVED';
const startingWidth = 320;
const startingHeight = 240;
export default class AdhocMetricEditPopover extends React.PureComponent {
// "Saved" is a default tab unless there are no saved metrics for dataset
defaultActiveTabKey =
(this.props.savedMetric.metric_name || this.props.adhocMetric.isNew) &&
Array.isArray(this.props.savedMetricsOptions) &&
this.props.savedMetricsOptions.length > 0
? SAVED_TAB_KEY
: this.props.adhocMetric.expressionType;
constructor(props) {
super(props);
this.onSave = this.onSave.bind(this);
this.onResetStateAndClose = this.onResetStateAndClose.bind(this);
this.onColumnChange = this.onColumnChange.bind(this);
this.onAggregateChange = this.onAggregateChange.bind(this);
this.onSavedMetricChange = this.onSavedMetricChange.bind(this);
this.onSqlExpressionChange = this.onSqlExpressionChange.bind(this);
this.onDragDown = this.onDragDown.bind(this);
this.onMouseMove = this.onMouseMove.bind(this);
this.onMouseUp = this.onMouseUp.bind(this);
this.onTabChange = this.onTabChange.bind(this);
this.handleAceEditorRef = this.handleAceEditorRef.bind(this);
this.refreshAceEditor = this.refreshAceEditor.bind(this);
this.state = {
adhocMetric: this.props.adhocMetric,
savedMetric: this.props.savedMetric,
width: startingWidth,
height: startingHeight,
};
document.addEventListener('mouseup', this.onMouseUp);
}
componentDidMount() {
this.props.getCurrentTab(this.defaultActiveTabKey);
}
componentDidUpdate(prevProps, prevState) {
if (
prevState.adhocMetric?.sqlExpression !==
this.state.adhocMetric?.sqlExpression ||
prevState.adhocMetric?.aggregate !== this.state.adhocMetric?.aggregate ||
prevState.adhocMetric?.column?.column_name !==
this.state.adhocMetric?.column?.column_name ||
prevState.savedMetric?.metric_name !== this.state.savedMetric?.metric_name
) {
this.props.getCurrentLabel({
savedMetricLabel:
this.state.savedMetric?.verbose_name ||
this.state.savedMetric?.metric_name,
adhocMetricLabel: this.state.adhocMetric?.getDefaultLabel(),
});
}
}
componentWillUnmount() {
document.removeEventListener('mouseup', this.onMouseUp);
document.removeEventListener('mousemove', this.onMouseMove);
}
onSave() {
const { adhocMetric, savedMetric } = this.state;
const metric = savedMetric?.metric_name ? savedMetric : adhocMetric;
const oldMetric = this.props.savedMetric?.metric_name
? this.props.savedMetric
: this.props.adhocMetric;
this.props.onChange(
{
...metric,
},
oldMetric,
);
this.props.onClose();
}
onResetStateAndClose() {
this.setState(
{
adhocMetric: this.props.adhocMetric,
savedMetric: this.props.savedMetric,
},
this.props.onClose,
);
}
onColumnChange(columnId) {
const column = this.props.columns.find(column => column.id === columnId);
this.setState(prevState => ({
adhocMetric: prevState.adhocMetric.duplicateWith({
column,
expressionType: EXPRESSION_TYPES.SIMPLE,
}),
savedMetric: undefined,
}));
}
onAggregateChange(aggregate) {
// we construct this object explicitly to overwrite the value in the case aggregate is null
this.setState(prevState => ({
adhocMetric: prevState.adhocMetric.duplicateWith({
aggregate,
expressionType: EXPRESSION_TYPES.SIMPLE,
}),
savedMetric: undefined,
}));
}
onSavedMetricChange(savedMetricId) {
const savedMetric = this.props.savedMetricsOptions.find(
metric => metric.id === savedMetricId,
);
this.setState(prevState => ({
savedMetric,
adhocMetric: prevState.adhocMetric.duplicateWith({
column: undefined,
aggregate: undefined,
sqlExpression: undefined,
expressionType: EXPRESSION_TYPES.SIMPLE,
}),
}));
}
onSqlExpressionChange(sqlExpression) {
this.setState(prevState => ({
adhocMetric: prevState.adhocMetric.duplicateWith({
sqlExpression,
expressionType: EXPRESSION_TYPES.SQL,
}),
savedMetric: undefined,
}));
}
onDragDown(e) {
this.dragStartX = e.clientX;
this.dragStartY = e.clientY;
this.dragStartWidth = this.state.width;
this.dragStartHeight = this.state.height;
document.addEventListener('mousemove', this.onMouseMove);
}
onMouseMove(e) {
this.props.onResize();
this.setState({
width: Math.max(
this.dragStartWidth + (e.clientX - this.dragStartX),
startingWidth,
),
height: Math.max(
this.dragStartHeight + (e.clientY - this.dragStartY) * 2,
startingHeight,
),
});
}
onMouseUp() {
document.removeEventListener('mousemove', this.onMouseMove);
}
onTabChange(tab) {
this.refreshAceEditor();
this.props.getCurrentTab(tab);
}
handleAceEditorRef(ref) {
if (ref) {
this.aceEditorRef = ref;
}
}
refreshAceEditor() {
setTimeout(() => {
if (this.aceEditorRef) {
this.aceEditorRef.editor.resize();
}
}, 0);
}
renderColumnOption(option) {
const column = { ...option };
if (column.metric_name && !column.verbose_name) {
column.verbose_name = column.metric_name;
}
return (
<ColumnOptionStyle>
<ColumnOption column={column} showType />
</ColumnOptionStyle>
);
}
render() {
const {
adhocMetric: propsAdhocMetric,
savedMetric: propsSavedMetric,
columns,
savedMetricsOptions,
onChange,
onClose,
onResize,
datasourceType,
...popoverProps
} = this.props;
const { adhocMetric, savedMetric } = this.state;
const keywords = sqlKeywords.concat(
columns.map(column => ({
name: column.column_name,
value: column.column_name,
score: 50,
meta: 'column',
})),
);
const columnValue =
(adhocMetric.column && adhocMetric.column.column_name) ||
adhocMetric.inferSqlExpressionColumn();
// autofocus on column if there's no value in column; otherwise autofocus on aggregate
const columnSelectProps = {
placeholder: t('%s column(s)', columns.length),
value: columnValue,
onChange: this.onColumnChange,
allowClear: true,
showSearch: true,
autoFocus: !columnValue,
filterOption: (input, option) =>
option.filterBy.toLowerCase().indexOf(input.toLowerCase()) >= 0,
};
const aggregateSelectProps = {
placeholder: t('%s aggregates(s)', AGGREGATES_OPTIONS.length),
value: adhocMetric.aggregate || adhocMetric.inferSqlExpressionAggregate(),
onChange: this.onAggregateChange,
allowClear: true,
autoFocus: !!columnValue,
showSearch: true,
};
const savedSelectProps = {
placeholder: t('%s saved metric(s)', savedMetricsOptions?.length ?? 0),
value: savedMetric?.verbose_name || savedMetric?.metric_name,
onChange: this.onSavedMetricChange,
allowClear: true,
showSearch: true,
autoFocus: true,
filterOption: (input, option) =>
option.filterBy.toLowerCase().indexOf(input.toLowerCase()) >= 0,
};
if (this.props.datasourceType === 'druid') {
aggregateSelectProps.options = aggregateSelectProps.options.filter(
aggregate => aggregate !== 'AVG',
);
}
const stateIsValid = adhocMetric.isValid() || savedMetric?.metric_name;
const hasUnsavedChanges =
!adhocMetric.equals(propsAdhocMetric) ||
(!(
typeof savedMetric?.metric_name === 'undefined' &&
typeof propsSavedMetric?.metric_name === 'undefined'
) &&
savedMetric?.metric_name !== propsSavedMetric?.metric_name);
return (
<div
id="metrics-edit-popover"
data-test="metrics-edit-popover"
{...popoverProps}
>
<Tabs
id="adhoc-metric-edit-tabs"
data-test="adhoc-metric-edit-tabs"
defaultActiveKey={this.defaultActiveTabKey}
className="adhoc-metric-edit-tabs"
style={{ height: this.state.height, width: this.state.width }}
onChange={this.onTabChange}
allowOverflow
>
<Tabs.TabPane key={SAVED_TAB_KEY} tab={t('Saved')}>
<FormGroup>
<FormLabel>
<strong>{t('Saved metric')}</strong>
</FormLabel>
<Select
{...savedSelectProps}
name="select-saved"
getPopupContainer={triggerNode => triggerNode.parentNode}
>
{Array.isArray(savedMetricsOptions) &&
savedMetricsOptions.map(savedMetric => (
<Select.Option
value={savedMetric.id}
filterBy={
savedMetric.verbose_name || savedMetric.metric_name
}
key={savedMetric.id}
>
<MetricOption metric={savedMetric} showType />
</Select.Option>
))}
</Select>
</FormGroup>
</Tabs.TabPane>
<Tabs.TabPane key={EXPRESSION_TYPES.SIMPLE} tab={t('Simple')}>
<FormGroup>
<FormLabel>
<strong>{t('column')}</strong>
</FormLabel>
<Select
{...columnSelectProps}
name="select-column"
getPopupContainer={triggerNode => triggerNode.parentNode}
>
{columns.map(column => (
<Select.Option
value={column.id}
filterBy={column.verbose_name || column.column_name}
key={column.id}
>
{this.renderColumnOption(column)}
</Select.Option>
))}
</Select>
</FormGroup>
<FormGroup>
<FormLabel>
<strong>{t('aggregate')}</strong>
</FormLabel>
<Select
{...aggregateSelectProps}
name="select-aggregate"
getPopupContainer={triggerNode => triggerNode.parentNode}
>
{AGGREGATES_OPTIONS.map(option => (
<Select.Option value={option} key={option}>
{option}
</Select.Option>
))}
</Select>
</FormGroup>
</Tabs.TabPane>
<Tabs.TabPane
key={EXPRESSION_TYPES.SQL}
tab={t('Custom SQL')}
data-test="adhoc-metric-edit-tab#custom"
>
{this.props.datasourceType !== 'druid' ? (
<FormGroup data-test="sql-editor">
<SQLEditor
showLoadingForImport
ref={this.handleAceEditorRef}
keywords={keywords}
height={`${this.state.height - 80}px`}
onChange={this.onSqlExpressionChange}
width="100%"
showGutter={false}
value={
adhocMetric.sqlExpression || adhocMetric.translateToSql()
}
editorProps={{ $blockScrolling: true }}
enableLiveAutocompletion
className="adhoc-filter-sql-editor"
wrapEnabled
/>
</FormGroup>
) : (
<div className="custom-sql-disabled-message">
Custom SQL Metrics are not available on druid datasources
</div>
)}
</Tabs.TabPane>
</Tabs>
<div>
<Button
buttonSize="small"
onClick={this.onResetStateAndClose}
data-test="AdhocMetricEdit#cancel"
cta
>
{t('Close')}
</Button>
<Button
disabled={!stateIsValid}
buttonStyle={
hasUnsavedChanges && stateIsValid ? 'primary' : 'default'
}
buttonSize="small"
data-test="AdhocMetricEdit#save"
onClick={this.onSave}
cta
>
{t('Save')}
</Button>
<ResizeIcon
role="button"
aria-label="Resize"
tabIndex={0}
onMouseDown={this.onDragDown}
className="fa fa-expand edit-popover-resize text-muted"
/>
</div>
</div>
);
}
}
AdhocMetricEditPopover.propTypes = propTypes;
AdhocMetricEditPopover.defaultProps = defaultProps;