From 203c311ca30cba616bff5253ca561438da21c03e Mon Sep 17 00:00:00 2001 From: Kamil Gabryjelski Date: Tue, 17 Aug 2021 14:56:39 +0200 Subject: [PATCH] feat(explore): make dnd controls clickable (#16119) * Make ghost buttons clickable * Popover for column control * Make column dnd ghost button clickable * Prefill operator only if column is defined * Remove data-tests * lint fix * Hide new features behind a feature flag * Change ghost button texts * Remove caret for non clickable columns --- .../ColumnSelectPopover.tsx | 223 ++++++++++++++++++ .../ColumnSelectPopoverTrigger.tsx | 99 ++++++++ .../DndColumnSelect.tsx | 134 +++++++++-- .../DndFilterSelect.tsx | 18 +- .../DndMetricSelect.tsx | 28 ++- .../DndColumnSelectControl/DndSelectLabel.tsx | 5 +- .../controls/DndColumnSelectControl/types.ts | 1 + superset/config.py | 1 + 8 files changed, 475 insertions(+), 34 deletions(-) create mode 100644 superset-frontend/src/explore/components/controls/DndColumnSelectControl/ColumnSelectPopover.tsx create mode 100644 superset-frontend/src/explore/components/controls/DndColumnSelectControl/ColumnSelectPopoverTrigger.tsx diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/ColumnSelectPopover.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/ColumnSelectPopover.tsx new file mode 100644 index 000000000..c58b3d382 --- /dev/null +++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/ColumnSelectPopover.tsx @@ -0,0 +1,223 @@ +/** + * 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. + */ +/* eslint-disable camelcase */ +import React, { useCallback, useMemo, useState } from 'react'; +import Tabs from 'src/components/Tabs'; +import Button from 'src/components/Button'; +import { NativeSelect as Select } from 'src/components/Select'; +import { t, styled } from '@superset-ui/core'; + +import { Form, FormItem } from 'src/components/Form'; +import { StyledColumnOption } from 'src/explore/components/optionRenderers'; +import { ColumnMeta } from '@superset-ui/chart-controls'; + +const StyledSelect = styled(Select)` + .metric-option { + & > svg { + min-width: ${({ theme }) => `${theme.gridUnit * 4}px`}; + } + & > .option-label { + overflow: hidden; + text-overflow: ellipsis; + } + } +`; + +interface ColumnSelectPopoverProps { + columns: ColumnMeta[]; + editedColumn?: ColumnMeta; + onChange: (column: ColumnMeta) => void; + onClose: () => void; +} + +const ColumnSelectPopover = ({ + columns, + editedColumn, + onChange, + onClose, +}: ColumnSelectPopoverProps) => { + const [ + initialCalculatedColumn, + initialSimpleColumn, + ] = editedColumn?.expression + ? [editedColumn, undefined] + : [undefined, editedColumn]; + const [selectedCalculatedColumn, setSelectedCalculatedColumn] = useState( + initialCalculatedColumn, + ); + const [selectedSimpleColumn, setSelectedSimpleColumn] = useState( + initialSimpleColumn, + ); + + const [calculatedColumns, simpleColumns] = useMemo( + () => + columns?.reduce( + (acc: [ColumnMeta[], ColumnMeta[]], column: ColumnMeta) => { + if (column.expression) { + acc[0].push(column); + } else { + acc[1].push(column); + } + return acc; + }, + [[], []], + ), + [columns], + ); + + const onCalculatedColumnChange = useCallback( + selectedColumnName => { + const selectedColumn = calculatedColumns.find( + col => col.column_name === selectedColumnName, + ); + setSelectedCalculatedColumn(selectedColumn); + setSelectedSimpleColumn(undefined); + }, + [calculatedColumns], + ); + + const onSimpleColumnChange = useCallback( + selectedColumnName => { + const selectedColumn = simpleColumns.find( + col => col.column_name === selectedColumnName, + ); + setSelectedCalculatedColumn(undefined); + setSelectedSimpleColumn(selectedColumn); + }, + [simpleColumns], + ); + + const defaultActiveTabKey = + initialSimpleColumn || calculatedColumns.length === 0 ? 'simple' : 'saved'; + + const onSave = useCallback(() => { + const selectedColumn = selectedCalculatedColumn || selectedSimpleColumn; + if (!selectedColumn) { + return; + } + onChange(selectedColumn); + onClose(); + }, [onChange, onClose, selectedCalculatedColumn, selectedSimpleColumn]); + + const onResetStateAndClose = useCallback(() => { + setSelectedCalculatedColumn(initialCalculatedColumn); + setSelectedSimpleColumn(initialSimpleColumn); + onClose(); + }, [initialCalculatedColumn, initialSimpleColumn, onClose]); + + const stateIsValid = selectedCalculatedColumn || selectedSimpleColumn; + const hasUnsavedChanges = + selectedCalculatedColumn?.column_name !== + initialCalculatedColumn?.column_name || + selectedSimpleColumn?.column_name !== initialSimpleColumn?.column_name; + + const filterOption = useCallback( + (input, option) => + option?.filterBy.toLowerCase().indexOf(input.toLowerCase()) >= 0, + [], + ); + + const getPopupContainer = useCallback( + (triggerNode: any) => triggerNode.parentNode, + [], + ); + + return ( +
+ + + + + {calculatedColumns.map(calculatedColumn => ( + + + + ))} + + + + + + + + + +
+ + +
+
+ ); +}; + +export default ColumnSelectPopover; diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/ColumnSelectPopoverTrigger.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/ColumnSelectPopoverTrigger.tsx new file mode 100644 index 000000000..4caf086d4 --- /dev/null +++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/ColumnSelectPopoverTrigger.tsx @@ -0,0 +1,99 @@ +/** + * 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, { useCallback, useMemo, useState } from 'react'; +import { ColumnMeta } from '@superset-ui/chart-controls'; +import Popover from 'src/components/Popover'; +import { ExplorePopoverContent } from 'src/explore/components/ExploreContentPopover'; +import ColumnSelectPopover from './ColumnSelectPopover'; + +interface ColumnSelectPopoverTriggerProps { + columns: ColumnMeta[]; + editedColumn?: ColumnMeta; + onColumnEdit: (editedColumn: ColumnMeta) => void; + isControlledComponent?: boolean; + visible?: boolean; + togglePopover?: (visible: boolean) => void; + closePopover?: () => void; + children: React.ReactNode; +} + +const ColumnSelectPopoverTrigger = ({ + columns, + editedColumn, + onColumnEdit, + isControlledComponent, + children, + ...props +}: ColumnSelectPopoverTriggerProps) => { + const [popoverVisible, setPopoverVisible] = useState(false); + + const togglePopover = useCallback((visible: boolean) => { + setPopoverVisible(visible); + }, []); + + const closePopover = useCallback(() => { + setPopoverVisible(false); + }, []); + + const { + visible, + handleTogglePopover, + handleClosePopover, + } = isControlledComponent + ? { + visible: props.visible, + handleTogglePopover: props.togglePopover!, + handleClosePopover: props.closePopover!, + } + : { + visible: popoverVisible, + handleTogglePopover: togglePopover, + handleClosePopover: closePopover, + }; + + const overlayContent = useMemo( + () => ( + + + + ), + [columns, editedColumn, handleClosePopover, onColumnEdit], + ); + + return ( + + {children} + + ); +}; + +export default ColumnSelectPopoverTrigger; diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelect.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelect.tsx index 788b4e27c..69cc6442e 100644 --- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelect.tsx +++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelect.tsx @@ -16,8 +16,8 @@ * specific language governing permissions and limitations * under the License. */ -import React, { useCallback, useMemo } from 'react'; -import { tn } from '@superset-ui/core'; +import React, { useCallback, useMemo, useState } from 'react'; +import { FeatureFlag, isFeatureEnabled, tn } from '@superset-ui/core'; import { ColumnMeta } from '@superset-ui/chart-controls'; import { isEmpty } from 'lodash'; import { LabelProps } from 'src/explore/components/controls/DndColumnSelectControl/types'; @@ -27,6 +27,7 @@ import { OptionSelector } from 'src/explore/components/controls/DndColumnSelectC import { DatasourcePanelDndItem } from 'src/explore/components/DatasourcePanel/types'; import { DndItemType } from 'src/explore/components/DndItemType'; import { useComponentDidUpdate } from 'src/common/hooks/useComponentDidUpdate'; +import ColumnSelectPopoverTrigger from './ColumnSelectPopoverTrigger'; export const DndColumnSelect = (props: LabelProps) => { const { @@ -39,6 +40,8 @@ export const DndColumnSelect = (props: LabelProps) => { name, label, } = props; + const [newColumnPopoverVisible, setNewColumnPopoverVisible] = useState(false); + const optionSelector = useMemo( () => new OptionSelector(options, multi, value), [multi, options, value], @@ -110,41 +113,120 @@ export const DndColumnSelect = (props: LabelProps) => { [onChange, optionSelector], ); + const popoverOptions = useMemo( + () => + Object.values(options).filter( + col => + !optionSelector.values + .map(val => val.column_name) + .includes(col.column_name), + ), + [optionSelector.values, options], + ); + const valuesRenderer = useCallback( () => - optionSelector.values.map((column, idx) => ( - - )), + optionSelector.values.map((column, idx) => + isFeatureEnabled(FeatureFlag.ENABLE_DND_WITH_CLICK_UX) ? ( + { + optionSelector.replace(idx, newColumn.column_name); + onChange(optionSelector.getValues()); + }} + editedColumn={column} + > + + + ) : ( + + ), + ), [ canDelete, label, name, + onChange, onClickClose, onShiftOptions, - optionSelector.values, + optionSelector, + popoverOptions, ], ); + const addNewColumnWithPopover = useCallback( + (newColumn: ColumnMeta) => { + optionSelector.add(newColumn.column_name); + onChange(optionSelector.getValues()); + }, + [onChange, optionSelector], + ); + + const togglePopover = useCallback((visible: boolean) => { + setNewColumnPopoverVisible(visible); + }, []); + + const closePopover = useCallback(() => { + togglePopover(false); + }, [togglePopover]); + + const openPopover = useCallback(() => { + togglePopover(true); + }, [togglePopover]); + + const defaultGhostButtonText = isFeatureEnabled( + FeatureFlag.ENABLE_DND_WITH_CLICK_UX, + ) + ? tn( + 'Drop a column here or click', + 'Drop columns here or click', + multi ? 2 : 1, + ) + : tn('Drop column here', 'Drop columns here', multi ? 2 : 1); + return ( - - onDrop={onDrop} - canDrop={canDrop} - valuesRenderer={valuesRenderer} - accept={DndItemType.Column} - displayGhostButton={multi || optionSelector.values.length === 0} - ghostButtonText={ - ghostButtonText || - tn('Drop column here', 'Drop columns here', multi ? 2 : 1) - } - {...props} - /> +
+ + onDrop={onDrop} + canDrop={canDrop} + valuesRenderer={valuesRenderer} + accept={DndItemType.Column} + displayGhostButton={multi || optionSelector.values.length === 0} + ghostButtonText={ghostButtonText || defaultGhostButtonText} + onClickGhostButton={ + isFeatureEnabled(FeatureFlag.ENABLE_DND_WITH_CLICK_UX) + ? openPopover + : undefined + } + {...props} + /> + +
+ +
); }; diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndFilterSelect.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndFilterSelect.tsx index 1e2eba8a6..97842f5bb 100644 --- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndFilterSelect.tsx +++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndFilterSelect.tsx @@ -333,6 +333,11 @@ export const DndFilterSelect = (props: DndFilterSelectProps) => { ], ); + const handleClickGhostButton = useCallback(() => { + setDroppedItem(null); + togglePopover(true); + }, [togglePopover]); + const adhocFilter = useMemo(() => { if (droppedItem?.metric_name) { return new AdhocFilter({ @@ -351,7 +356,7 @@ export const DndFilterSelect = (props: DndFilterSelectProps) => { const config: Partial = { subject: (droppedItem as ColumnMeta)?.column_name, }; - if (isFeatureEnabled(FeatureFlag.UX_BETA)) { + if (config.subject && isFeatureEnabled(FeatureFlag.UX_BETA)) { config.operator = OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.IN].operation; config.operatorId = Operators.IN; } @@ -367,6 +372,10 @@ export const DndFilterSelect = (props: DndFilterSelectProps) => { [togglePopover], ); + const ghostButtonText = isFeatureEnabled(FeatureFlag.ENABLE_DND_WITH_CLICK_UX) + ? t('Drop columns/metrics here or click') + : t('Drop columns or metrics here'); + return ( <> @@ -374,7 +383,12 @@ export const DndFilterSelect = (props: DndFilterSelectProps) => { canDrop={canDrop} valuesRenderer={valuesRenderer} accept={DND_ACCEPTED_TYPES} - ghostButtonText={t('Drop columns or metrics here')} + ghostButtonText={ghostButtonText} + onClickGhostButton={ + isFeatureEnabled(FeatureFlag.ENABLE_DND_WITH_CLICK_UX) + ? handleClickGhostButton + : undefined + } {...props} /> { [onNewMetric, togglePopover], ); + const handleClickGhostButton = useCallback(() => { + setDroppedItem(null); + togglePopover(true); + }, [togglePopover]); + const adhocMetric = useMemo(() => { if (droppedItem?.type === DndItemType.Column) { const itemValue = droppedItem?.value as ColumnMeta; @@ -334,6 +339,18 @@ export const DndMetricSelect = (props: any) => { 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 (
@@ -341,12 +358,13 @@ export const DndMetricSelect = (props: any) => { canDrop={canDrop} valuesRenderer={valuesRenderer} accept={DND_ACCEPTED_TYPES} - ghostButtonText={tn( - 'Drop column or metric here', - 'Drop columns or metrics here', - multi ? 2 : 1, - )} + ghostButtonText={ghostButtonText} displayGhostButton={multi || value.length === 0} + onClickGhostButton={ + isFeatureEnabled(FeatureFlag.ENABLE_DND_WITH_CLICK_UX) + ? handleClickGhostButton + : undefined + } {...props} /> ({ function renderGhostButton() { return ( - + {t(props.ghostButtonText || 'Drop columns here')} diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/types.ts b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/types.ts index 6a8a6876d..b77e9d263 100644 --- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/types.ts +++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/types.ts @@ -65,6 +65,7 @@ export interface DndColumnSelectProps< accept: DndItemType | DndItemType[]; ghostButtonText?: string; displayGhostButton?: boolean; + onClickGhostButton?: () => void; } export type OptionValueType = Record; diff --git a/superset/config.py b/superset/config.py index 58d9693f6..f18fcca8a 100644 --- a/superset/config.py +++ b/superset/config.py @@ -388,6 +388,7 @@ DEFAULT_FEATURE_FLAGS: Dict[str, bool] = { "OMNIBAR": False, "DASHBOARD_RBAC": False, "ENABLE_EXPLORE_DRAG_AND_DROP": False, + "ENABLE_DND_WITH_CLICK_UX": False, # Enabling ALERTS_ATTACH_REPORTS, the system sends email and slack message # with screenshot and link # Disables ALERTS_ATTACH_REPORTS, the system DOES NOT generate screenshot