From 6d74a2aeb50fbfe3b77b7a63617955a6724ba2a2 Mon Sep 17 00:00:00 2001 From: Kamil Gabryjelski Date: Thu, 25 Feb 2021 15:33:25 +0100 Subject: [PATCH 01/12] Implement DnD feature for filters --- .../src/explore/components/OptionControls.tsx | 12 +- .../DndColumnSelectLabel.tsx | 57 +--- .../FilterColumnSelect.tsx | 298 ++++++++++++++++++ .../GroupByColumnSelect.tsx | 80 +++++ .../components/Option.tsx | 10 +- .../components/OptionWrapper.tsx | 27 +- .../controls/DndColumnSelectControl/index.ts | 2 + .../controls/DndColumnSelectControl/types.ts | 53 +++- .../utils/optionSelector.ts | 33 +- .../FilterControl/AdhocFilterOption.jsx | 2 +- .../AdhocFilterPopoverTrigger.tsx | 28 +- .../MetricControl/AdhocMetricOption.jsx | 2 +- .../src/explore/components/controls/index.js | 7 +- 13 files changed, 509 insertions(+), 102 deletions(-) create mode 100644 superset-frontend/src/explore/components/controls/DndColumnSelectControl/FilterColumnSelect.tsx create mode 100644 superset-frontend/src/explore/components/controls/DndColumnSelectControl/GroupByColumnSelect.tsx diff --git a/superset-frontend/src/explore/components/OptionControls.tsx b/superset-frontend/src/explore/components/OptionControls.tsx index d0053f41790a8..639fbbd64b1d0 100644 --- a/superset-frontend/src/explore/components/OptionControls.tsx +++ b/superset-frontend/src/explore/components/OptionControls.tsx @@ -35,7 +35,7 @@ export const DragContainer = styled.div` `; export const OptionControlContainer = styled.div<{ - isAdhoc?: boolean; + withCaret?: boolean; }>` display: flex; align-items: center; @@ -44,7 +44,7 @@ export const OptionControlContainer = styled.div<{ height: ${({ theme }) => theme.gridUnit * 6}px; background-color: ${({ theme }) => theme.colors.grayscale.light3}; border-radius: 3px; - cursor: ${({ isAdhoc }) => (isAdhoc ? 'pointer' : 'default')}; + cursor: ${({ withCaret }) => (withCaret ? 'pointer' : 'default')}; `; export const Label = styled.div` @@ -162,7 +162,7 @@ export const OptionControlLabel = ({ onRemove, onMoveLabel, onDropLabel, - isAdhoc, + withCaret, isFunction, type, index, @@ -174,7 +174,7 @@ export const OptionControlLabel = ({ onRemove: () => void; onMoveLabel: (dragIndex: number, hoverIndex: number) => void; onDropLabel: () => void; - isAdhoc?: boolean; + withCaret?: boolean; isFunction?: boolean; isDraggable?: boolean; type: string; @@ -246,7 +246,7 @@ export const OptionControlLabel = ({ const getOptionControlContent = () => ( @@ -272,7 +272,7 @@ export const OptionControlLabel = ({ `)} /> )} - {isAdhoc && ( + {withCaret && ( diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelectLabel.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelectLabel.tsx index a08ef9eddd4f5..fba79e7298dae 100644 --- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelectLabel.tsx +++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelectLabel.tsx @@ -16,11 +16,10 @@ * specific language governing permissions and limitations * under the License. */ -import React, { useState } from 'react'; +import React from 'react'; import { useDrop } from 'react-dnd'; import { isEmpty } from 'lodash'; import { t, useTheme } from '@superset-ui/core'; -import { BaseControlConfig, ColumnMeta } from '@superset-ui/chart-controls'; import ControlHeader from 'src/explore/components/ControlHeader'; import { AddControlLabel, @@ -32,39 +31,19 @@ import { DatasourcePanelDndType, } from 'src/explore/components/DatasourcePanel/types'; import Icon from 'src/components/Icon'; -import OptionWrapper from './components/OptionWrapper'; -import { OptionSelector } from './utils'; +import { DndColumnSelectProps } from './types'; -interface LabelProps extends BaseControlConfig { - name: string; - value: string[] | string | null; - onChange: (value: string[] | string | null) => void; - options: { string: ColumnMeta }; -} - -export default function DndColumnSelectLabel(props: LabelProps) { +export default function DndColumnSelectLabel(props: DndColumnSelectProps) { const theme = useTheme(); - const { value, options } = props; - const optionSelector = new OptionSelector(options, value); - const [groupByOptions, setGroupByOptions] = useState( - optionSelector.groupByOptions, - ); const [{ isOver, canDrop }, datasourcePanelDrop] = useDrop({ accept: DatasourcePanelDndType.COLUMN, drop: (item: DatasourcePanelDndItem) => { - if (!optionSelector.isArray && !isEmpty(optionSelector.groupByOptions)) { - optionSelector.replace(0, item.metricOrColumnName); - } else { - optionSelector.add(item.metricOrColumnName); - } - setGroupByOptions(optionSelector.groupByOptions); - props.onChange(optionSelector.getValues()); + props.onDrop(item); }, - canDrop: (item: DatasourcePanelDndItem) => - !optionSelector.has(item.metricOrColumnName), + canDrop: (item: DatasourcePanelDndItem) => props.canDrop(item), collect: monitor => ({ isOver: monitor.isOver(), @@ -73,18 +52,6 @@ export default function DndColumnSelectLabel(props: LabelProps) { }), }); - function onClickClose(index: number) { - optionSelector.del(index); - setGroupByOptions(optionSelector.groupByOptions); - props.onChange(optionSelector.getValues()); - } - - function onShiftOptions(dragIndex: number, hoverIndex: number) { - optionSelector.swap(dragIndex, hoverIndex); - setGroupByOptions(optionSelector.groupByOptions); - props.onChange(optionSelector.getValues()); - } - function renderPlaceHolder() { return ( @@ -94,25 +61,13 @@ export default function DndColumnSelectLabel(props: LabelProps) { ); } - function renderOptions() { - return groupByOptions.map((column, idx) => ( - - )); - } - return (
- {isEmpty(groupByOptions) ? renderPlaceHolder() : renderOptions()} + {isEmpty(props.values) ? renderPlaceHolder() : props.valuesRenderer()}
); diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/FilterColumnSelect.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/FilterColumnSelect.tsx new file mode 100644 index 0000000000000..f7d18c736266a --- /dev/null +++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/FilterColumnSelect.tsx @@ -0,0 +1,298 @@ +/** + * 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, { useEffect, useState } from 'react'; +import { logging, SupersetClient } from '@superset-ui/core'; +import { ColumnMeta, Metric } from '@superset-ui/chart-controls'; +import { + FilterColumnSelectProps, + FilterItemType, + OptionSortType, +} from './types'; +import AdhocFilterPopoverTrigger from '../FilterControl/AdhocFilterPopoverTrigger'; +import OptionWrapper from './components/OptionWrapper'; +import DndColumnSelectLabel from './DndColumnSelectLabel'; +import AdhocFilter, { + CLAUSES, + EXPRESSION_TYPES, +} from '../FilterControl/AdhocFilter'; +import AdhocMetric from '../MetricControl/AdhocMetric'; +import { Tooltip } from '../../../../common/components/Tooltip'; +import { OPERATORS } from '../../../constants'; + +const isDictionaryForAdhocFilter = (value: Record | AdhocFilter) => + value && !(value instanceof AdhocFilter) && value.expressionType; + +export const FilterColumnSelect = (props: FilterColumnSelectProps) => { + const propsValues = Array.from(props.value ?? []); + const [values, setValues] = useState( + propsValues.map((filter: Record | AdhocFilter) => + isDictionaryForAdhocFilter(filter) ? new AdhocFilter(filter) : filter, + ), + ); + const [partitionColumn, setPartitionColumn] = useState(undefined); + const [newFilterPopoverVisible, setNewFilterPopoverVisible] = useState(false); + const [droppedItem, setDroppedItem] = useState(null); + + const optionsForSelect = ( + columns: ColumnMeta[], + formData: Record, + ) => { + const options = [ + ...columns, + ...[...(formData.metrics || []), formData.metric].map( + metric => + metric && + (typeof metric === 'string' + ? { saved_metric_name: metric } + : new AdhocMetric(metric)), + ), + ].filter(option => option); + + return options + .reduce((results, option) => { + if (option.saved_metric_name) { + results.push({ + ...option, + filterOptionName: option.saved_metric_name, + }); + } else if (option.column_name) { + results.push({ + ...option, + filterOptionName: `_col_${option.column_name}`, + }); + } else if (option instanceof AdhocMetric) { + results.push({ + ...option, + filterOptionName: `_adhocmetric_${option.label}`, + }); + } + return results; + }, []) + .sort((a: OptionSortType, b: OptionSortType) => + (a.saved_metric_name || a.column_name || a.label).localeCompare( + b.saved_metric_name || b.column_name || b.label, + ), + ); + }; + const [options, setOptions] = useState( + optionsForSelect(props.columns, props.formData), + ); + + useEffect(() => { + const { datasource } = props; + if (datasource && datasource.type === 'table') { + const dbId = datasource.database?.id; + const { + datasource_name: name, + schema, + is_sqllab_view: isSqllabView, + } = datasource; + + if (!isSqllabView && dbId && name && schema) { + SupersetClient.get({ + endpoint: `/superset/extra_table_metadata/${dbId}/${name}/${schema}/`, + }) + .then(({ json }) => { + if (json && json.partitions) { + const { partitions } = json; + // for now only show latest_partition option + // when table datasource has only 1 partition key. + if ( + partitions && + partitions.cols && + Object.keys(partitions.cols).length === 1 + ) { + setPartitionColumn(partitions.cols[0]); + } + } + }) + .catch(error => { + logging.error('fetch extra_table_metadata:', error.statusText); + }); + } + } + }, []); + + useEffect(() => { + setOptions(optionsForSelect(props.columns, props.formData)); + }, [props.columns, props.formData]); + + useEffect(() => { + setValues( + (props.value || []).map((filter: Record | AdhocFilter) => + isDictionaryForAdhocFilter(filter) ? new AdhocFilter(filter) : filter, + ), + ); + }, [props.value]); + + const onClickClose = (index: number) => { + const valuesCopy = [...values]; + valuesCopy.splice(index, 1); + setValues(valuesCopy); + props.onChange(valuesCopy); + }; + + const onShiftOptions = (dragIndex: number, hoverIndex: number) => { + const newValues = [...values]; + [newValues[hoverIndex], newValues[dragIndex]] = [ + newValues[dragIndex], + newValues[hoverIndex], + ]; + setValues(newValues); + }; + + const getMetricExpression = (savedMetricName: string) => + props.savedMetrics.find( + (savedMetric: Metric) => savedMetric.metric_name === savedMetricName, + ).expression; + + const mapOption = (option: AdhocFilter | Record) => { + // already a AdhocFilter, skip + if (option instanceof AdhocFilter) { + return option; + } + // via datasource saved metric + if (option.saved_metric_name) { + return new AdhocFilter({ + expressionType: + props.datasource.type === 'druid' + ? EXPRESSION_TYPES.SIMPLE + : EXPRESSION_TYPES.SQL, + subject: + props.datasource.type === 'druid' + ? option.saved_metric_name + : getMetricExpression(option.saved_metric_name), + operator: OPERATORS['>'], + comparator: 0, + clause: CLAUSES.HAVING, + }); + } + // has a custom label, meaning it's custom column + if (option.label) { + return new AdhocFilter({ + expressionType: + props.datasource.type === 'druid' + ? EXPRESSION_TYPES.SIMPLE + : EXPRESSION_TYPES.SQL, + subject: + props.datasource.type === 'druid' + ? option.label + : new AdhocMetric(option).translateToSql(), + operator: OPERATORS['>'], + comparator: 0, + clause: CLAUSES.HAVING, + }); + } + // add a new filter item + if (option.column_name) { + return new AdhocFilter({ + expressionType: EXPRESSION_TYPES.SIMPLE, + subject: option.column_name, + operator: OPERATORS['=='], + comparator: '', + clause: CLAUSES.WHERE, + isNew: true, + }); + } + return null; + }; + + const onFilterEdit = (changedFilter: AdhocFilter) => { + props.onChange( + values.map((value: AdhocFilter) => { + if (value.filterOptionName === changedFilter.filterOptionName) { + return changedFilter; + } + return value; + }), + ); + }; + + const onNewFilter = (newFilter: AdhocFilter) => { + const mappedOption = mapOption(newFilter); + if (mappedOption) { + const newValues = [...values, mappedOption]; + setValues(newValues); + props.onChange(newValues); + } + }; + + const togglePopover = (visible: boolean) => { + setNewFilterPopoverVisible(visible); + }; + + const closePopover = () => { + togglePopover(false); + }; + + const valuesRenderer = () => + values.map((adhocFilter: AdhocFilter, index: number) => { + const label = adhocFilter.getDefaultLabel(); + return ( + + + {label} + + + ); + }); + + return ( + <> + { + setDroppedItem(item.metricOrColumnName); + togglePopover(true); + }} + canDrop={() => true} + valuesRenderer={valuesRenderer} + {...props} + /> + +
+ + + ); +}; diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/GroupByColumnSelect.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/GroupByColumnSelect.tsx new file mode 100644 index 0000000000000..3c368a8d93a53 --- /dev/null +++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/GroupByColumnSelect.tsx @@ -0,0 +1,80 @@ +/** + * 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, { useState } from 'react'; +import { ColumnOption, ColumnMeta } from '@superset-ui/chart-controls'; +import { isEmpty } from 'lodash'; +import { GroupByItemType, LabelProps } from './types'; +import DndColumnSelectLabel from './DndColumnSelectLabel'; +import OptionWrapper from './components/OptionWrapper'; +import { OptionSelector } from './utils'; +import { DatasourcePanelDndItem } from '../../DatasourcePanel/types'; + +export const GroupByColumnSelect = (props: LabelProps) => { + const { value, options } = props; + const optionSelector = new OptionSelector(options, value); + const [values, setValues] = useState(optionSelector.values); + + const onDrop = (item: DatasourcePanelDndItem) => { + if (!optionSelector.isArray && !isEmpty(optionSelector.values)) { + optionSelector.replace(0, item.metricOrColumnName); + } else { + optionSelector.add(item.metricOrColumnName); + } + setValues(optionSelector.values); + props.onChange(optionSelector.getValues()); + }; + + const canDrop = (item: DatasourcePanelDndItem) => + !optionSelector.has(item.metricOrColumnName); + + const onClickClose = (index: number) => { + optionSelector.del(index); + setValues(optionSelector.values); + props.onChange(optionSelector.getValues()); + }; + + const onShiftOptions = (dragIndex: number, hoverIndex: number) => { + optionSelector.swap(dragIndex, hoverIndex); + setValues(optionSelector.values); + props.onChange(optionSelector.getValues()); + }; + + const valuesRenderer = () => + values.map((column, idx) => ( + + + + )); + + return ( + + ); +}; diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/components/Option.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/components/Option.tsx index 22cbb037a13a8..ce7cba099eefb 100644 --- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/components/Option.tsx +++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/components/Option.tsx @@ -18,7 +18,6 @@ */ import React from 'react'; import { useTheme } from '@superset-ui/core'; -import { ColumnOption } from '@superset-ui/chart-controls'; import Icon from 'src/components/Icon'; import { CaretContainer, @@ -32,7 +31,10 @@ export default function Option(props: OptionProps) { const theme = useTheme(); return ( - + - + {props.withCaret && ( diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/components/OptionWrapper.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/components/OptionWrapper.tsx index 13e18aadcfb0a..a09764e66c296 100644 --- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/components/OptionWrapper.tsx +++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/components/OptionWrapper.tsx @@ -25,13 +25,20 @@ import { } from 'react-dnd'; import { DragContainer } from 'src/explore/components/OptionControls'; import Option from './Option'; -import { OptionProps, GroupByItemInterface, GroupByItemType } from '../types'; +import { OptionProps, OptionItemInterface, GroupByItemType } from '../types'; export default function OptionWrapper(props: OptionProps) { - const { index, onShiftOptions } = props; + const { + index, + onShiftOptions, + clickClose, + type, + withCaret, + children, + } = props; const ref = useRef(null); - const item: GroupByItemInterface = { + const item: OptionItemInterface = { dragIndex: index, type: GroupByItemType, }; @@ -45,7 +52,7 @@ export default function OptionWrapper(props: OptionProps) { const [, drop] = useDrop({ accept: GroupByItemType, - hover: (item: GroupByItemInterface, monitor: DropTargetMonitor) => { + hover: (item: OptionItemInterface, monitor: DropTargetMonitor) => { if (!ref.current) { return; } @@ -89,8 +96,16 @@ export default function OptionWrapper(props: OptionProps) { drag(drop(ref)); return ( - -