From d439da2fe00c0a16edfec3fb5354d8e8f4da470b Mon Sep 17 00:00:00 2001 From: Kamil Gabryjelski Date: Fri, 12 Mar 2021 05:31:17 +0100 Subject: [PATCH] feat: Implement drag and drop for metrics (#13575) * Convert MetricsControl to functional component * Implement drag and drop for metrics * Implement customizable placeholder for DndSelectLabel --- .../DndFilterSelect.tsx | 15 +- .../DndMetricSelect.tsx | 301 ++++++++++++++++++ .../DndColumnSelectControl/DndSelectLabel.tsx | 2 +- .../controls/DndColumnSelectControl/index.ts | 1 + .../controls/DndColumnSelectControl/types.ts | 8 +- .../AdhocMetricPopoverTrigger.tsx | 41 ++- .../MetricControl/MetricDefinitionValue.jsx | 23 +- .../controls/MetricControl/types.ts | 2 +- .../src/explore/components/controls/index.js | 2 + 9 files changed, 357 insertions(+), 38 deletions(-) create mode 100644 superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndMetricSelect.tsx diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndFilterSelect.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndFilterSelect.tsx index 5b148883ec8..e835f9953eb 100644 --- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndFilterSelect.tsx +++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndFilterSelect.tsx @@ -17,12 +17,12 @@ * under the License. */ import React, { useEffect, useMemo, useState } from 'react'; -import { logging, SupersetClient } from '@superset-ui/core'; +import { logging, SupersetClient, t } from '@superset-ui/core'; import { ColumnMeta, Metric } from '@superset-ui/chart-controls'; import { Tooltip } from 'src/common/components/Tooltip'; import { OPERATORS } from 'src/explore/constants'; import { OptionSortType } from 'src/explore/types'; -import { DndFilterSelectProps, FilterOptionValueType } from './types'; +import { DndFilterSelectProps, OptionValueType } from './types'; import AdhocFilterPopoverTrigger from '../FilterControl/AdhocFilterPopoverTrigger'; import OptionWrapper from './components/OptionWrapper'; import DndSelectLabel from './DndSelectLabel'; @@ -37,13 +37,13 @@ import { } from '../../DatasourcePanel/types'; import { DndItemType } from '../../DndItemType'; -const isDictionaryForAdhocFilter = (value: FilterOptionValueType) => +const isDictionaryForAdhocFilter = (value: OptionValueType) => !(value instanceof AdhocFilter) && value?.expressionType; export const DndFilterSelect = (props: DndFilterSelectProps) => { const propsValues = Array.from(props.value ?? []); const [values, setValues] = useState( - propsValues.map((filter: FilterOptionValueType) => + propsValues.map((filter: OptionValueType) => isDictionaryForAdhocFilter(filter) ? new AdhocFilter(filter) : filter, ), ); @@ -144,7 +144,7 @@ export const DndFilterSelect = (props: DndFilterSelectProps) => { useEffect(() => { setValues( - (props.value || []).map((filter: FilterOptionValueType) => + (props.value || []).map((filter: OptionValueType) => isDictionaryForAdhocFilter(filter) ? new AdhocFilter(filter) : filter, ), ); @@ -171,7 +171,7 @@ export const DndFilterSelect = (props: DndFilterSelectProps) => { (savedMetric: Metric) => savedMetric.metric_name === savedMetricName, )?.expression; - const mapOption = (option: FilterOptionValueType) => { + const mapOption = (option: OptionValueType) => { // already a AdhocFilter, skip if (option instanceof AdhocFilter) { return option; @@ -299,7 +299,7 @@ export const DndFilterSelect = (props: DndFilterSelectProps) => { return ( <> - + values={values} onDrop={(item: DatasourcePanelDndItem) => { setDroppedItem(item.value); @@ -313,6 +313,7 @@ export const DndFilterSelect = (props: DndFilterSelectProps) => { DndItemType.MetricOption, DndItemType.AdhocMetricOption, ]} + placeholderText={t('Drop columns or metrics')} {...props} /> + 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, + ) ?? []; + +const columnsContainAllMetrics = ( + value: (string | AdhocMetric | ColumnMeta)[], + columns: ColumnMeta[], + savedMetrics: savedMetricType[], +) => { + 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 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<(AdhocMetric | Metric | string)[]>( + coerceAdhocMetrics(props.value), + ); + const [droppedItem, setDroppedItem] = useState( + null, + ); + 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 = (item: DatasourcePanelDndItem) => { + const isMetricAlreadyInValues = + item.type === 'metric' ? value.includes(item.value.metric_name) : false; + return (props.multi || value.length === 0) && !isMetricAlreadyInValues; + }; + + const onNewMetric = (newMetric: Metric) => { + const newValue = [...value, newMetric]; + setValue(newValue); + handleChange(newValue); + }; + + const onMetricEdit = ( + changedMetric: Metric | AdhocMetric, + oldMetric: Metric | AdhocMetric, + ) => { + const newValue = value.map(value => { + if ( + // compare saved metrics + value === (oldMetric as Metric).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); + }; + + const onRemoveMetric = (index: number) => { + if (!Array.isArray(value)) { + return; + } + const valuesCopy = [...value]; + valuesCopy.splice(index, 1); + setValue(valuesCopy); + onChange(valuesCopy); + }; + + const moveLabel = (dragIndex: number, hoverIndex: number) => { + const newValues = [...value]; + [newValues[hoverIndex], newValues[dragIndex]] = [ + newValues[dragIndex], + newValues[hoverIndex], + ]; + setValue(newValues); + }; + + const valueRenderer = ( + option: Metric | AdhocMetric | string, + index: number, + ) => ( + onRemoveMetric(index)} + columns={props.columns} + savedMetrics={props.savedMetrics} + savedMetricsOptions={getOptionsForSavedMetrics( + props.savedMetrics, + props.value, + props.value?.[index], + )} + datasourceType={props.datasourceType} + onMoveLabel={moveLabel} + onDropLabel={() => onChange(value)} + /> + ); + + const valuesRenderer = () => + value.map((value, index) => valueRenderer(value, index)); + + const togglePopover = (visible: boolean) => { + setNewMetricPopoverVisible(visible); + }; + + const closePopover = () => { + togglePopover(false); + }; + + const { savedMetric, adhocMetric } = useMemo(() => { + if (droppedItem?.type === 'column') { + const itemValue = droppedItem?.value as ColumnMeta; + return { + savedMetric: {} as savedMetricType, + adhocMetric: new AdhocMetric({ + column: { column_name: itemValue?.column_name }, + }), + }; + } + if (droppedItem?.type === 'metric') { + const itemValue = droppedItem?.value as savedMetricType; + return { + savedMetric: itemValue, + adhocMetric: new AdhocMetric({ isNew: true }), + }; + } + return { + savedMetric: {} as savedMetricType, + adhocMetric: new AdhocMetric({ isNew: true }), + }; + }, [droppedItem?.type, droppedItem?.value]); + + return ( +
+ + values={value} + onDrop={(item: DatasourcePanelDndItem) => { + setDroppedItem(item); + togglePopover(true); + }} + canDrop={canDrop} + valuesRenderer={valuesRenderer} + accept={[DndItemType.Column, DndItemType.Metric]} + placeholderText={t('Drop columns or metrics')} + {...props} + /> + +
+ +
+ ); +}; diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndSelectLabel.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndSelectLabel.tsx index 820ae311354..f0ee950726f 100644 --- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndSelectLabel.tsx +++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndSelectLabel.tsx @@ -55,7 +55,7 @@ export default function DndSelectLabel( return ( - {t('Drop Columns')} + {t(props.placeholderText || 'Drop columns')} ); } diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/index.ts b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/index.ts index 7e8ca102744..dda99f7d0b0 100644 --- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/index.ts +++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/index.ts @@ -19,3 +19,4 @@ export { default } from './DndSelectLabel'; export * from './DndColumnSelect'; export * from './DndFilterSelect'; +export * from './DndMetricSelect'; diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/types.ts b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/types.ts index 13e30dd3db3..68f830f5e53 100644 --- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/types.ts +++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/types.ts @@ -17,7 +17,6 @@ * under the License. */ import { ReactNode } from 'react'; -import { AdhocFilter } from '@superset-ui/core'; import { ColumnMeta, Metric } from '@superset-ui/chart-controls'; import { DatasourcePanelDndItem } from '../../DatasourcePanel/types'; import { DndItemType } from '../../DndItemType'; @@ -51,16 +50,17 @@ export interface DndColumnSelectProps< canDrop: (item: DatasourcePanelDndItem) => boolean; valuesRenderer: () => ReactNode; accept: DndItemType | DndItemType[]; + placeholderText?: string; } -export type FilterOptionValueType = Record | AdhocFilter; +export type OptionValueType = Record; export interface DndFilterSelectProps { name: string; - value: FilterOptionValueType[]; + value: OptionValueType[]; columns: ColumnMeta[]; datasource: Record; formData: Record; savedMetrics: Metric[]; - onChange: (filters: FilterOptionValueType[]) => void; + onChange: (filters: OptionValueType[]) => void; options: { string: ColumnMeta }; } diff --git a/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricPopoverTrigger.tsx b/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricPopoverTrigger.tsx index dd7b4bf8945..4ca9c65b9ec 100644 --- a/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricPopoverTrigger.tsx +++ b/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricPopoverTrigger.tsx @@ -35,6 +35,10 @@ export type AdhocMetricPopoverTriggerProps = { datasourceType: string; children: ReactNode; createNew?: boolean; + isControlledComponent?: boolean; + visible?: boolean; + togglePopover?: (visible: boolean) => void; + closePopover?: () => void; }; export type AdhocMetricPopoverTriggerState = { @@ -139,7 +143,14 @@ class AdhocMetricPopoverTrigger extends React.PureComponent< } render() { - const { adhocMetric, savedMetric } = this.props; + const { + adhocMetric, + savedMetric, + columns, + savedMetricsOptions, + datasourceType, + isControlledComponent, + } = this.props; const { verbose_name, metric_name } = savedMetric; const { hasCustomLabel, label } = adhocMetric; const adhocMetricLabel = hasCustomLabel @@ -152,16 +163,28 @@ class AdhocMetricPopoverTrigger extends React.PureComponent< hasCustomLabel, }; + const { visible, togglePopover, closePopover } = isControlledComponent + ? { + visible: this.props.visible, + togglePopover: this.props.togglePopover, + closePopover: this.props.closePopover, + } + : { + visible: this.state.popoverVisible, + togglePopover: this.togglePopover, + closePopover: this.closePopover, + }; + const overlayContent = ( diff --git a/superset-frontend/src/explore/components/controls/MetricControl/MetricDefinitionValue.jsx b/superset-frontend/src/explore/components/controls/MetricControl/MetricDefinitionValue.jsx index f3bb5478e5b..12dbd182b0c 100644 --- a/superset-frontend/src/explore/components/controls/MetricControl/MetricDefinitionValue.jsx +++ b/superset-frontend/src/explore/components/controls/MetricControl/MetricDefinitionValue.jsx @@ -18,16 +18,20 @@ */ import React from 'react'; import PropTypes from 'prop-types'; +import { Metric } from '@superset-ui/chart-controls/lib/types'; import columnType from 'src/explore/propTypes/columnType'; -import { OptionControlLabel } from 'src/explore/components/OptionControls'; import AdhocMetricOption from './AdhocMetricOption'; import AdhocMetric from './AdhocMetric'; import savedMetricType from './savedMetricType'; import adhocMetricType from './adhocMetricType'; -import { DndItemType } from '../../DndItemType'; const propTypes = { - option: PropTypes.oneOfType([savedMetricType, adhocMetricType]).isRequired, + option: PropTypes.oneOfType([ + savedMetricType, + adhocMetricType, + Metric, + PropTypes.string, + ]).isRequired, index: PropTypes.number.isRequired, onMetricEdit: PropTypes.func, onRemoveMetric: PropTypes.func, @@ -81,19 +85,6 @@ export default function MetricDefinitionValue({ return ; } - if (typeof option === 'string') { - return ( - - ); - } return null; } MetricDefinitionValue.propTypes = propTypes; diff --git a/superset-frontend/src/explore/components/controls/MetricControl/types.ts b/superset-frontend/src/explore/components/controls/MetricControl/types.ts index 0130353212d..f8f52fcb337 100644 --- a/superset-frontend/src/explore/components/controls/MetricControl/types.ts +++ b/superset-frontend/src/explore/components/controls/MetricControl/types.ts @@ -18,6 +18,6 @@ */ export type savedMetricType = { metric_name: string; - verbose_name: string; + verbose_name?: string; expression: string; }; diff --git a/superset-frontend/src/explore/components/controls/index.js b/superset-frontend/src/explore/components/controls/index.js index 28bfcd186db..dc27b1a9b4e 100644 --- a/superset-frontend/src/explore/components/controls/index.js +++ b/superset-frontend/src/explore/components/controls/index.js @@ -42,6 +42,7 @@ import FilterBoxItemControl from './FilterBoxItemControl'; import DndColumnSelectControl, { DndColumnSelect, DndFilterSelect, + DndMetricSelect, } from './DndColumnSelectControl'; const controlMap = { @@ -57,6 +58,7 @@ const controlMap = { DndColumnSelectControl, DndColumnSelect, DndFilterSelect, + DndMetricSelect, FixedOrMetricControl, HiddenControl, SelectAsyncControl,