You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by yo...@apache.org on 2021/03/12 04:32:11 UTC
[superset] branch master updated: feat: Implement drag and drop for
metrics (#13575)
This is an automated email from the ASF dual-hosted git repository.
yongjiezhao pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new d439da2 feat: Implement drag and drop for metrics (#13575)
d439da2 is described below
commit d439da2fe00c0a16edfec3fb5354d8e8f4da470b
Author: Kamil Gabryjelski <ka...@gmail.com>
AuthorDate: Fri Mar 12 05:31:17 2021 +0100
feat: Implement drag and drop for metrics (#13575)
* Convert MetricsControl to functional component
* Implement drag and drop for metrics
* Implement customizable placeholder for DndSelectLabel
---
.../DndColumnSelectControl/DndFilterSelect.tsx | 15 +-
.../DndColumnSelectControl/DndMetricSelect.tsx | 301 +++++++++++++++++++++
.../DndColumnSelectControl/DndSelectLabel.tsx | 2 +-
.../controls/DndColumnSelectControl/index.ts | 1 +
.../controls/DndColumnSelectControl/types.ts | 8 +-
.../MetricControl/AdhocMetricPopoverTrigger.tsx | 41 ++-
.../MetricControl/MetricDefinitionValue.jsx | 23 +-
.../components/controls/MetricControl/types.ts | 2 +-
.../src/explore/components/controls/index.js | 2 +
9 files changed, 357 insertions(+), 38 deletions(-)
diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndFilterSelect.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndFilterSelect.tsx
index 5b14888..e835f99 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 (
<>
- <DndSelectLabel<FilterOptionValueType, FilterOptionValueType[]>
+ <DndSelectLabel<OptionValueType, OptionValueType[]>
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}
/>
<AdhocFilterPopoverTrigger
diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndMetricSelect.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndMetricSelect.tsx
new file mode 100644
index 0000000..7fe1b4b
--- /dev/null
+++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndMetricSelect.tsx
@@ -0,0 +1,301 @@
+/**
+ * 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 { ensureIsArray, Metric, t } 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 '../MetricControl/AdhocMetric';
+import AdhocMetricPopoverTrigger from '../MetricControl/AdhocMetricPopoverTrigger';
+import MetricDefinitionValue from '../MetricControl/MetricDefinitionValue';
+import { OptionValueType } from './types';
+import { DatasourcePanelDndItem } from '../../DatasourcePanel/types';
+import { DndItemType } from '../../DndItemType';
+import DndSelectLabel from './DndSelectLabel';
+import { savedMetricType } from '../MetricControl/types';
+
+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,
+ ) ?? [];
+
+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<DatasourcePanelDndItem | null>(
+ 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,
+ ) => (
+ <MetricDefinitionValue
+ key={index}
+ index={index}
+ option={option}
+ onMetricEdit={onMetricEdit}
+ onRemoveMetric={() => 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 (
+ <div className="metrics-select">
+ <DndSelectLabel<OptionValueType, OptionValueType[]>
+ 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}
+ />
+ <AdhocMetricPopoverTrigger
+ adhocMetric={adhocMetric}
+ onMetricEdit={onNewMetric}
+ columns={props.columns}
+ savedMetricsOptions={getOptionsForSavedMetrics(
+ props.savedMetrics,
+ props.value,
+ )}
+ savedMetric={savedMetric}
+ datasourceType={props.datasourceType}
+ isControlledComponent
+ visible={newMetricPopoverVisible}
+ togglePopover={togglePopover}
+ closePopover={closePopover}
+ createNew
+ >
+ <div />
+ </AdhocMetricPopoverTrigger>
+ </div>
+ );
+};
diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndSelectLabel.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndSelectLabel.tsx
index 820ae31..f0ee950 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<T, O>(
return (
<AddControlLabel cancelHover>
<Icon name="plus-small" color={theme.colors.grayscale.light1} />
- {t('Drop Columns')}
+ {t(props.placeholderText || 'Drop columns')}
</AddControlLabel>
);
}
diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/index.ts b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/index.ts
index 7e8ca10..dda99f7 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 13e30dd..68f830f 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<string, any> | AdhocFilter;
+export type OptionValueType = Record<string, any>;
export interface DndFilterSelectProps {
name: string;
- value: FilterOptionValueType[];
+ value: OptionValueType[];
columns: ColumnMeta[];
datasource: Record<string, any>;
formData: Record<string, any>;
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 dd7b4bf..4ca9c65 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 = (
<AdhocMetricEditPopover
adhocMetric={adhocMetric}
title={title}
- columns={this.props.columns}
- savedMetricsOptions={this.props.savedMetricsOptions}
- savedMetric={this.props.savedMetric}
- datasourceType={this.props.datasourceType}
+ columns={columns}
+ savedMetricsOptions={savedMetricsOptions}
+ savedMetric={savedMetric}
+ datasourceType={datasourceType}
onResize={this.onPopoverResize}
- onClose={this.closePopover}
+ onClose={closePopover}
onChange={this.onChange}
getCurrentTab={this.getCurrentTab}
getCurrentLabel={this.getCurrentLabel}
@@ -181,9 +204,9 @@ class AdhocMetricPopoverTrigger extends React.PureComponent<
placement="right"
trigger="click"
content={overlayContent}
- defaultVisible={this.state.popoverVisible}
- visible={this.state.popoverVisible}
- onVisibleChange={this.togglePopover}
+ defaultVisible={visible}
+ visible={visible}
+ onVisibleChange={togglePopover}
title={popoverTitle}
destroyTooltipOnHide={this.props.createNew}
>
diff --git a/superset-frontend/src/explore/components/controls/MetricControl/MetricDefinitionValue.jsx b/superset-frontend/src/explore/components/controls/MetricControl/MetricDefinitionValue.jsx
index f3bb547..12dbd18 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 <AdhocMetricOption {...metricOptionProps} />;
}
- if (typeof option === 'string') {
- return (
- <OptionControlLabel
- label={option}
- onRemove={onRemoveMetric}
- onMoveLabel={onMoveLabel}
- onDropLabel={onDropLabel}
- type={DndItemType.FilterOption}
- index={index}
- isFunction
- />
- );
- }
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 0130353..f8f52fc 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 28bfcd1..dc27b1a 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,