feat: Implement drag and drop columns for filters (#13340)

* Implement DnD feature for filters

* minor refactor

* Fix types

* Fix undefined error

* Refactor

* Fix ts errors

* Fix conflicting dnd types

* Bump superset-ui packages

* Change DndItemType case to PascalCase

* Remove redundant null check

* Fix

* Fix csrf mock api call
This commit is contained in:
Kamil Gabryjelski 2021-03-07 10:54:08 +01:00 committed by GitHub
parent 3970d7316b
commit 7b370e6f17
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 1069 additions and 540 deletions

File diff suppressed because it is too large Load Diff

View File

@ -65,34 +65,34 @@
"@babel/runtime-corejs3": "^7.12.5",
"@data-ui/sparkline": "^0.0.84",
"@emotion/core": "^10.0.35",
"@superset-ui/chart-controls": "^0.17.13",
"@superset-ui/core": "^0.17.13",
"@superset-ui/legacy-plugin-chart-calendar": "^0.17.13",
"@superset-ui/legacy-plugin-chart-chord": "^0.17.13",
"@superset-ui/legacy-plugin-chart-country-map": "^0.17.13",
"@superset-ui/legacy-plugin-chart-event-flow": "^0.17.13",
"@superset-ui/legacy-plugin-chart-force-directed": "^0.17.13",
"@superset-ui/legacy-plugin-chart-heatmap": "^0.17.13",
"@superset-ui/legacy-plugin-chart-histogram": "^0.17.13",
"@superset-ui/legacy-plugin-chart-horizon": "^0.17.13",
"@superset-ui/legacy-plugin-chart-map-box": "^0.17.13",
"@superset-ui/legacy-plugin-chart-paired-t-test": "^0.17.13",
"@superset-ui/legacy-plugin-chart-parallel-coordinates": "^0.17.13",
"@superset-ui/legacy-plugin-chart-partition": "^0.17.13",
"@superset-ui/legacy-plugin-chart-pivot-table": "^0.17.13",
"@superset-ui/legacy-plugin-chart-rose": "^0.17.13",
"@superset-ui/legacy-plugin-chart-sankey": "^0.17.13",
"@superset-ui/legacy-plugin-chart-sankey-loop": "^0.17.13",
"@superset-ui/legacy-plugin-chart-sunburst": "^0.17.13",
"@superset-ui/legacy-plugin-chart-treemap": "^0.17.13",
"@superset-ui/legacy-plugin-chart-world-map": "^0.17.13",
"@superset-ui/legacy-preset-chart-big-number": "^0.17.13",
"@superset-ui/chart-controls": "^0.17.14",
"@superset-ui/core": "^0.17.14",
"@superset-ui/legacy-plugin-chart-calendar": "^0.17.14",
"@superset-ui/legacy-plugin-chart-chord": "^0.17.14",
"@superset-ui/legacy-plugin-chart-country-map": "^0.17.14",
"@superset-ui/legacy-plugin-chart-event-flow": "^0.17.14",
"@superset-ui/legacy-plugin-chart-force-directed": "^0.17.14",
"@superset-ui/legacy-plugin-chart-heatmap": "^0.17.14",
"@superset-ui/legacy-plugin-chart-histogram": "^0.17.14",
"@superset-ui/legacy-plugin-chart-horizon": "^0.17.14",
"@superset-ui/legacy-plugin-chart-map-box": "^0.17.14",
"@superset-ui/legacy-plugin-chart-paired-t-test": "^0.17.14",
"@superset-ui/legacy-plugin-chart-parallel-coordinates": "^0.17.14",
"@superset-ui/legacy-plugin-chart-partition": "^0.17.14",
"@superset-ui/legacy-plugin-chart-pivot-table": "^0.17.14",
"@superset-ui/legacy-plugin-chart-rose": "^0.17.14",
"@superset-ui/legacy-plugin-chart-sankey": "^0.17.14",
"@superset-ui/legacy-plugin-chart-sankey-loop": "^0.17.14",
"@superset-ui/legacy-plugin-chart-sunburst": "^0.17.14",
"@superset-ui/legacy-plugin-chart-treemap": "^0.17.14",
"@superset-ui/legacy-plugin-chart-world-map": "^0.17.14",
"@superset-ui/legacy-preset-chart-big-number": "^0.17.14",
"@superset-ui/legacy-preset-chart-deckgl": "^0.4.6",
"@superset-ui/legacy-preset-chart-nvd3": "^0.17.13",
"@superset-ui/plugin-chart-echarts": "^0.17.13",
"@superset-ui/plugin-chart-table": "^0.17.13",
"@superset-ui/plugin-chart-word-cloud": "^0.17.13",
"@superset-ui/preset-chart-xy": "^0.17.13",
"@superset-ui/legacy-preset-chart-nvd3": "^0.17.14",
"@superset-ui/plugin-chart-echarts": "^0.17.14",
"@superset-ui/plugin-chart-table": "^0.17.14",
"@superset-ui/plugin-chart-word-cloud": "^0.17.14",
"@superset-ui/preset-chart-xy": "^0.17.14",
"@vx/responsive": "^0.0.195",
"abortcontroller-polyfill": "^1.1.9",
"antd": "^4.9.4",

View File

@ -23,6 +23,6 @@ export default function setupSupersetClient() {
// The following is needed to mock out SupersetClient requests
// including CSRF authentication and initialization
global.FormData = window.FormData; // used by SupersetClient
fetchMock.get('glob:*superset/csrf_token/*', { csrf_token: '1234' });
fetchMock.get('glob:*/api/v1/security/csrf_token/*', { result: '1234' });
SupersetClient.configure({ protocol: 'http', host: 'localhost' }).init();
}

View File

@ -32,7 +32,7 @@ import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags';
import { ExploreActions } from 'src/explore/actions/exploreActions';
import Control from 'src/explore/components/Control';
import DatasourcePanelDragWrapper from './DatasourcePanelDragWrapper';
import { DatasourcePanelDndType } from './types';
import { DndItemType } from '../DndItemType';
interface DatasourceControl extends ControlConfig {
datasource?: DatasourceMeta;
@ -213,8 +213,8 @@ export default function DataSourcePanel({
<LabelContainer key={m.metric_name} className="column">
{enableExploreDnd ? (
<DatasourcePanelDragWrapper
metricOrColumnName={m.metric_name}
type={DatasourcePanelDndType.METRIC}
value={m}
type={DndItemType.Metric}
>
<MetricOption metric={m} showType />
</DatasourcePanelDragWrapper>
@ -235,8 +235,8 @@ export default function DataSourcePanel({
<LabelContainer key={col.column_name} className="column">
{enableExploreDnd ? (
<DatasourcePanelDragWrapper
metricOrColumnName={col.column_name}
type={DatasourcePanelDndType.COLUMN}
value={col}
type={DndItemType.Column}
>
<ColumnOption column={col} showType />
</DatasourcePanelDragWrapper>

View File

@ -42,7 +42,7 @@ export default function DatasourcePanelDragWrapper(
) {
const [, drag] = useDrag({
item: {
metricOrColumnName: props.metricOrColumnName,
value: props.value,
type: props.type,
},
});

View File

@ -16,15 +16,12 @@
* specific language governing permissions and limitations
* under the License.
*/
export enum DatasourcePanelDndType {
// todo: The new `metric` conflicts with the existing metric type
METRIC = 'datasource-panel-metric',
COLUMN = 'column',
}
import { ColumnMeta, Metric } from '@superset-ui/chart-controls';
import { DndItemType } from '../DndItemType';
export type DndItemValue = ColumnMeta | Metric;
export interface DatasourcePanelDndItem {
metricOrColumnName: string;
type:
| typeof DatasourcePanelDndType.METRIC
| typeof DatasourcePanelDndType.COLUMN;
value: DndItemValue;
type: DndItemType;
}

View File

@ -16,7 +16,25 @@
* specific language governing permissions and limitations
* under the License.
*/
export const OPTION_TYPES = {
metric: 'metric',
filter: 'filter',
};
/**
* All possible draggable items for the chart controls.
*/
export enum DndItemType {
// an existing column in table
Column = 'column',
// a selected column option in ColumnSelectControl
ColumnOption = 'columnOption',
// an adhoc column option in ColumnSelectControl
AdhocColumnOption = 'adhocColumn',
// a saved metric
Metric = 'metric',
// a selected saved metric in MetricsControl
MetricOption = 'metricOption',
// an adhoc metric option in MetricsControl
AdhocMetricOption = 'adhocMetric',
// an adhoc filter option
FilterOption = 'filterOption',
}

View File

@ -26,6 +26,7 @@ import {
import { Tooltip } from 'src/common/components/Tooltip';
import Icon from 'src/components/Icon';
import { savedMetricType } from 'src/explore/components/controls/MetricControl/types';
import AdhocMetric from './controls/MetricControl/AdhocMetric';
export const DragContainer = styled.div`
margin-bottom: ${({ theme }) => theme.gridUnit}px;
@ -35,7 +36,7 @@ export const DragContainer = styled.div`
`;
export const OptionControlContainer = styled.div<{
isAdhoc?: boolean;
withCaret?: boolean;
}>`
display: flex;
align-items: center;
@ -44,7 +45,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`
@ -159,10 +160,11 @@ interface DragItem {
export const OptionControlLabel = ({
label,
savedMetric,
adhocMetric,
onRemove,
onMoveLabel,
onDropLabel,
isAdhoc,
withCaret,
isFunction,
type,
index,
@ -171,10 +173,11 @@ export const OptionControlLabel = ({
}: {
label: string | React.ReactNode;
savedMetric?: savedMetricType;
adhocMetric?: AdhocMetric;
onRemove: () => void;
onMoveLabel: (dragIndex: number, hoverIndex: number) => void;
onDropLabel: () => void;
isAdhoc?: boolean;
withCaret?: boolean;
isFunction?: boolean;
isDraggable?: boolean;
type: string;
@ -231,7 +234,11 @@ export const OptionControlLabel = ({
},
});
const [, drag] = useDrag({
item: { type, index },
item: {
type,
index,
value: savedMetric?.metric_name ? savedMetric : adhocMetric,
},
collect: monitor => ({
isDragging: monitor.isDragging(),
}),
@ -246,7 +253,7 @@ export const OptionControlLabel = ({
const getOptionControlContent = () => (
<OptionControlContainer
isAdhoc={isAdhoc}
withCaret={withCaret}
data-test="option-label"
{...props}
>
@ -272,7 +279,7 @@ export const OptionControlLabel = ({
`)}
/>
)}
{isAdhoc && (
{withCaret && (
<CaretContainer>
<Icon name="caret-right" color={theme.colors.grayscale.light1} />
</CaretContainer>

View File

@ -0,0 +1,83 @@
/**
* 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 { ColumnMeta, ColumnOption } from '@superset-ui/chart-controls';
import { isEmpty } from 'lodash';
import { LabelProps } from './types';
import DndSelectLabel from './DndSelectLabel';
import OptionWrapper from './components/OptionWrapper';
import { OptionSelector } from './utils';
import { DatasourcePanelDndItem } from '../../DatasourcePanel/types';
import { DndItemType } from '../../DndItemType';
export const DndColumnSelect = (props: LabelProps) => {
const { value, options } = props;
const optionSelector = new OptionSelector(options, value);
const [values, setValues] = useState<ColumnMeta[]>(optionSelector.values);
const onDrop = (item: DatasourcePanelDndItem) => {
const column = item.value as ColumnMeta;
if (!optionSelector.isArray && !isEmpty(optionSelector.values)) {
optionSelector.replace(0, column.column_name);
} else {
optionSelector.add(column.column_name);
}
setValues(optionSelector.values);
props.onChange(optionSelector.getValues());
};
const canDrop = (item: DatasourcePanelDndItem) =>
!optionSelector.has((item.value as ColumnMeta).column_name);
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) => (
<OptionWrapper
key={idx}
index={idx}
clickClose={onClickClose}
onShiftOptions={onShiftOptions}
type={DndItemType.ColumnOption}
>
<ColumnOption column={column} showType />
</OptionWrapper>
));
return (
<DndSelectLabel<string | string[], ColumnMeta[]>
values={values}
onDrop={onDrop}
canDrop={canDrop}
valuesRenderer={valuesRenderer}
accept={DndItemType.Column}
{...props}
/>
);
};

View File

@ -1,119 +0,0 @@
/**
* 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 { 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,
DndLabelsContainer,
HeaderContainer,
} from 'src/explore/components/OptionControls';
import {
DatasourcePanelDndItem,
DatasourcePanelDndType,
} from 'src/explore/components/DatasourcePanel/types';
import Icon from 'src/components/Icon';
import OptionWrapper from './components/OptionWrapper';
import { OptionSelector } from './utils';
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) {
const theme = useTheme();
const { value, options } = props;
const optionSelector = new OptionSelector(options, value);
const [groupByOptions, setGroupByOptions] = useState<ColumnMeta[]>(
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());
},
canDrop: (item: DatasourcePanelDndItem) =>
!optionSelector.has(item.metricOrColumnName),
collect: monitor => ({
isOver: monitor.isOver(),
canDrop: monitor.canDrop(),
type: monitor.getItemType(),
}),
});
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 (
<AddControlLabel cancelHover>
<Icon name="plus-small" color={theme.colors.grayscale.light1} />
{t('Drop Columns')}
</AddControlLabel>
);
}
function renderOptions() {
return groupByOptions.map((column, idx) => (
<OptionWrapper
key={idx}
index={idx}
column={column}
clickClose={onClickClose}
onShiftOptions={onShiftOptions}
/>
));
}
return (
<div ref={datasourcePanelDrop}>
<HeaderContainer>
<ControlHeader {...props} />
</HeaderContainer>
<DndLabelsContainer canDrop={canDrop} isOver={isOver}>
{isEmpty(groupByOptions) ? renderPlaceHolder() : renderOptions()}
</DndLabelsContainer>
</div>
);
}

View File

@ -0,0 +1,334 @@
/**
* 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, useMemo, useState } from 'react';
import { logging, SupersetClient } 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 AdhocFilterPopoverTrigger from '../FilterControl/AdhocFilterPopoverTrigger';
import OptionWrapper from './components/OptionWrapper';
import DndSelectLabel from './DndSelectLabel';
import AdhocFilter, {
CLAUSES,
EXPRESSION_TYPES,
} from '../FilterControl/AdhocFilter';
import AdhocMetric from '../MetricControl/AdhocMetric';
import {
DatasourcePanelDndItem,
DndItemValue,
} from '../../DatasourcePanel/types';
import { DndItemType } from '../../DndItemType';
const isDictionaryForAdhocFilter = (value: FilterOptionValueType) =>
!(value instanceof AdhocFilter) && value?.expressionType;
export const DndFilterSelect = (props: DndFilterSelectProps) => {
const propsValues = Array.from(props.value ?? []);
const [values, setValues] = useState(
propsValues.map((filter: FilterOptionValueType) =>
isDictionaryForAdhocFilter(filter) ? new AdhocFilter(filter) : filter,
),
);
const [partitionColumn, setPartitionColumn] = useState(undefined);
const [newFilterPopoverVisible, setNewFilterPopoverVisible] = useState(false);
const [droppedItem, setDroppedItem] = useState<DndItemValue | null>(null);
const optionsForSelect = (
columns: ColumnMeta[],
formData: Record<string, any>,
) => {
const options: OptionSortType[] = [
...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: (OptionSortType & { filterOptionName: string })[],
option,
) => {
if ('saved_metric_name' in option && option.saved_metric_name) {
results.push({
...option,
filterOptionName: option.saved_metric_name,
});
} else if ('column_name' in option && 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 || '',
) ?? 0,
);
};
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 }: { json: Record<string, any> }) => {
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: Record<string, any>) => {
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: FilterOptionValueType) =>
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: FilterOptionValueType) => {
// already a AdhocFilter, skip
if (option instanceof AdhocFilter) {
return option;
}
const filterOptions = option as Record<string, any>;
// via datasource saved metric
if (filterOptions.saved_metric_name) {
return new AdhocFilter({
expressionType:
props.datasource.type === 'druid'
? EXPRESSION_TYPES.SIMPLE
: EXPRESSION_TYPES.SQL,
subject:
props.datasource.type === 'druid'
? filterOptions.saved_metric_name
: getMetricExpression(filterOptions.saved_metric_name),
operator: OPERATORS['>'],
comparator: 0,
clause: CLAUSES.HAVING,
});
}
// has a custom label, meaning it's custom column
if (filterOptions.label) {
return new AdhocFilter({
expressionType:
props.datasource.type === 'druid'
? EXPRESSION_TYPES.SIMPLE
: EXPRESSION_TYPES.SQL,
subject:
props.datasource.type === 'druid'
? filterOptions.label
: new AdhocMetric(option).translateToSql(),
operator: OPERATORS['>'],
comparator: 0,
clause: CLAUSES.HAVING,
});
}
// add a new filter item
if (filterOptions.column_name) {
return new AdhocFilter({
expressionType: EXPRESSION_TYPES.SIMPLE,
subject: filterOptions.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 (
<AdhocFilterPopoverTrigger
key={index}
adhocFilter={adhocFilter}
options={options}
datasource={props.datasource}
onFilterEdit={onFilterEdit}
partitionColumn={partitionColumn}
>
<OptionWrapper
key={index}
index={index}
clickClose={onClickClose}
onShiftOptions={onShiftOptions}
type={DndItemType.FilterOption}
withCaret
>
<Tooltip title={label}>{label}</Tooltip>
</OptionWrapper>
</AdhocFilterPopoverTrigger>
);
});
const adhocFilter = useMemo(() => {
if (droppedItem?.metric_name) {
return new AdhocFilter({
expressionType: EXPRESSION_TYPES.SQL,
clause: CLAUSES.HAVING,
sqlExpression: droppedItem?.expression,
});
}
if (droppedItem instanceof AdhocMetric) {
return new AdhocFilter({
expressionType: EXPRESSION_TYPES.SQL,
clause: CLAUSES.HAVING,
sqlExpression: (droppedItem as AdhocMetric)?.translateToSql(),
});
}
return new AdhocFilter({
subject: (droppedItem as ColumnMeta)?.column_name,
});
}, [droppedItem]);
return (
<>
<DndSelectLabel<FilterOptionValueType, FilterOptionValueType[]>
values={values}
onDrop={(item: DatasourcePanelDndItem) => {
setDroppedItem(item.value);
togglePopover(true);
}}
canDrop={() => true}
valuesRenderer={valuesRenderer}
accept={[
DndItemType.Column,
DndItemType.Metric,
DndItemType.MetricOption,
DndItemType.AdhocMetricOption,
]}
{...props}
/>
<AdhocFilterPopoverTrigger
adhocFilter={adhocFilter}
options={options}
datasource={props.datasource}
onFilterEdit={onNewFilter}
partitionColumn={partitionColumn}
isControlledComponent
visible={newFilterPopoverVisible}
togglePopover={togglePopover}
closePopover={closePopover}
createNew
>
<div />
</AdhocFilterPopoverTrigger>
</>
);
};

View File

@ -0,0 +1,73 @@
/**
* 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 from 'react';
import { useDrop } from 'react-dnd';
import { isEmpty } from 'lodash';
import { t, useTheme } from '@superset-ui/core';
import ControlHeader from 'src/explore/components/ControlHeader';
import {
AddControlLabel,
DndLabelsContainer,
HeaderContainer,
} from 'src/explore/components/OptionControls';
import { DatasourcePanelDndItem } from 'src/explore/components/DatasourcePanel/types';
import Icon from 'src/components/Icon';
import { DndColumnSelectProps } from './types';
export default function DndSelectLabel<T, O>(
props: DndColumnSelectProps<T, O>,
) {
const theme = useTheme();
const [{ isOver, canDrop }, datasourcePanelDrop] = useDrop({
accept: props.accept,
drop: (item: DatasourcePanelDndItem) => {
props.onDrop(item);
},
canDrop: (item: DatasourcePanelDndItem) => props.canDrop(item),
collect: monitor => ({
isOver: monitor.isOver(),
canDrop: monitor.canDrop(),
type: monitor.getItemType(),
}),
});
function renderPlaceHolder() {
return (
<AddControlLabel cancelHover>
<Icon name="plus-small" color={theme.colors.grayscale.light1} />
{t('Drop Columns')}
</AddControlLabel>
);
}
return (
<div ref={datasourcePanelDrop}>
<HeaderContainer>
<ControlHeader {...props} />
</HeaderContainer>
<DndLabelsContainer canDrop={canDrop} isOver={isOver}>
{isEmpty(props.values) ? renderPlaceHolder() : props.valuesRenderer()}
</DndLabelsContainer>
</div>
);
}

View File

@ -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 (
<OptionControlContainer data-test="option-label">
<OptionControlContainer
data-test="option-label"
withCaret={props.withCaret}
>
<CloseContainer
role="button"
data-test="remove-control-button"
@ -40,9 +42,7 @@ export default function Option(props: OptionProps) {
>
<Icon name="x-small" color={theme.colors.grayscale.light1} />
</CloseContainer>
<Label data-test="control-label">
<ColumnOption column={props.column} showType />
</Label>
<Label data-test="control-label">{props.children}</Label>
{props.withCaret && (
<CaretContainer>
<Icon name="caret-right" color={theme.colors.grayscale.light1} />

View File

@ -25,15 +25,25 @@ import {
} from 'react-dnd';
import { DragContainer } from 'src/explore/components/OptionControls';
import Option from './Option';
import { OptionProps, GroupByItemInterface, GroupByItemType } from '../types';
import { OptionProps, OptionItemInterface } from '../types';
import { DndItemType } from '../../../DndItemType';
export default function OptionWrapper(props: OptionProps) {
const { index, onShiftOptions } = props;
export default function OptionWrapper(
props: OptionProps & { type: DndItemType },
) {
const {
index,
type,
onShiftOptions,
clickClose,
withCaret,
children,
} = props;
const ref = useRef<HTMLDivElement>(null);
const item: GroupByItemInterface = {
const item: OptionItemInterface = {
dragIndex: index,
type: GroupByItemType,
type,
};
const [, drag] = useDrag({
item,
@ -43,9 +53,9 @@ export default function OptionWrapper(props: OptionProps) {
});
const [, drop] = useDrop({
accept: GroupByItemType,
accept: type,
hover: (item: GroupByItemInterface, monitor: DropTargetMonitor) => {
hover: (item: OptionItemInterface, monitor: DropTargetMonitor) => {
if (!ref.current) {
return;
}
@ -89,8 +99,15 @@ export default function OptionWrapper(props: OptionProps) {
drag(drop(ref));
return (
<DragContainer ref={ref}>
<Option {...props} />
<DragContainer ref={ref} {...props}>
<Option
index={index}
clickClose={clickClose}
onShiftOptions={onShiftOptions}
withCaret={withCaret}
>
{children}
</Option>
</DragContainer>
);
}

View File

@ -16,4 +16,6 @@
* specific language governing permissions and limitations
* under the License.
*/
export { default } from './DndColumnSelectLabel';
export { default } from './DndSelectLabel';
export * from './DndColumnSelect';
export * from './DndFilterSelect';

View File

@ -16,19 +16,51 @@
* specific language governing permissions and limitations
* under the License.
*/
import { ColumnMeta } from '@superset-ui/chart-controls';
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';
export interface OptionProps {
column: ColumnMeta;
children: ReactNode;
index: number;
clickClose: (index: number) => void;
onShiftOptions: (dragIndex: number, hoverIndex: number) => void;
withCaret?: boolean;
}
export const GroupByItemType = 'groupByItem';
export interface GroupByItemInterface {
type: typeof GroupByItemType;
export interface OptionItemInterface {
type: string;
dragIndex: number;
}
export interface LabelProps<T = string[] | string> {
name: string;
value?: T;
onChange: (value?: T) => void;
options: { string: ColumnMeta };
}
export interface DndColumnSelectProps<
T = string[] | string,
O = string[] | string
> extends LabelProps<T> {
values?: O;
onDrop: (item: DatasourcePanelDndItem) => void;
canDrop: (item: DatasourcePanelDndItem) => boolean;
valuesRenderer: () => ReactNode;
accept: DndItemType | DndItemType[];
}
export type FilterOptionValueType = Record<string, any> | AdhocFilter;
export interface DndFilterSelectProps {
name: string;
value: FilterOptionValueType[];
columns: ColumnMeta[];
datasource: Record<string, any>;
formData: Record<string, any>;
savedMetrics: Metric[];
onChange: (filters: FilterOptionValueType[]) => void;
options: { string: ColumnMeta };
}

View File

@ -19,7 +19,7 @@
import { ColumnMeta } from '@superset-ui/chart-controls';
export class OptionSelector {
groupByOptions: ColumnMeta[];
values: ColumnMeta[];
options: { string: ColumnMeta };
@ -27,18 +27,18 @@ export class OptionSelector {
constructor(
options: { string: ColumnMeta },
values: string[] | string | null,
initialValues?: string[] | string,
) {
this.options = options;
let groupByValues: string[];
if (Array.isArray(values)) {
groupByValues = values;
let values: string[];
if (Array.isArray(initialValues)) {
values = initialValues;
this.isArray = true;
} else {
groupByValues = values ? [values] : [];
values = initialValues ? [initialValues] : [];
this.isArray = false;
}
this.groupByOptions = groupByValues
this.values = values
.map(value => {
if (value in options) {
return options[value];
@ -50,37 +50,32 @@ export class OptionSelector {
add(value: string) {
if (value in this.options) {
this.groupByOptions.push(this.options[value]);
this.values.push(this.options[value]);
}
}
del(idx: number) {
this.groupByOptions.splice(idx, 1);
this.values.splice(idx, 1);
}
replace(idx: number, value: string) {
if (this.groupByOptions[idx]) {
this.groupByOptions[idx] = this.options[value];
if (this.values[idx]) {
this.values[idx] = this.options[value];
}
}
swap(a: number, b: number) {
[this.groupByOptions[a], this.groupByOptions[b]] = [
this.groupByOptions[b],
this.groupByOptions[a],
];
[this.values[a], this.values[b]] = [this.values[b], this.values[a]];
}
has(groupBy: string): boolean {
return !!this.getValues()?.includes(groupBy);
}
getValues(): string[] | string | null {
getValues(): string[] | string | undefined {
if (!this.isArray) {
return this.groupByOptions.length > 0
? this.groupByOptions[0].column_name
: null;
return this.values.length > 0 ? this.values[0].column_name : undefined;
}
return this.groupByOptions.map(option => option.column_name);
return this.values.map(option => option.column_name);
}
}

View File

@ -21,9 +21,9 @@ import PropTypes from 'prop-types';
import columnType from 'src/explore/propTypes/columnType';
import adhocMetricType from 'src/explore/components/controls/MetricControl/adhocMetricType';
import { OptionControlLabel } from 'src/explore/components/OptionControls';
import { OPTION_TYPES } from 'src/explore/components/optionTypes';
import AdhocFilterPopoverTrigger from './AdhocFilterPopoverTrigger';
import AdhocFilter from './AdhocFilter';
import { DndItemType } from '../../DndItemType';
const propTypes = {
adhocFilter: PropTypes.instanceOf(AdhocFilter).isRequired,
@ -67,8 +67,8 @@ const AdhocFilterOption = ({
onMoveLabel={onMoveLabel}
onDropLabel={onDropLabel}
index={index}
type={OPTION_TYPES.filter}
isAdhoc
type={DndItemType.FilterOption}
withCaret
isExtra={adhocFilter.isExtra}
/>
</AdhocFilterPopoverTrigger>

View File

@ -18,21 +18,21 @@
*/
import React from 'react';
import Popover from 'src/common/components/Popover';
import columnType from 'src/explore/propTypes/columnType';
import adhocMetricType from 'src/explore/components/controls/MetricControl/adhocMetricType';
import { OptionSortType } from 'src/explore/types';
import AdhocFilterEditPopover from './AdhocFilterEditPopover';
import AdhocFilter from './AdhocFilter';
interface AdhocFilterPopoverTriggerProps {
adhocFilter: AdhocFilter;
options:
| typeof columnType[]
| { saved_metric_name: string }[]
| typeof adhocMetricType[];
options: OptionSortType[];
datasource: Record<string, any>;
onFilterEdit: () => void;
onFilterEdit: (editedFilter: AdhocFilter) => void;
partitionColumn?: string;
createNew?: boolean;
isControlledComponent?: boolean;
visible?: boolean;
togglePopover?: (visible: boolean) => void;
closePopover?: () => void;
}
interface AdhocFilterPopoverTriggerState {
@ -68,7 +68,19 @@ class AdhocFilterPopoverTrigger extends React.PureComponent<
}
render() {
const { adhocFilter } = this.props;
const { adhocFilter, isControlledComponent } = this.props;
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 = (
<AdhocFilterEditPopover
adhocFilter={adhocFilter}
@ -76,7 +88,7 @@ class AdhocFilterPopoverTrigger extends React.PureComponent<
datasource={this.props.datasource}
partitionColumn={this.props.partitionColumn}
onResize={this.onPopoverResize}
onClose={this.closePopover}
onClose={closePopover}
onChange={this.props.onFilterEdit}
/>
);
@ -86,9 +98,9 @@ class AdhocFilterPopoverTrigger 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}
destroyTooltipOnHide={this.props.createNew}
>
{this.props.children}

View File

@ -20,10 +20,10 @@ import React from 'react';
import PropTypes from 'prop-types';
import columnType from 'src/explore/propTypes/columnType';
import { OptionControlLabel } from 'src/explore/components/OptionControls';
import { OPTION_TYPES } from 'src/explore/components/optionTypes';
import AdhocMetric from './AdhocMetric';
import savedMetricType from './savedMetricType';
import AdhocMetricPopoverTrigger from './AdhocMetricPopoverTrigger';
import { DndItemType } from '../../DndItemType';
const propTypes = {
adhocMetric: PropTypes.instanceOf(AdhocMetric),
@ -73,13 +73,14 @@ class AdhocMetricOption extends React.PureComponent {
>
<OptionControlLabel
savedMetric={savedMetric}
adhocMetric={adhocMetric}
label={adhocMetric.label}
onRemove={this.onRemoveMetric}
onMoveLabel={onMoveLabel}
onDropLabel={onDropLabel}
index={index}
type={OPTION_TYPES.metric}
isAdhoc
type={DndItemType.AdhocMetricOption}
withCaret
isFunction
/>
</AdhocMetricPopoverTrigger>

View File

@ -20,11 +20,11 @@ import React from 'react';
import PropTypes from 'prop-types';
import columnType from 'src/explore/propTypes/columnType';
import { OptionControlLabel } from 'src/explore/components/OptionControls';
import { OPTION_TYPES } from 'src/explore/components/optionTypes';
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,
@ -88,7 +88,7 @@ export default function MetricDefinitionValue({
onRemove={onRemoveMetric}
onMoveLabel={onMoveLabel}
onDropLabel={onDropLabel}
type={OPTION_TYPES.metric}
type={DndItemType.FilterOption}
index={index}
isFunction
/>

View File

@ -39,7 +39,10 @@ import VizTypeControl from './VizTypeControl';
import MetricsControl from './MetricControl/MetricsControl';
import AdhocFilterControl from './FilterControl/AdhocFilterControl';
import FilterBoxItemControl from './FilterBoxItemControl';
import DndColumnSelectControl from './DndColumnSelectControl';
import DndColumnSelectControl, {
DndColumnSelect,
DndFilterSelect,
} from './DndColumnSelectControl';
const controlMap = {
AnnotationLayerControl,
@ -52,6 +55,8 @@ const controlMap = {
DatasourceControl,
DateFilterControl,
DndColumnSelectControl,
DndColumnSelect,
DndFilterSelect,
FixedOrMetricControl,
HiddenControl,
SelectAsyncControl,

View File

@ -16,7 +16,13 @@
* specific language governing permissions and limitations
* under the License.
*/
import { QueryData, QueryFormData, AnnotationData } from '@superset-ui/core';
import {
QueryData,
QueryFormData,
AnnotationData,
AdhocMetric,
} from '@superset-ui/core';
import { ColumnMeta } from '@superset-ui/chart-controls';
export { Slice, Chart } from 'src/types/Chart';
@ -45,3 +51,7 @@ export interface ChartState {
triggerQuery: boolean;
asyncJobId?: string;
}
export type OptionSortType = Partial<
ColumnMeta & AdhocMetric & { saved_metric_name: string }
>;