| /** |
| * Licensed to the Apache Software Foundation (ASF) under one |
| * or more contributor license agreements. See the NOTICE file |
| * distributed with work for additional information |
| * regarding copyright ownership. The ASF licenses file |
| * to you under the Apache License, Version 2.0 (the |
| * "License"); you may not use 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, { useCallback, useEffect, useMemo, useState } from 'react'; |
| import { |
| DatasourceType, |
| ensureIsArray, |
| FeatureFlag, |
| GenericDataType, |
| isFeatureEnabled, |
| Metric, |
| QueryFormMetric, |
| tn, |
| } from '@superset-ui/core'; |
| import { ColumnMeta } from '@superset-ui/chart-controls'; |
| import { isEqual } from 'lodash'; |
| import { usePrevious } from 'src/common/hooks/usePrevious'; |
| import AdhocMetric from 'src/explore/components/controls/MetricControl/AdhocMetric'; |
| import AdhocMetricPopoverTrigger from 'src/explore/components/controls/MetricControl/AdhocMetricPopoverTrigger'; |
| import MetricDefinitionValue from 'src/explore/components/controls/MetricControl/MetricDefinitionValue'; |
| import { |
| DatasourcePanelDndItem, |
| isDatasourcePanelDndItem, |
| } from 'src/explore/components/DatasourcePanel/types'; |
| import { DndItemType } from 'src/explore/components/DndItemType'; |
| import DndSelectLabel from 'src/explore/components/controls/DndColumnSelectControl/DndSelectLabel'; |
| import { savedMetricType } from 'src/explore/components/controls/MetricControl/types'; |
| import { AGGREGATES } from 'src/explore/constants'; |
| import { DndControlProps } from './types'; |
| |
| const EMPTY_OBJECT = {}; |
| const DND_ACCEPTED_TYPES = [DndItemType.Column, DndItemType.Metric]; |
| |
| const isDictionaryForAdhocMetric = (value: any) => |
| value && !(value instanceof AdhocMetric) && value.expressionType; |
| |
| const coerceAdhocMetrics = (value: any) => { |
| 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; |
| }); |
| }; |
| |
| const getOptionsForSavedMetrics = ( |
| savedMetrics: savedMetricType[], |
| currentMetricValues: (string | AdhocMetric)[], |
| currentMetric?: string, |
| ) => |
| savedMetrics?.filter(savedMetric => |
| Array.isArray(currentMetricValues) |
| ? !currentMetricValues.includes(savedMetric.metric_name ?? '') || |
| savedMetric.metric_name === currentMetric |
| : savedMetric, |
| ) ?? []; |
| |
| type ValueType = Metric | AdhocMetric | QueryFormMetric; |
| |
| const columnsContainAllMetrics = ( |
| value: ValueType | ValueType[] | null | undefined, |
| columns: ColumnMeta[], |
| savedMetrics: (savedMetricType | Metric)[], |
| ) => { |
| const columnNames = new Set( |
| [...(columns || []), ...(savedMetrics || [])] |
| // eslint-disable-next-line camelcase |
| .map( |
| item => |
| (item as ColumnMeta).column_name || |
| (item as savedMetricType).metric_name, |
| ), |
| ); |
| |
| return ( |
| ensureIsArray(value) |
| .filter(metric => metric) |
| // find column names |
| .map(metric => |
| (metric as AdhocMetric).column |
| ? (metric as AdhocMetric).column.column_name |
| : (metric as ColumnMeta).column_name || metric, |
| ) |
| .filter(name => name && typeof name === 'string') |
| .every(name => columnNames.has(name)) |
| ); |
| }; |
| |
| export type DndMetricSelectProps = DndControlProps<ValueType> & { |
| savedMetrics: savedMetricType[]; |
| columns: ColumnMeta[]; |
| datasourceType?: DatasourceType; |
| }; |
| |
| export const DndMetricSelect = (props: any) => { |
| const { onChange, multi, columns, savedMetrics } = props; |
| |
| const handleChange = useCallback( |
| opts => { |
| // if clear out options |
| if (opts === null) { |
| onChange(null); |
| return; |
| } |
| |
| const transformedOpts = ensureIsArray(opts); |
| const optionValues = transformedOpts |
| .map(option => { |
| // pre-defined metric |
| if (option.metric_name) { |
| return option.metric_name; |
| } |
| return option; |
| }) |
| .filter(option => option); |
| onChange(multi ? optionValues : optionValues[0]); |
| }, |
| [multi, onChange], |
| ); |
| |
| const [value, setValue] = useState<ValueType[]>( |
| coerceAdhocMetrics(props.value), |
| ); |
| const [droppedItem, setDroppedItem] = useState< |
| DatasourcePanelDndItem | typeof EMPTY_OBJECT |
| >({}); |
| const [newMetricPopoverVisible, setNewMetricPopoverVisible] = useState(false); |
| const prevColumns = usePrevious(columns); |
| const prevSavedMetrics = usePrevious(savedMetrics); |
| |
| useEffect(() => { |
| setValue(coerceAdhocMetrics(props.value)); |
| }, [JSON.stringify(props.value)]); |
| |
| useEffect(() => { |
| if ( |
| !isEqual(prevColumns, columns) || |
| !isEqual(prevSavedMetrics, savedMetrics) |
| ) { |
| // 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(props.value, columns, savedMetrics)) { |
| onChange([]); |
| } |
| } |
| }, [ |
| prevColumns, |
| columns, |
| prevSavedMetrics, |
| savedMetrics, |
| props.value, |
| onChange, |
| ]); |
| |
| const canDrop = useCallback( |
| (item: DatasourcePanelDndItem) => { |
| const isMetricAlreadyInValues = |
| item.type === 'metric' ? value.includes(item.value.metric_name) : false; |
| return !isMetricAlreadyInValues; |
| }, |
| [value], |
| ); |
| |
| const onNewMetric = useCallback( |
| (newMetric: Metric) => { |
| const newValue = props.multi ? [...value, newMetric] : [newMetric]; |
| setValue(newValue); |
| handleChange(newValue); |
| }, |
| [handleChange, props.multi, value], |
| ); |
| |
| const onMetricEdit = useCallback( |
| (changedMetric: Metric | AdhocMetric, oldMetric: Metric | AdhocMetric) => { |
| if (oldMetric instanceof AdhocMetric && oldMetric.equals(changedMetric)) { |
| return; |
| } |
| const newValue = value.map(value => { |
| if ( |
| // compare saved metrics |
| ('metric_name' in oldMetric && value === oldMetric.metric_name) || |
| // compare adhoc metrics |
| typeof (value as AdhocMetric).optionName !== 'undefined' |
| ? (value as AdhocMetric).optionName === |
| (oldMetric as AdhocMetric).optionName |
| : false |
| ) { |
| return changedMetric; |
| } |
| return value; |
| }); |
| setValue(newValue); |
| handleChange(newValue); |
| }, |
| [handleChange, value], |
| ); |
| |
| const onRemoveMetric = useCallback( |
| (index: number) => { |
| if (!Array.isArray(value)) { |
| return; |
| } |
| const valuesCopy = [...value]; |
| valuesCopy.splice(index, 1); |
| setValue(valuesCopy); |
| onChange(valuesCopy); |
| }, |
| [onChange, value], |
| ); |
| |
| const moveLabel = useCallback( |
| (dragIndex: number, hoverIndex: number) => { |
| const newValues = [...value]; |
| [newValues[hoverIndex], newValues[dragIndex]] = [ |
| newValues[dragIndex], |
| newValues[hoverIndex], |
| ]; |
| setValue(newValues); |
| }, |
| [value], |
| ); |
| |
| const newSavedMetricOptions = useMemo( |
| () => getOptionsForSavedMetrics(props.savedMetrics, props.value), |
| [props.savedMetrics, props.value], |
| ); |
| |
| const getSavedMetricOptionsForMetric = useCallback( |
| index => |
| getOptionsForSavedMetrics( |
| props.savedMetrics, |
| props.value, |
| props.value?.[index], |
| ), |
| [props.savedMetrics, props.value], |
| ); |
| |
| const handleDropLabel = useCallback( |
| () => onChange(multi ? value : value[0]), |
| [multi, onChange, value], |
| ); |
| |
| const valueRenderer = useCallback( |
| (option: ValueType, index: number) => ( |
| <MetricDefinitionValue |
| key={index} |
| index={index} |
| option={option} |
| onMetricEdit={onMetricEdit} |
| onRemoveMetric={onRemoveMetric} |
| columns={props.columns} |
| savedMetrics={props.savedMetrics} |
| savedMetricsOptions={getSavedMetricOptionsForMetric(index)} |
| datasourceType={props.datasourceType} |
| onMoveLabel={moveLabel} |
| onDropLabel={handleDropLabel} |
| type={`${DndItemType.AdhocMetricOption}_${props.name}_${props.label}`} |
| multi={multi} |
| /> |
| ), |
| [ |
| getSavedMetricOptionsForMetric, |
| handleDropLabel, |
| moveLabel, |
| multi, |
| onMetricEdit, |
| onRemoveMetric, |
| props.columns, |
| props.datasourceType, |
| props.label, |
| props.name, |
| props.savedMetrics, |
| ], |
| ); |
| |
| const valuesRenderer = useCallback( |
| () => value.map((value, index) => valueRenderer(value, index)), |
| [value, valueRenderer], |
| ); |
| |
| const togglePopover = useCallback((visible: boolean) => { |
| setNewMetricPopoverVisible(visible); |
| }, []); |
| |
| const closePopover = useCallback(() => { |
| togglePopover(false); |
| }, [togglePopover]); |
| |
| const handleDrop = useCallback( |
| (item: DatasourcePanelDndItem) => { |
| if (item.type === DndItemType.Metric) { |
| onNewMetric(item.value as Metric); |
| } |
| if (item.type === DndItemType.Column) { |
| setDroppedItem(item); |
| togglePopover(true); |
| } |
| }, |
| [onNewMetric, togglePopover], |
| ); |
| |
| const handleClickGhostButton = useCallback(() => { |
| setDroppedItem({}); |
| togglePopover(true); |
| }, [togglePopover]); |
| |
| const adhocMetric = useMemo(() => { |
| if ( |
| isDatasourcePanelDndItem(droppedItem) && |
| droppedItem.type === DndItemType.Column |
| ) { |
| const itemValue = droppedItem.value as ColumnMeta; |
| const config: Partial<AdhocMetric> = { |
| column: { column_name: itemValue?.column_name }, |
| }; |
| if (isFeatureEnabled(FeatureFlag.UX_BETA)) { |
| if (itemValue.type_generic === GenericDataType.NUMERIC) { |
| config.aggregate = AGGREGATES.SUM; |
| } else if ( |
| itemValue.type_generic === GenericDataType.STRING || |
| itemValue.type_generic === GenericDataType.BOOLEAN || |
| itemValue.type_generic === GenericDataType.TEMPORAL |
| ) { |
| config.aggregate = AGGREGATES.COUNT_DISTINCT; |
| } |
| } |
| return new AdhocMetric(config); |
| } |
| return new AdhocMetric({ isNew: true }); |
| }, [droppedItem]); |
| |
| const ghostButtonText = isFeatureEnabled(FeatureFlag.ENABLE_DND_WITH_CLICK_UX) |
| ? tn( |
| 'Drop a column/metric here or click', |
| 'Drop columns/metrics here or click', |
| multi ? 2 : 1, |
| ) |
| : tn( |
| 'Drop column or metric here', |
| 'Drop columns or metrics here', |
| multi ? 2 : 1, |
| ); |
| |
| return ( |
| <div className="metrics-select"> |
| <DndSelectLabel |
| onDrop={handleDrop} |
| canDrop={canDrop} |
| valuesRenderer={valuesRenderer} |
| accept={DND_ACCEPTED_TYPES} |
| ghostButtonText={ghostButtonText} |
| displayGhostButton={multi || value.length === 0} |
| onClickGhostButton={ |
| isFeatureEnabled(FeatureFlag.ENABLE_DND_WITH_CLICK_UX) |
| ? handleClickGhostButton |
| : undefined |
| } |
| {...props} |
| /> |
| <AdhocMetricPopoverTrigger |
| adhocMetric={adhocMetric} |
| onMetricEdit={onNewMetric} |
| columns={props.columns} |
| savedMetricsOptions={newSavedMetricOptions} |
| savedMetric={EMPTY_OBJECT as savedMetricType} |
| datasourceType={props.datasourceType} |
| isControlledComponent |
| visible={newMetricPopoverVisible} |
| togglePopover={togglePopover} |
| closePopover={closePopover} |
| > |
| <div /> |
| </AdhocMetricPopoverTrigger> |
| </div> |
| ); |
| }; |