| /** |
| * 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 { PureComponent, createRef } from 'react'; |
| import PropTypes from 'prop-types'; |
| import { |
| isDefined, |
| t, |
| styled, |
| ensureIsArray, |
| DatasourceType, |
| } from '@superset-ui/core'; |
| import Tabs from '@superset-ui/core/components/Tabs'; |
| import { |
| Button, |
| EmptyState, |
| Form, |
| FormItem, |
| Icons, |
| Select, |
| Tooltip, |
| } from '@superset-ui/core/components'; |
| import sqlKeywords from 'src/SqlLab/utils/sqlKeywords'; |
| import { noOp } from 'src/utils/common'; |
| import { |
| AGGREGATES_OPTIONS, |
| POPOVER_INITIAL_HEIGHT, |
| POPOVER_INITIAL_WIDTH, |
| } from 'src/explore/constants'; |
| import columnType from 'src/explore/components/controls/MetricControl/columnType'; |
| import savedMetricType from 'src/explore/components/controls/MetricControl/savedMetricType'; |
| import AdhocMetric, { |
| EXPRESSION_TYPES, |
| } from 'src/explore/components/controls/MetricControl/AdhocMetric'; |
| import { |
| StyledMetricOption, |
| StyledColumnOption, |
| } from 'src/explore/components/optionRenderers'; |
| import { getColumnKeywords } from 'src/explore/controlUtils/getColumnKeywords'; |
| import SQLEditorWithValidation from 'src/components/SQLEditorWithValidation'; |
| |
| const propTypes = { |
| onChange: PropTypes.func.isRequired, |
| onClose: PropTypes.func.isRequired, |
| onResize: PropTypes.func.isRequired, |
| getCurrentTab: PropTypes.func, |
| getCurrentLabel: PropTypes.func, |
| adhocMetric: PropTypes.instanceOf(AdhocMetric).isRequired, |
| columns: PropTypes.arrayOf(columnType), |
| savedMetricsOptions: PropTypes.arrayOf(savedMetricType), |
| savedMetric: savedMetricType, |
| datasource: PropTypes.object, |
| isNewMetric: PropTypes.bool, |
| isLabelModified: PropTypes.bool, |
| }; |
| |
| const defaultProps = { |
| columns: [], |
| getCurrentTab: noOp, |
| isNewMetric: false, |
| }; |
| |
| const StyledSelect = styled(Select)` |
| .metric-option { |
| & > svg { |
| min-width: ${({ theme }) => `${theme.sizeUnit * 4}px`}; |
| } |
| & > .option-label { |
| overflow: hidden; |
| text-overflow: ellipsis; |
| } |
| } |
| `; |
| |
| export const SAVED_TAB_KEY = 'SAVED'; |
| |
| export default class AdhocMetricEditPopover extends PureComponent { |
| // "Saved" is a default tab unless there are no saved metrics for dataset |
| defaultActiveTabKey = this.getDefaultTab(); |
| |
| 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.aceEditorRef = createRef(); |
| this.refreshAceEditor = this.refreshAceEditor.bind(this); |
| this.getDefaultTab = this.getDefaultTab.bind(this); |
| |
| this.state = { |
| adhocMetric: this.props.adhocMetric, |
| savedMetric: this.props.savedMetric, |
| width: POPOVER_INITIAL_WIDTH, |
| height: POPOVER_INITIAL_HEIGHT, |
| }; |
| 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); |
| } |
| |
| getDefaultTab() { |
| const { adhocMetric, savedMetric, savedMetricsOptions, isNewMetric } = |
| this.props; |
| if (isDefined(adhocMetric.column) || isDefined(adhocMetric.sqlExpression)) { |
| return adhocMetric.expressionType; |
| } |
| if ( |
| (isNewMetric || savedMetric.metric_name) && |
| Array.isArray(savedMetricsOptions) && |
| savedMetricsOptions.length > 0 |
| ) { |
| return SAVED_TAB_KEY; |
| } |
| return adhocMetric.expressionType; |
| } |
| |
| 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(columnName) { |
| const column = this.props.columns.find( |
| column => column.column_name === columnName, |
| ); |
| 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(savedMetricName) { |
| const savedMetric = this.props.savedMetricsOptions.find( |
| metric => metric.metric_name === savedMetricName, |
| ); |
| 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), |
| POPOVER_INITIAL_WIDTH, |
| ), |
| height: Math.max( |
| this.dragStartHeight + (e.clientY - this.dragStartY), |
| POPOVER_INITIAL_HEIGHT, |
| ), |
| }); |
| } |
| |
| onMouseUp() { |
| document.removeEventListener('mousemove', this.onMouseMove); |
| } |
| |
| onTabChange(tab) { |
| this.refreshAceEditor(); |
| this.props.getCurrentTab(tab); |
| } |
| |
| refreshAceEditor() { |
| setTimeout(() => { |
| if (this.aceEditorRef.current) { |
| this.aceEditorRef.current.editor?.resize?.(); |
| } |
| }, 0); |
| } |
| |
| renderColumnOption(option) { |
| const column = { ...option }; |
| if (column.metric_name && !column.verbose_name) { |
| column.verbose_name = column.metric_name; |
| } |
| return <StyledColumnOption column={column} showType />; |
| } |
| |
| renderMetricOption(savedMetric) { |
| return <StyledMetricOption metric={savedMetric} showType />; |
| } |
| |
| render() { |
| const { |
| adhocMetric: propsAdhocMetric, |
| savedMetric: propsSavedMetric, |
| columns, |
| savedMetricsOptions, |
| onChange, |
| onClose, |
| onResize, |
| datasource, |
| isNewMetric, |
| isLabelModified, |
| ...popoverProps |
| } = this.props; |
| const { adhocMetric, savedMetric } = this.state; |
| const keywords = sqlKeywords.concat(getColumnKeywords(columns)); |
| |
| 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 = { |
| ariaLabel: t('Select column'), |
| placeholder: t('%s column(s)', columns.length), |
| value: columnValue, |
| onChange: this.onColumnChange, |
| allowClear: true, |
| autoFocus: !columnValue, |
| }; |
| |
| const aggregateSelectProps = { |
| ariaLabel: t('Select aggregate options'), |
| placeholder: t('%s aggregates(s)', AGGREGATES_OPTIONS.length), |
| value: adhocMetric.aggregate || adhocMetric.inferSqlExpressionAggregate(), |
| onChange: this.onAggregateChange, |
| allowClear: true, |
| autoFocus: !!columnValue, |
| }; |
| |
| const savedSelectProps = { |
| ariaLabel: t('Select saved metrics'), |
| placeholder: t('%s saved metric(s)', savedMetricsOptions?.length ?? 0), |
| value: savedMetric?.metric_name, |
| onChange: this.onSavedMetricChange, |
| allowClear: true, |
| autoFocus: true, |
| }; |
| |
| const stateIsValid = adhocMetric.isValid() || savedMetric?.metric_name; |
| const hasUnsavedChanges = |
| isLabelModified || |
| isNewMetric || |
| !adhocMetric.equals(propsAdhocMetric) || |
| (!( |
| typeof savedMetric?.metric_name === 'undefined' && |
| typeof propsSavedMetric?.metric_name === 'undefined' |
| ) && |
| savedMetric?.metric_name !== propsSavedMetric?.metric_name); |
| |
| let extra = {}; |
| if (datasource?.extra) { |
| try { |
| extra = JSON.parse(datasource.extra); |
| } catch {} // eslint-disable-line no-empty |
| } |
| |
| return ( |
| <Form |
| layout="vertical" |
| 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 |
| items={[ |
| { |
| key: SAVED_TAB_KEY, |
| label: t('Saved'), |
| children: |
| ensureIsArray(savedMetricsOptions).length > 0 ? ( |
| <FormItem label={t('Saved metric')}> |
| <StyledSelect |
| options={ensureIsArray(savedMetricsOptions).map( |
| savedMetric => ({ |
| value: savedMetric.metric_name, |
| label: this.renderMetricOption(savedMetric), |
| key: savedMetric.id, |
| }), |
| )} |
| {...savedSelectProps} |
| /> |
| </FormItem> |
| ) : datasource.type === DatasourceType.Table ? ( |
| <EmptyState |
| image="empty.svg" |
| size="small" |
| title={t('No saved metrics found')} |
| description={t( |
| 'Add metrics to dataset in "Edit datasource" modal', |
| )} |
| /> |
| ) : ( |
| <EmptyState |
| image="empty.svg" |
| size="small" |
| title={t('No saved metrics found')} |
| description={ |
| <> |
| <span |
| tabIndex={0} |
| role="button" |
| onClick={() => { |
| this.props.handleDatasetModal(true); |
| this.props.onClose(); |
| }} |
| > |
| {t('Create a dataset')} |
| </span> |
| {t(' to add metrics')} |
| </> |
| } |
| /> |
| ), |
| }, |
| { |
| key: EXPRESSION_TYPES.SIMPLE, |
| label: extra.disallow_adhoc_metrics ? ( |
| <Tooltip |
| title={t( |
| 'Simple ad-hoc metrics are not enabled for this dataset', |
| )} |
| > |
| {t('Simple')} |
| </Tooltip> |
| ) : ( |
| t('Simple') |
| ), |
| disabled: extra.disallow_adhoc_metrics, |
| children: ( |
| <> |
| <FormItem label={t('column')}> |
| <Select |
| options={columns.map(column => ({ |
| value: column.column_name, |
| key: column.id, |
| label: this.renderColumnOption(column), |
| }))} |
| {...columnSelectProps} |
| /> |
| </FormItem> |
| <FormItem label={t('aggregate')}> |
| <Select |
| options={AGGREGATES_OPTIONS.map(option => ({ |
| value: option, |
| label: option, |
| key: option, |
| }))} |
| {...aggregateSelectProps} |
| /> |
| </FormItem> |
| </> |
| ), |
| }, |
| { |
| key: EXPRESSION_TYPES.SQL, |
| label: extra.disallow_adhoc_metrics ? ( |
| <Tooltip |
| title={t( |
| 'Custom SQL ad-hoc metrics are not enabled for this dataset', |
| )} |
| > |
| {t('Custom SQL')} |
| </Tooltip> |
| ) : ( |
| t('Custom SQL') |
| ), |
| disabled: extra.disallow_adhoc_metrics, |
| children: ( |
| <SQLEditorWithValidation |
| data-test="sql-editor" |
| showLoadingForImport |
| ref={this.aceEditorRef} |
| keywords={keywords} |
| height={`${this.state.height - 120}px`} |
| onChange={this.onSqlExpressionChange} |
| width="100%" |
| showGutter={false} |
| value={ |
| adhocMetric.sqlExpression || |
| adhocMetric.translateToSql({ transformCountDistinct: true }) |
| } |
| editorProps={{ $blockScrolling: true }} |
| enableLiveAutocompletion |
| className="filter-sql-editor" |
| wrapEnabled |
| showValidation |
| expressionType="metric" |
| datasourceId={datasource?.id} |
| datasourceType={datasource?.type} |
| /> |
| ), |
| }, |
| ]} |
| /> |
| <div> |
| <Button |
| buttonSize="small" |
| buttonStyle="secondary" |
| onClick={this.onResetStateAndClose} |
| data-test="AdhocMetricEdit#cancel" |
| cta |
| > |
| {t('Close')} |
| </Button> |
| <Button |
| disabled={!stateIsValid || !hasUnsavedChanges} |
| buttonStyle="primary" |
| buttonSize="small" |
| data-test="AdhocMetricEdit#save" |
| onClick={this.onSave} |
| cta |
| > |
| {t('Save')} |
| </Button> |
| <Icons.ArrowsAltOutlined |
| role="button" |
| aria-label="Resize" |
| tabIndex={0} |
| onMouseDown={this.onDragDown} |
| className="edit-popover-resize" |
| /> |
| </div> |
| </Form> |
| ); |
| } |
| } |
| AdhocMetricEditPopover.propTypes = propTypes; |
| AdhocMetricEditPopover.defaultProps = defaultProps; |