/** * 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 { useCallback, useEffect, useMemo, useState } from 'react'; import { nanoid } from 'nanoid'; import { ensureIsArray, isAdhocMetricSimple, isSavedMetric, Metric, QueryFormMetric, t, tn, } from '@superset-ui/core'; import { GenericDataType } from '@apache-superset/core/api/core'; import { ColumnMeta } from '@superset-ui/chart-controls'; 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'; const EMPTY_OBJECT = {}; const DND_ACCEPTED_TYPES = [DndItemType.Column, DndItemType.Metric]; const isDictionaryForAdhocMetric = (value: QueryFormMetric) => value && !(value instanceof AdhocMetric) && typeof value !== 'string' && value.expressionType; const coerceMetrics = ( addedMetrics: QueryFormMetric | QueryFormMetric[] | undefined | null, savedMetrics: Metric[], columns: ColumnMeta[], ) => { if (!addedMetrics) { return []; } const metricsCompatibleWithDataset = ensureIsArray(addedMetrics).filter( metric => { if (isAdhocMetricSimple(metric)) { return columns.some( column => column.column_name === metric.column.column_name, ); } return true; }, ); return metricsCompatibleWithDataset.map(metric => { if ( isSavedMetric(metric) && !savedMetrics.some(savedMetric => savedMetric.metric_name === metric) ) { return { metric_name: metric, error_text: t('This metric might be incompatible with current dataset'), uuid: nanoid(), }; } if (!isDictionaryForAdhocMetric(metric)) { return metric; } if (isAdhocMetricSimple(metric)) { const column = columns.find( col => col.column_name === metric.column.column_name, ); if (column) { // Cast entire config object to handle type mismatch between @superset-ui/core and local types return new AdhocMetric({ ...(metric as unknown as Record), column, } as Record); } } // Cast to unknown first to handle type mismatch between @superset-ui/core and local AdhocMetric return new AdhocMetric(metric as unknown as Record); }); }; 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 DndMetricSelect = (props: any) => { const { onChange, multi, datasource, savedMetrics } = props; const extra = useMemo<{ disallow_adhoc_metrics?: boolean }>(() => { let extra = {}; if (datasource?.extra) { try { extra = JSON.parse(datasource.extra); } catch {} // eslint-disable-line no-empty } return extra; }, [datasource?.extra]); const savedMetricSet = useMemo( () => new Set( (savedMetrics as savedMetricType[]).map( ({ metric_name }) => metric_name, ), ), [savedMetrics], ); 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( coerceMetrics(props.value, props.savedMetrics, props.columns), ); const [droppedItem, setDroppedItem] = useState< DatasourcePanelDndItem | typeof EMPTY_OBJECT >({}); const [newMetricPopoverVisible, setNewMetricPopoverVisible] = useState(false); useEffect(() => { setValue(coerceMetrics(props.value, props.savedMetrics, props.columns)); }, [ JSON.stringify(props.value), JSON.stringify(props.savedMetrics), JSON.stringify(props.columns), ]); const canDrop = useCallback( (item: DatasourcePanelDndItem) => { if ( extra.disallow_adhoc_metrics && (item.type !== DndItemType.Metric || !savedMetricSet.has(item.value.metric_name)) ) { return false; } const isMetricAlreadyInValues = item.type === 'metric' ? value.includes(item.value.metric_name) : false; return !isMetricAlreadyInValues; }, [value, extra, savedMetricSet], ); 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 && changedMetric 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); handleChange(valuesCopy); }, [handleChange, 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) => ( ), [ getSavedMetricOptionsForMetric, handleDropLabel, moveLabel, multi, onMetricEdit, onRemoveMetric, props.columns, props.datasource, 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; // Cast config to handle ColumnMeta/ColumnType mismatch const config = { column: itemValue, } as Partial; 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({}); }, [droppedItem]); const ghostButtonText = tn( 'Drop a column/metric here or click', 'Drop columns/metrics here or click', multi ? 2 : 1, ); return (
); }; export { DndMetricSelect };