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
This commit is contained in:
Kamil Gabryjelski 2021-08-17 14:56:39 +02:00 committed by GitHub
parent 5e75baf0cc
commit 203c311ca3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 475 additions and 34 deletions

View File

@ -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 (
<Form layout="vertical" id="metrics-edit-popover">
<Tabs
id="adhoc-metric-edit-tabs"
defaultActiveKey={defaultActiveTabKey}
className="adhoc-metric-edit-tabs"
allowOverflow
>
<Tabs.TabPane key="saved" tab={t('Saved')}>
<FormItem label={t('Saved expressions')}>
<StyledSelect
value={selectedCalculatedColumn?.column_name}
getPopupContainer={getPopupContainer}
onChange={onCalculatedColumnChange}
allowClear
showSearch
autoFocus={!selectedCalculatedColumn}
filterOption={filterOption}
placeholder={t('%s column(s)', calculatedColumns.length)}
>
{calculatedColumns.map(calculatedColumn => (
<Select.Option
value={calculatedColumn.column_name}
filterBy={
calculatedColumn.verbose_name ||
calculatedColumn.column_name
}
key={calculatedColumn.column_name}
>
<StyledColumnOption column={calculatedColumn} showType />
</Select.Option>
))}
</StyledSelect>
</FormItem>
</Tabs.TabPane>
<Tabs.TabPane key="simple" tab={t('Simple')}>
<FormItem label={t('Column')}>
<Select
value={selectedSimpleColumn?.column_name}
getPopupContainer={getPopupContainer}
onChange={onSimpleColumnChange}
allowClear
showSearch
autoFocus={!selectedSimpleColumn}
filterOption={filterOption}
placeholder={t('%s column(s)', simpleColumns.length)}
>
{simpleColumns.map(simpleColumn => (
<Select.Option
value={simpleColumn.column_name}
filterBy={
simpleColumn.verbose_name || simpleColumn.column_name
}
key={simpleColumn.column_name}
>
<StyledColumnOption column={simpleColumn} showType />
</Select.Option>
))}
</Select>
</FormItem>
</Tabs.TabPane>
</Tabs>
<div>
<Button buttonSize="small" onClick={onResetStateAndClose} cta>
{t('Close')}
</Button>
<Button
disabled={!stateIsValid}
buttonStyle={
hasUnsavedChanges && stateIsValid ? 'primary' : 'default'
}
buttonSize="small"
onClick={onSave}
cta
>
{t('Save')}
</Button>
</div>
</Form>
);
};
export default ColumnSelectPopover;

View File

@ -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(
() => (
<ExplorePopoverContent>
<ColumnSelectPopover
editedColumn={editedColumn}
columns={columns}
onClose={handleClosePopover}
onChange={onColumnEdit}
/>
</ExplorePopoverContent>
),
[columns, editedColumn, handleClosePopover, onColumnEdit],
);
return (
<Popover
placement="right"
trigger="click"
content={overlayContent}
defaultVisible={visible}
visible={visible}
onVisibleChange={handleTogglePopover}
destroyTooltipOnHide
>
{children}
</Popover>
);
};
export default ColumnSelectPopoverTrigger;

View File

@ -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) => (
<OptionWrapper
key={idx}
index={idx}
clickClose={onClickClose}
onShiftOptions={onShiftOptions}
type={`${DndItemType.ColumnOption}_${name}_${label}`}
canDelete={canDelete}
column={column}
/>
)),
optionSelector.values.map((column, idx) =>
isFeatureEnabled(FeatureFlag.ENABLE_DND_WITH_CLICK_UX) ? (
<ColumnSelectPopoverTrigger
columns={popoverOptions}
onColumnEdit={newColumn => {
optionSelector.replace(idx, newColumn.column_name);
onChange(optionSelector.getValues());
}}
editedColumn={column}
>
<OptionWrapper
key={idx}
index={idx}
clickClose={onClickClose}
onShiftOptions={onShiftOptions}
type={`${DndItemType.ColumnOption}_${name}_${label}`}
canDelete={canDelete}
column={column}
withCaret
/>
</ColumnSelectPopoverTrigger>
) : (
<OptionWrapper
key={idx}
index={idx}
clickClose={onClickClose}
onShiftOptions={onShiftOptions}
type={`${DndItemType.ColumnOption}_${name}_${label}`}
canDelete={canDelete}
column={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 (
<DndSelectLabel<string | string[], ColumnMeta[]>
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}
/>
<div>
<DndSelectLabel<string | string[], ColumnMeta[]>
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}
/>
<ColumnSelectPopoverTrigger
columns={popoverOptions}
onColumnEdit={addNewColumnWithPopover}
isControlledComponent
togglePopover={togglePopover}
closePopover={closePopover}
visible={newColumnPopoverVisible}
>
<div />
</ColumnSelectPopoverTrigger>
</div>
);
};

View File

@ -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<AdhocFilter> = {
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 (
<>
<DndSelectLabel<OptionValueType, OptionValueType[]>
@ -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}
/>
<AdhocFilterPopoverTrigger

View File

@ -312,6 +312,11 @@ export const DndMetricSelect = (props: any) => {
[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 (
<div className="metrics-select">
<DndSelectLabel<OptionValueType, OptionValueType[]>
@ -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}
/>
<AdhocMetricPopoverTrigger

View File

@ -55,7 +55,10 @@ export default function DndSelectLabel<T, O>({
function renderGhostButton() {
return (
<AddControlLabel cancelHover>
<AddControlLabel
cancelHover={!props.onClickGhostButton}
onClick={props.onClickGhostButton}
>
<Icons.PlusSmall iconColor={theme.colors.grayscale.light1} />
{t(props.ghostButtonText || 'Drop columns here')}
</AddControlLabel>

View File

@ -65,6 +65,7 @@ export interface DndColumnSelectProps<
accept: DndItemType | DndItemType[];
ghostButtonText?: string;
displayGhostButton?: boolean;
onClickGhostButton?: () => void;
}
export type OptionValueType = Record<string, any>;

View File

@ -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