| /** |
| * 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. |
| */ |
| import React from 'react'; |
| import PropTypes from 'prop-types'; |
| import { t, withTheme } from '@superset-ui/core'; |
| import { isEqual } from 'lodash'; |
| import ControlHeader from 'src/explore/components/ControlHeader'; |
| import columnType from 'src/explore/propTypes/columnType'; |
| import { |
| AGGREGATES_OPTIONS, |
| sqlaAutoGeneratedMetricNameRegex, |
| druidAutoGeneratedMetricRegex, |
| } from 'src/explore/constants'; |
| import Icon from 'src/components/Icon'; |
| import { |
| AddIconButton, |
| AddControlLabel, |
| HeaderContainer, |
| LabelsContainer, |
| } from 'src/explore/components/OptionControls'; |
| import MetricDefinitionOption from './MetricDefinitionOption'; |
| import MetricDefinitionValue from './MetricDefinitionValue'; |
| import AdhocMetric from './AdhocMetric'; |
| import savedMetricType from './savedMetricType'; |
| import adhocMetricType from './adhocMetricType'; |
| import AdhocMetricPopoverTrigger from './AdhocMetricPopoverTrigger'; |
| |
| 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), |
| isLoading: PropTypes.bool, |
| multi: PropTypes.bool, |
| clearable: PropTypes.bool, |
| datasourceType: PropTypes.string, |
| }; |
| |
| const defaultProps = { |
| onChange: () => {}, |
| clearable: true, |
| savedMetrics: [], |
| columns: [], |
| }; |
| |
| function getOptionsForSavedMetrics( |
| savedMetrics, |
| currentMetricValues, |
| currentMetric, |
| ) { |
| return ( |
| savedMetrics?.filter(savedMetric => |
| Array.isArray(currentMetricValues) |
| ? !currentMetricValues.includes(savedMetric.metric_name) || |
| savedMetric.metric_name === currentMetric |
| : savedMetric, |
| ) ?? [] |
| ); |
| } |
| |
| function isDictionaryForAdhocMetric(value) { |
| return value && !(value instanceof AdhocMetric) && value.expressionType; |
| } |
| |
| function columnsContainAllMetrics(value, nextProps) { |
| const columnNames = new Set( |
| [...(nextProps.columns || []), ...(nextProps.savedMetrics || [])] |
| // eslint-disable-next-line camelcase |
| .map(({ column_name, metric_name }) => column_name || metric_name), |
| ); |
| |
| return ( |
| (Array.isArray(value) ? value : [value]) |
| .filter(metric => metric) |
| // find column names |
| .map(metric => |
| metric.column |
| ? metric.column.column_name |
| : metric.column_name || metric, |
| ) |
| .filter(name => name && typeof name === 'string') |
| .every(name => columnNames.has(name)) |
| ); |
| } |
| |
| // 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; |
| }); |
| } |
| |
| class MetricsControl extends React.PureComponent { |
| constructor(props) { |
| super(props); |
| this.onChange = this.onChange.bind(this); |
| this.onMetricEdit = this.onMetricEdit.bind(this); |
| this.onNewMetric = this.onNewMetric.bind(this); |
| this.onRemoveMetric = this.onRemoveMetric.bind(this); |
| this.moveLabel = this.moveLabel.bind(this); |
| this.checkIfAggregateInInput = this.checkIfAggregateInInput.bind(this); |
| this.optionsForSelect = this.optionsForSelect.bind(this); |
| this.selectFilterOption = this.selectFilterOption.bind(this); |
| this.isAutoGeneratedMetric = this.isAutoGeneratedMetric.bind(this); |
| this.optionRenderer = option => <MetricDefinitionOption option={option} />; |
| this.valueRenderer = (option, index) => ( |
| <MetricDefinitionValue |
| key={index} |
| index={index} |
| option={option} |
| onMetricEdit={this.onMetricEdit} |
| onRemoveMetric={() => this.onRemoveMetric(index)} |
| columns={this.props.columns} |
| datasource={this.props.datasource} |
| savedMetrics={this.props.savedMetrics} |
| savedMetricsOptions={getOptionsForSavedMetrics( |
| this.props.savedMetrics, |
| this.props.value, |
| this.props.value?.[index], |
| )} |
| datasourceType={this.props.datasourceType} |
| onMoveLabel={this.moveLabel} |
| onDropLabel={() => this.props.onChange(this.state.value)} |
| /> |
| ); |
| this.select = null; |
| this.selectRef = ref => { |
| if (ref) { |
| this.select = ref.select; |
| } else { |
| this.select = null; |
| } |
| }; |
| this.state = { |
| aggregateInInput: null, |
| options: this.optionsForSelect(this.props), |
| value: coerceAdhocMetrics(this.props.value), |
| }; |
| } |
| |
| UNSAFE_componentWillReceiveProps(nextProps) { |
| const { value } = this.props; |
| if ( |
| !isEqual(this.props.columns, nextProps.columns) || |
| !isEqual(this.props.savedMetrics, nextProps.savedMetrics) |
| ) { |
| this.setState({ options: this.optionsForSelect(nextProps) }); |
| |
| // Remove all metrics if selected value no longer a valid column |
| // in the dataset. Must use `nextProps` here because Redux reducers may |
| // have already updated the value for this control. |
| if (!columnsContainAllMetrics(nextProps.value, nextProps)) { |
| this.props.onChange([]); |
| } |
| } |
| if (value !== nextProps.value) { |
| this.setState({ value: coerceAdhocMetrics(nextProps.value) }); |
| } |
| } |
| |
| onNewMetric(newMetric) { |
| this.setState( |
| prevState => ({ |
| ...prevState, |
| value: [...prevState.value, newMetric], |
| }), |
| () => { |
| this.onChange(this.state.value); |
| }, |
| ); |
| } |
| |
| onMetricEdit(changedMetric, oldMetric) { |
| this.setState( |
| prevState => ({ |
| value: prevState.value.map(value => { |
| if ( |
| // compare saved metrics |
| value === oldMetric.metric_name || |
| // compare adhoc metrics |
| typeof value.optionName !== 'undefined' |
| ? value.optionName === oldMetric.optionName |
| : false |
| ) { |
| return changedMetric; |
| } |
| return value; |
| }), |
| }), |
| () => { |
| this.onChange(this.state.value); |
| }, |
| ); |
| } |
| |
| onRemoveMetric(index) { |
| if (!Array.isArray(this.state.value)) { |
| return; |
| } |
| const valuesCopy = [...this.state.value]; |
| valuesCopy.splice(index, 1); |
| this.setState(prevState => ({ |
| ...prevState, |
| value: valuesCopy, |
| })); |
| this.props.onChange(valuesCopy); |
| } |
| |
| onChange(opts) { |
| // if clear out options |
| if (opts === null) { |
| this.props.onChange(null); |
| return; |
| } |
| |
| let transformedOpts; |
| if (Array.isArray(opts)) { |
| transformedOpts = opts; |
| } else { |
| transformedOpts = opts ? [opts] : []; |
| } |
| const optionValues = transformedOpts |
| .map(option => { |
| // pre-defined metric |
| if (option.metric_name) { |
| return option.metric_name; |
| } |
| return option; |
| }) |
| .filter(option => option); |
| this.props.onChange(this.props.multi ? optionValues : optionValues[0]); |
| } |
| |
| moveLabel(dragIndex, hoverIndex) { |
| const { value } = this.state; |
| |
| const newValues = [...value]; |
| [newValues[hoverIndex], newValues[dragIndex]] = [ |
| newValues[dragIndex], |
| newValues[hoverIndex], |
| ]; |
| this.setState({ value: newValues }); |
| } |
| |
| isAddNewMetricDisabled() { |
| return !this.props.multi && this.state.value.length > 0; |
| } |
| |
| addNewMetricPopoverTrigger(trigger) { |
| if (this.isAddNewMetricDisabled()) { |
| return trigger; |
| } |
| return ( |
| <AdhocMetricPopoverTrigger |
| adhocMetric={new AdhocMetric({ isNew: true })} |
| onMetricEdit={this.onNewMetric} |
| columns={this.props.columns} |
| savedMetricsOptions={getOptionsForSavedMetrics( |
| this.props.savedMetrics, |
| this.props.value, |
| null, |
| )} |
| datasource={this.props.datasource} |
| savedMetric={{}} |
| datasourceType={this.props.datasourceType} |
| createNew |
| > |
| {trigger} |
| </AdhocMetricPopoverTrigger> |
| ); |
| } |
| |
| checkIfAggregateInInput(input) { |
| const lowercaseInput = input.toLowerCase(); |
| const aggregateInInput = |
| AGGREGATES_OPTIONS.find(x => |
| lowercaseInput.startsWith(`${x.toLowerCase()}(`), |
| ) || null; |
| this.clearedAggregateInInput = this.state.aggregateInInput; |
| this.setState({ aggregateInInput }); |
| } |
| |
| optionsForSelect(props) { |
| const { columns, savedMetrics } = props; |
| const aggregates = |
| columns && columns.length |
| ? AGGREGATES_OPTIONS.map(aggregate => ({ |
| aggregate_name: aggregate, |
| })) |
| : []; |
| const options = [ |
| ...(columns || []), |
| ...aggregates, |
| ...(savedMetrics || []), |
| ]; |
| |
| return options.reduce((results, option) => { |
| if (option.metric_name) { |
| results.push({ ...option, optionName: option.metric_name }); |
| } else if (option.column_name) { |
| results.push({ ...option, optionName: `_col_${option.column_name}` }); |
| } else if (option.aggregate_name) { |
| results.push({ |
| ...option, |
| optionName: `_aggregate_${option.aggregate_name}`, |
| }); |
| } |
| return results; |
| }, []); |
| } |
| |
| isAutoGeneratedMetric(savedMetric) { |
| if (this.props.datasourceType === 'druid') { |
| return druidAutoGeneratedMetricRegex.test(savedMetric.verbose_name); |
| } |
| return sqlaAutoGeneratedMetricNameRegex.test(savedMetric.metric_name); |
| } |
| |
| selectFilterOption({ data: 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) >= 0 |
| ); |
| } |
| return ( |
| option.optionName && |
| (!option.metric_name || |
| !this.isAutoGeneratedMetric(option) || |
| option.verbose_name) && |
| (option.optionName.toLowerCase().indexOf(filterValue) >= 0 || |
| (option.verbose_name && |
| option.verbose_name.toLowerCase().indexOf(filterValue) >= 0)) |
| ); |
| } |
| |
| render() { |
| const { theme } = this.props; |
| return ( |
| <div className="metrics-select"> |
| <HeaderContainer> |
| <ControlHeader {...this.props} /> |
| {this.addNewMetricPopoverTrigger( |
| <AddIconButton |
| disabled={this.isAddNewMetricDisabled()} |
| data-test="add-metric-button" |
| > |
| <Icon |
| name="plus-large" |
| width={theme.gridUnit * 3} |
| height={theme.gridUnit * 3} |
| color={theme.colors.grayscale.light5} |
| /> |
| </AddIconButton>, |
| )} |
| </HeaderContainer> |
| <LabelsContainer> |
| {this.state.value.length > 0 |
| ? this.state.value.map((value, index) => |
| this.valueRenderer(value, index), |
| ) |
| : this.addNewMetricPopoverTrigger( |
| <AddControlLabel> |
| <Icon |
| name="plus-small" |
| color={theme.colors.grayscale.light1} |
| /> |
| {t('Add metric')} |
| </AddControlLabel>, |
| )} |
| </LabelsContainer> |
| </div> |
| ); |
| } |
| } |
| |
| MetricsControl.propTypes = propTypes; |
| MetricsControl.defaultProps = defaultProps; |
| |
| export default withTheme(MetricsControl); |