feat(custom-tooltip): custom tooltip on deck.gl charts (#34276)

This commit is contained in:
Richard Fogaca Nienkotter
2025-09-16 11:11:19 -03:00
committed by GitHub
parent bc6859a99d
commit a66737cb05
62 changed files with 4599 additions and 421 deletions

View File

@@ -34,6 +34,9 @@ import {
styled,
css,
DatasourceType,
Metric,
QueryFormMetric,
// useTheme,
} from '@superset-ui/core';
import { ColumnMeta, isSavedExpression } from '@superset-ui/chart-controls';
import Tabs from '@superset-ui/core/components/Tabs';
@@ -74,10 +77,24 @@ const StyledSelect = styled(Select)`
}
`;
const MetricOptionContainer = styled.div`
display: flex;
align-items: center;
`;
const MetricIcon = styled.span`
margin-right: ${({ theme }) => theme.sizeUnit * 2}px;
color: ${({ theme }) => theme.colorSuccess};
`;
const MetricLabel = styled.span`
color: ${({ theme }) => theme.colorText};
`;
export interface ColumnSelectPopoverProps {
columns: ColumnMeta[];
editedColumn?: ColumnMeta | AdhocColumn;
onChange: (column: ColumnMeta | AdhocColumn) => void;
onChange: (column: ColumnMeta | AdhocColumn | Metric) => void;
onClose: () => void;
hasCustomLabel: boolean;
setLabel: (title: string) => void;
@@ -86,6 +103,8 @@ export interface ColumnSelectPopoverProps {
isTemporal?: boolean;
setDatasetModal?: Dispatch<SetStateAction<boolean>>;
disabledTabs?: Set<string>;
metrics?: Metric[];
selectedMetrics?: QueryFormMetric[];
datasource?: any;
}
@@ -116,8 +135,11 @@ const ColumnSelectPopover = ({
setDatasetModal,
setLabel,
disabledTabs = new Set<'saved' | 'simple' | 'sqlExpression'>(),
metrics = [],
selectedMetrics = [],
datasource,
}: ColumnSelectPopoverProps) => {
// const theme = useTheme(); // Unused variable
const datasourceType = useSelector<ExplorePageState, string | undefined>(
state => state.explore.datasource.type,
);
@@ -134,6 +156,9 @@ const ColumnSelectPopover = ({
const [selectedSimpleColumn, setSelectedSimpleColumn] = useState<
ColumnMeta | undefined
>(initialSimpleColumn);
const [selectedMetric, setSelectedMetric] = useState<Metric | undefined>(
undefined,
);
const [selectedTab, setSelectedTab] = useState<string | null>(null);
const [resizeButton, width, height] = useResizeButton(
@@ -159,11 +184,31 @@ const ColumnSelectPopover = ({
[columns],
);
// Filter metrics that are already selected in the chart
const availableMetrics = useMemo(() => {
if (!metrics?.length) return [];
const selectedMetricsSet = new Set(selectedMetrics);
return metrics.filter(metric => selectedMetricsSet.has(metric.metric_name));
}, [metrics, selectedMetrics]);
const columnMap = useMemo(
() => Object.fromEntries(simpleColumns.map(col => [col.column_name, col])),
[simpleColumns],
);
const metricMap = useMemo(
() =>
Object.fromEntries(
availableMetrics.map(metric => [metric.metric_name, metric]),
),
[availableMetrics],
);
const onSqlExpressionChange = useCallback(
sqlExpression => {
setAdhocColumn({ label, sqlExpression, expressionType: 'SQL' });
setSelectedSimpleColumn(undefined);
setSelectedCalculatedColumn(undefined);
setSelectedMetric(undefined);
},
[label],
);
@@ -175,6 +220,7 @@ const ColumnSelectPopover = ({
);
setSelectedCalculatedColumn(selectedColumn);
setSelectedSimpleColumn(undefined);
setSelectedMetric(undefined);
setAdhocColumn(undefined);
setLabel(
selectedColumn?.verbose_name || selectedColumn?.column_name || '',
@@ -190,6 +236,7 @@ const ColumnSelectPopover = ({
);
setSelectedCalculatedColumn(undefined);
setSelectedSimpleColumn(selectedColumn);
setSelectedMetric(undefined);
setAdhocColumn(undefined);
setLabel(
selectedColumn?.verbose_name || selectedColumn?.column_name || '',
@@ -198,6 +245,38 @@ const ColumnSelectPopover = ({
[setLabel, simpleColumns],
);
const onSimpleMetricChange = useCallback(
selectedMetricName => {
const selectedMetric = availableMetrics.find(
metric => metric.metric_name === selectedMetricName,
);
setSelectedCalculatedColumn(undefined);
setSelectedSimpleColumn(undefined);
setSelectedMetric(selectedMetric);
setAdhocColumn(undefined);
setLabel(
selectedMetric?.verbose_name || selectedMetric?.metric_name || '',
);
},
[setLabel, availableMetrics],
);
const onSimpleItemChange = useCallback(
selectedValue => {
const selectedColumn = columnMap[selectedValue];
if (selectedColumn) {
onSimpleColumnChange(selectedValue);
return;
}
const selectedMetric = metricMap[selectedValue];
if (selectedMetric) {
onSimpleMetricChange(selectedValue);
}
},
[columnMap, metricMap, onSimpleColumnChange, onSimpleMetricChange],
);
const defaultActiveTabKey = initialAdhocColumn
? 'sqlExpression'
: selectedCalculatedColumn
@@ -241,10 +320,11 @@ const ColumnSelectPopover = ({
}
const selectedColumn =
adhocColumn || selectedCalculatedColumn || selectedSimpleColumn;
if (!selectedColumn) {
const selectedItem = selectedColumn || selectedMetric;
if (!selectedItem) {
return;
}
onChange(selectedColumn);
onChange(selectedItem);
onClose();
}, [
adhocColumn,
@@ -253,11 +333,13 @@ const ColumnSelectPopover = ({
onClose,
selectedCalculatedColumn,
selectedSimpleColumn,
selectedMetric,
]);
const onResetStateAndClose = useCallback(() => {
setSelectedCalculatedColumn(initialCalculatedColumn);
setSelectedSimpleColumn(initialSimpleColumn);
setSelectedMetric(undefined);
setAdhocColumn(initialAdhocColumn);
onClose();
}, [
@@ -285,16 +367,20 @@ const ColumnSelectPopover = ({
};
const stateIsValid =
adhocColumn || selectedCalculatedColumn || selectedSimpleColumn;
adhocColumn ||
selectedCalculatedColumn ||
selectedSimpleColumn ||
selectedMetric;
const hasUnsavedChanges =
initialLabel !== label ||
selectedCalculatedColumn?.column_name !==
initialCalculatedColumn?.column_name ||
selectedSimpleColumn?.column_name !== initialSimpleColumn?.column_name ||
selectedMetric?.metric_name !== undefined ||
adhocColumn?.sqlExpression !== initialAdhocColumn?.sqlExpression;
const savedExpressionsLabel = t('Saved expressions');
const simpleColumnsLabel = t('Column');
const simpleColumnsLabel = t('Columns and metrics');
const keywords = useMemo(
() => sqlKeywords.concat(getColumnKeywords(columns)),
[columns],
@@ -313,95 +399,103 @@ const ColumnSelectPopover = ({
width: ${width}px;
`}
items={[
{
key: TABS_KEYS.SAVED,
label: t('Saved'),
disabled: disabledTabs.has('saved'),
children: (
<>
{calculatedColumns.length > 0 ? (
<FormItem label={savedExpressionsLabel}>
<StyledSelect
ariaLabel={savedExpressionsLabel}
value={selectedCalculatedColumn?.column_name}
onChange={onCalculatedColumnChange}
allowClear
autoFocus={!selectedCalculatedColumn}
placeholder={t('%s column(s)', calculatedColumns.length)}
options={calculatedColumns.map(calculatedColumn => ({
value: calculatedColumn.column_name,
label: (
<StyledColumnOption
column={calculatedColumn}
showType
// Only show Saved tab if not disabled
...(disabledTabs.has('saved')
? []
: [
{
key: TABS_KEYS.SAVED,
label: t('Saved'),
children: (
<>
{calculatedColumns.length > 0 ? (
<FormItem label={savedExpressionsLabel}>
<StyledSelect
ariaLabel={savedExpressionsLabel}
value={selectedCalculatedColumn?.column_name}
onChange={onCalculatedColumnChange}
allowClear
autoFocus={!selectedCalculatedColumn}
placeholder={t(
'%s column(s)',
calculatedColumns.length,
)}
options={calculatedColumns.map(
calculatedColumn => ({
value: calculatedColumn.column_name,
label: (
<StyledColumnOption
column={calculatedColumn}
showType
/>
),
key: calculatedColumn.column_name,
}),
)}
/>
),
key: calculatedColumn.column_name,
}))}
/>
</FormItem>
) : datasourceType === DatasourceType.Table ? (
<EmptyState
image="empty.svg"
size="small"
title={
isTemporal
? t('No temporal columns found')
: t('No saved expressions found')
}
description={
isTemporal
? t(
'Add calculated temporal columns to dataset in "Edit datasource" modal',
)
: t(
'Add calculated columns to dataset in "Edit datasource" modal',
)
}
/>
) : (
<EmptyState
image="empty.svg"
size="small"
title={
isTemporal
? t('No temporal columns found')
: t('No saved expressions found')
}
description={
isTemporal ? (
<>
<span
role="button"
tabIndex={0}
onClick={setDatasetAndClose}
>
{t('Create a dataset')}
</span>{' '}
{t(' to mark a column as a time column')}
</>
</FormItem>
) : datasourceType === DatasourceType.Table ? (
<EmptyState
image="empty.svg"
size="small"
title={
isTemporal
? t('No temporal columns found')
: t('No saved expressions found')
}
description={
isTemporal
? t(
'Add calculated temporal columns to dataset in "Edit datasource" modal',
)
: t(
'Add calculated columns to dataset in "Edit datasource" modal',
)
}
/>
) : (
<>
<span
role="button"
tabIndex={0}
onClick={setDatasetAndClose}
>
{t('Create a dataset')}
</span>{' '}
{t(' to add calculated columns')}
</>
)
}
/>
)}
</>
),
},
<EmptyState
image="empty.svg"
size="small"
title={
isTemporal
? t('No temporal columns found')
: t('No saved expressions found')
}
description={
isTemporal ? (
<>
<span
role="button"
tabIndex={0}
onClick={setDatasetAndClose}
>
{t('Create a dataset')}
</span>{' '}
{t(' to mark a column as a time column')}
</>
) : (
<>
<span
role="button"
tabIndex={0}
onClick={setDatasetAndClose}
>
{t('Create a dataset')}
</span>{' '}
{t(' to add calculated columns')}
</>
)
}
/>
)}
</>
),
},
]),
{
key: TABS_KEYS.SIMPLE,
label: t('Simple'),
disabled: disabledTabs.has('simple'),
children: (
<>
{isTemporal && simpleColumns.length === 0 ? (
@@ -432,18 +526,41 @@ const ColumnSelectPopover = ({
<FormItem label={simpleColumnsLabel}>
<Select
ariaLabel={simpleColumnsLabel}
value={selectedSimpleColumn?.column_name}
onChange={onSimpleColumnChange}
value={
selectedSimpleColumn?.column_name ||
selectedMetric?.metric_name
}
onChange={onSimpleItemChange}
allowClear
autoFocus={!selectedSimpleColumn}
placeholder={t('%s column(s)', simpleColumns.length)}
options={simpleColumns.map(simpleColumn => ({
value: simpleColumn.column_name,
label: (
<StyledColumnOption column={simpleColumn} showType />
),
key: simpleColumn.column_name,
}))}
autoFocus={!selectedSimpleColumn && !selectedMetric}
placeholder={t(
'%s item(s)',
simpleColumns.length + availableMetrics.length,
)}
options={[
...simpleColumns.map(simpleColumn => ({
value: simpleColumn.column_name,
label: (
<StyledColumnOption
column={simpleColumn}
showType
/>
),
key: `column-${simpleColumn.column_name}`,
})),
...availableMetrics.map(metric => ({
value: metric.metric_name,
label: (
<MetricOptionContainer>
<MetricIcon>ƒ</MetricIcon>
<MetricLabel>
{metric.verbose_name || metric.metric_name}
</MetricLabel>
</MetricOptionContainer>
),
key: `metric-${metric.metric_name}`,
})),
]}
/>
</FormItem>
)}

View File

@@ -17,9 +17,15 @@
* under the License.
*/
import { useCallback, useEffect, useMemo, useState, ReactNode } from 'react';
import { useSelector } from 'react-redux';
import { AdhocColumn, t, isAdhocColumn } from '@superset-ui/core';
import {
AdhocColumn,
t,
isAdhocColumn,
Metric,
QueryFormMetric,
} from '@superset-ui/core';
import { ColumnMeta, isColumnMeta } from '@superset-ui/chart-controls';
import { ExplorePopoverContent } from 'src/explore/components/ExploreContentPopover';
import { SaveDatasetModal } from 'src/SqlLab/components/SaveDatasetModal';
@@ -27,6 +33,9 @@ import ColumnSelectPopover from './ColumnSelectPopover';
import { DndColumnSelectPopoverTitle } from './DndColumnSelectPopoverTitle';
import ControlPopover from '../ControlPopover/ControlPopover';
const defaultPopoverLabel = t('My column');
const editableTitleTab = 'sqlExpression';
interface ColumnSelectPopoverTriggerProps {
columns: ColumnMeta[];
editedColumn?: ColumnMeta | AdhocColumn;
@@ -38,12 +47,26 @@ interface ColumnSelectPopoverTriggerProps {
children: ReactNode;
isTemporal?: boolean;
disabledTabs?: Set<string>;
metrics?: Metric[];
selectedMetrics?: QueryFormMetric[];
}
const defaultPopoverLabel = t('My column');
const editableTitleTab = 'sqlExpression';
const ColumnSelectPopoverTriggerWrapper = (
props: ColumnSelectPopoverTriggerProps,
) => {
const datasource = useSelector(
(state: any) => state?.explore?.datasource || null,
);
const ColumnSelectPopoverTrigger = ({
return <ColumnSelectPopoverTriggerInner {...props} datasource={datasource} />;
};
interface ColumnSelectPopoverTriggerInnerProps
extends ColumnSelectPopoverTriggerProps {
datasource?: any;
}
const ColumnSelectPopoverTriggerInner = ({
columns,
editedColumn,
onColumnEdit,
@@ -51,10 +74,11 @@ const ColumnSelectPopoverTrigger = ({
children,
isTemporal,
disabledTabs,
metrics,
selectedMetrics,
datasource,
...props
}: ColumnSelectPopoverTriggerProps) => {
// @ts-ignore
const datasource = useSelector(state => state.explore.datasource);
}: ColumnSelectPopoverTriggerInnerProps) => {
const [popoverLabel, setPopoverLabel] = useState(defaultPopoverLabel);
const [popoverVisible, setPopoverVisible] = useState(false);
const [isTitleEditDisabled, setIsTitleEditDisabled] = useState(true);
@@ -112,6 +136,8 @@ const ColumnSelectPopoverTrigger = ({
getCurrentTab={getCurrentTab}
isTemporal={isTemporal}
disabledTabs={disabledTabs}
metrics={metrics}
selectedMetrics={selectedMetrics}
datasource={datasource}
/>
</ExplorePopoverContent>
@@ -126,29 +152,39 @@ const ColumnSelectPopoverTrigger = ({
onColumnEdit,
popoverLabel,
disabledTabs,
metrics,
selectedMetrics,
datasource,
],
);
const onLabelChange = useCallback(
(e: any) => {
(e: React.ChangeEvent<HTMLInputElement>) => {
setPopoverLabel(e.target.value);
setHasCustomLabel(true);
},
[setPopoverLabel, setHasCustomLabel],
);
const popoverTitle = useMemo(
() => (
const popoverTitle = useMemo(() => {
if (disabledTabs?.has('saved') && disabledTabs?.has('sqlExpression')) {
return <span>{t('Tooltip contents')}</span>;
}
return (
<DndColumnSelectPopoverTitle
title={popoverLabel}
onChange={onLabelChange}
isEditDisabled={isTitleEditDisabled}
hasCustomLabel={hasCustomLabel}
/>
),
[hasCustomLabel, isTitleEditDisabled, onLabelChange, popoverLabel],
);
);
}, [
hasCustomLabel,
isTitleEditDisabled,
onLabelChange,
popoverLabel,
disabledTabs,
]);
return (
<>
@@ -179,4 +215,4 @@ const ColumnSelectPopoverTrigger = ({
);
};
export default ColumnSelectPopoverTrigger;
export default ColumnSelectPopoverTriggerWrapper;

View File

@@ -0,0 +1,344 @@
/**
* 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 {
fireEvent,
render,
screen,
within,
} from 'spec/helpers/testing-library';
import { DndColumnMetricSelect } from 'src/explore/components/controls/DndColumnSelectControl/DndColumnMetricSelect';
import DatasourcePanelDragOption from '../../DatasourcePanel/DatasourcePanelDragOption';
import { DndItemType } from '../../DndItemType';
const defaultProps = {
name: 'test-control',
actions: {},
savedMetrics: [
{
metric_name: 'metric_a',
expression: 'expression_a',
verbose_name: 'metric_a',
},
{
metric_name: 'metric_b',
expression: 'expression_b',
verbose_name: 'Metric B',
},
],
columns: [
{
column_name: 'column_a',
},
{
column_name: 'column_b',
verbose_name: 'Column B',
},
],
selectedMetrics: [
{
metric_name: 'metric_a',
expression: 'expression_a',
verbose_name: 'metric_a',
},
{
metric_name: 'metric_b',
expression: 'expression_b',
verbose_name: 'Metric B',
},
],
onChange: () => {},
} as any;
test('renders with default props', () => {
render(<DndColumnMetricSelect {...defaultProps} />, {
useDnd: true,
useRedux: true,
});
expect(
screen.getByText('Drop columns/metrics here or click'),
).toBeInTheDocument();
});
test('renders with default props and multi = true', () => {
render(<DndColumnMetricSelect {...defaultProps} multi />, {
useDnd: true,
useRedux: true,
});
expect(
screen.getByText('Drop columns/metrics here or click'),
).toBeInTheDocument();
});
test('render selected columns and metrics correctly', () => {
const values = ['column_a', 'metric_a'];
render(<DndColumnMetricSelect {...defaultProps} value={values} multi />, {
useDnd: true,
useRedux: true,
});
expect(screen.getByText('column_a')).toBeVisible();
expect(screen.getByText('metric_a')).toBeVisible();
});
test('can drop columns and metrics', () => {
const values = ['column_a', 'metric_a'];
const { getByTestId } = render(
<>
<DatasourcePanelDragOption
value={{ column_name: 'column_b', uuid: '1' }}
type={DndItemType.Column}
/>
<DatasourcePanelDragOption
value={{ metric_name: 'metric_b', uuid: '2' }}
type={DndItemType.Metric}
/>
<DndColumnMetricSelect {...defaultProps} value={values} multi />
</>,
{
useDnd: true,
useRedux: true,
},
);
const columnOption = screen.getAllByTestId('DatasourcePanelDragOption')[0];
const metricOption = screen.getAllByTestId('DatasourcePanelDragOption')[1];
const currentSelection = getByTestId('dnd-labels-container');
fireEvent.dragStart(columnOption);
fireEvent.dragOver(currentSelection);
fireEvent.drop(currentSelection);
fireEvent.dragStart(metricOption);
fireEvent.dragOver(currentSelection);
fireEvent.drop(currentSelection);
expect(currentSelection).toBeInTheDocument();
});
test('cannot drop duplicate items', () => {
const values = ['column_a', 'metric_a'];
const { getByTestId } = render(
<>
<DatasourcePanelDragOption
value={{ column_name: 'column_a', uuid: '1' }}
type={DndItemType.Column}
/>
<DatasourcePanelDragOption
value={{ metric_name: 'metric_a', uuid: '2' }}
type={DndItemType.Metric}
/>
<DndColumnMetricSelect {...defaultProps} value={values} multi />
</>,
{
useDnd: true,
useRedux: true,
},
);
const columnOption = screen.getAllByTestId('DatasourcePanelDragOption')[0];
const metricOption = screen.getAllByTestId('DatasourcePanelDragOption')[1];
const currentSelection = getByTestId('dnd-labels-container');
const initialCount = currentSelection.children.length;
fireEvent.dragStart(columnOption);
fireEvent.dragOver(currentSelection);
fireEvent.drop(currentSelection);
fireEvent.dragStart(metricOption);
fireEvent.dragOver(currentSelection);
fireEvent.drop(currentSelection);
expect(currentSelection.children).toHaveLength(initialCount);
});
test('can drop only selected metrics', () => {
const values = ['column_a'];
const { getByTestId } = render(
<>
<DatasourcePanelDragOption
value={{ metric_name: 'metric_a', uuid: '1' }}
type={DndItemType.Metric}
/>
<DatasourcePanelDragOption
value={{ metric_name: 'metric_c', uuid: '2' }}
type={DndItemType.Metric}
/>
<DndColumnMetricSelect {...defaultProps} value={values} multi />
</>,
{
useDnd: true,
useRedux: true,
},
);
const selectedMetric = screen.getAllByTestId('DatasourcePanelDragOption')[0];
const unselectedMetric = screen.getAllByTestId(
'DatasourcePanelDragOption',
)[1];
const currentSelection = getByTestId('dnd-labels-container');
const initialCount = currentSelection.children.length;
fireEvent.dragStart(unselectedMetric);
fireEvent.dragOver(currentSelection);
fireEvent.drop(currentSelection);
expect(currentSelection.children).toHaveLength(initialCount);
fireEvent.dragStart(selectedMetric);
fireEvent.dragOver(currentSelection);
fireEvent.drop(currentSelection);
expect(currentSelection).toBeInTheDocument();
});
test('can drag and reorder items', async () => {
const values = ['column_a', 'metric_a', 'column_b'];
render(<DndColumnMetricSelect {...defaultProps} value={values} multi />, {
useDnd: true,
useRedux: true,
});
const container = screen.getByTestId('dnd-labels-container');
expect(container.childElementCount).toBe(4);
const firstItem = container.children[0] as HTMLElement;
const lastItem = container.children[2] as HTMLElement;
expect(within(firstItem).getByText('column_a')).toBeVisible();
expect(within(lastItem).getByText('Column B')).toBeVisible();
fireEvent.dragStart(firstItem);
fireEvent.dragEnter(lastItem);
fireEvent.dragOver(lastItem);
fireEvent.drop(lastItem);
expect(container).toBeInTheDocument();
});
test('shows warning for aggregated DeckGL charts', () => {
const values = ['column_a'];
const formData = { viz_type: 'deck_heatmap', datasource: 'test' };
render(
<DndColumnMetricSelect
{...defaultProps}
value={values}
multi
formData={formData}
/>,
{ useDnd: true, useRedux: true },
);
const columnItem = screen.getByText('column_a');
expect(columnItem).toBeVisible();
});
test('handles single selection mode', () => {
const values = ['column_a'];
const onChange = jest.fn();
render(
<DndColumnMetricSelect
{...defaultProps}
value={values}
multi={false}
onChange={onChange}
/>,
{ useDnd: true, useRedux: true },
);
expect(screen.getByText('column_a')).toBeVisible();
expect(
screen.queryByText('Drop columns/metrics here or click'),
).not.toBeInTheDocument();
});
test('handles custom ghost button text', () => {
const customText = 'Custom drop text';
render(
<DndColumnMetricSelect {...defaultProps} ghostButtonText={customText} />,
{ useDnd: true, useRedux: true },
);
expect(screen.getByText(customText)).toBeInTheDocument();
});
test('can remove items by clicking close button', () => {
const values = ['column_a', 'metric_a'];
const onChange = jest.fn();
render(
<DndColumnMetricSelect
{...defaultProps}
value={values}
multi
onChange={onChange}
/>,
{ useDnd: true, useRedux: true },
);
const closeButtons = screen.getAllByRole('button', { name: /close/i });
expect(closeButtons).toHaveLength(2);
fireEvent.click(closeButtons[0]);
expect(onChange).toHaveBeenCalledWith(['metric_a']);
});
test('handles adhoc metric with error', () => {
const errorMetric = {
metric_name: 'error_metric',
error_text: 'This metric has an error',
uuid: 'error-uuid',
};
const values = [errorMetric];
render(<DndColumnMetricSelect {...defaultProps} value={values} multi />, {
useDnd: true,
useRedux: true,
});
const metricItem = screen.getByText('error_metric');
expect(metricItem).toBeVisible();
});
test('handles adhoc column values', () => {
const values = ['column_a'];
render(<DndColumnMetricSelect {...defaultProps} value={values} multi />, {
useDnd: true,
useRedux: true,
});
expect(screen.getByText('column_a')).toBeVisible();
});
test('handles mixed value types correctly', () => {
const mixedValues = ['column_a', 'metric_a'];
render(
<DndColumnMetricSelect {...defaultProps} value={mixedValues} multi />,
{ useDnd: true, useRedux: true },
);
expect(screen.getByText('column_a')).toBeVisible();
expect(screen.getByText('metric_a')).toBeVisible();
});

View File

@@ -0,0 +1,448 @@
/**
* 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 { useCallback, useMemo, useState } from 'react';
import {
AdhocColumn,
tn,
t,
isAdhocColumn,
Metric,
ensureIsArray,
Datasource,
QueryFormMetric,
QueryFormData,
} from '@superset-ui/core';
import { ColumnMeta, isColumnMeta } from '@superset-ui/chart-controls';
import { isString } from 'lodash';
import DndSelectLabel from 'src/explore/components/controls/DndColumnSelectControl/DndSelectLabel';
import OptionWrapper from 'src/explore/components/controls/DndColumnSelectControl/OptionWrapper';
import { DatasourcePanelDndItem } from 'src/explore/components/DatasourcePanel/types';
import { DndItemType } from 'src/explore/components/DndItemType';
import AdhocMetric from 'src/explore/components/controls/MetricControl/AdhocMetric';
import MetricDefinitionValue from 'src/explore/components/controls/MetricControl/MetricDefinitionValue';
import ColumnSelectPopoverTrigger from './ColumnSelectPopoverTrigger';
import { DndControlProps } from './types';
const AGGREGATED_DECK_GL_CHART_TYPES = [
'deck_screengrid',
'deck_heatmap',
'deck_contour',
'deck_hex',
'deck_grid',
];
const MULTI_VALUE_WARNING_MESSAGE =
'This metric or column contains many values, they may not be able to be all displayed in the tooltip';
interface TooltipItem {
item_type?: string;
column_name?: string;
metric_name?: string;
label?: string;
}
function isAggregatedDeckGLChart(vizType: string): boolean {
return AGGREGATED_DECK_GL_CHART_TYPES.includes(vizType);
}
function fieldHasMultipleValues(
item: TooltipItem | string,
formData: QueryFormData,
): boolean {
if (!formData?.viz_type || !isAggregatedDeckGLChart(formData.viz_type)) {
return false;
}
if (typeof item === 'string') {
return true;
}
if (item.item_type === 'metric') {
return false;
}
if (item.item_type === 'column') {
return true;
}
return false;
}
const DND_ACCEPTED_TYPES = [DndItemType.Column, DndItemType.Metric];
type ColumnMetricValue =
| string
| AdhocColumn
| AdhocMetric
| { metric_name: string; error_text: string; uuid: string };
export type DndColumnMetricSelectProps = DndControlProps<ColumnMetricValue> & {
columns: ColumnMeta[];
savedMetrics: Metric[];
datasource?: Datasource;
selectedMetrics?: QueryFormMetric[];
isTemporal?: boolean;
disabledTabs?: Set<string>;
formData?: any;
};
const isDictionaryForAdhocMetric = (
value: unknown,
): value is Record<string, unknown> =>
value !== null &&
value !== undefined &&
!(value instanceof AdhocMetric) &&
typeof value === 'object' &&
'expressionType' in value;
function DndColumnMetricSelect(props: DndColumnMetricSelectProps) {
const {
value,
columns = [],
savedMetrics = [],
selectedMetrics = [],
datasource,
multi = true,
onChange,
canDelete = true,
ghostButtonText,
name,
label,
isTemporal,
disabledTabs,
formData,
} = props;
const [newColumnPopoverVisible, setNewColumnPopoverVisible] = useState(false);
const combinedOptionsMap = useMemo(() => {
const optionsMap: Record<string, ColumnMeta> = {};
columns.forEach(column => {
optionsMap[column.column_name] = column;
});
return optionsMap;
}, [columns]);
const isMetricSelected = useCallback(
(metricName: string) => {
if (!selectedMetrics || selectedMetrics.length === 0) {
return false;
}
return selectedMetrics.some(metric => {
if (isString(metric)) {
return metric === metricName;
}
if (metric instanceof AdhocMetric) {
return metric.label === metricName;
}
if (metric && typeof metric === 'object') {
if (metric instanceof AdhocMetric) {
return metric.label === metricName;
}
return 'metric_name' in metric && metric.metric_name === metricName;
}
return false;
});
},
[selectedMetrics],
);
const coercedValue = useMemo(() => {
if (!value) return [];
const valueArray = ensureIsArray(value);
return valueArray.map(item => {
if (isString(item) && combinedOptionsMap[item]) {
return item;
}
if (isString(item) && savedMetrics.some(m => m.metric_name === item)) {
return item;
}
if (isAdhocColumn(item)) {
return item;
}
if (isDictionaryForAdhocMetric(item)) {
return new AdhocMetric(item);
}
if (item instanceof AdhocMetric) {
return item;
}
return item;
});
}, [value, combinedOptionsMap, savedMetrics]);
const onDrop = useCallback(
(item: DatasourcePanelDndItem) => {
const newValues = [...coercedValue];
if (item.type === DndItemType.Column) {
const column = item.value as ColumnMeta;
if (!multi && newValues.length > 0) {
newValues[0] = column.column_name;
} else {
newValues.push(column.column_name);
}
}
if (item.type === DndItemType.Metric) {
const metric = item.value as Metric;
if (!multi && newValues.length > 0) {
newValues[0] = metric.metric_name;
} else {
newValues.push(metric.metric_name);
}
}
onChange(multi ? newValues : newValues[0]);
},
[onChange, coercedValue, multi],
);
const canDrop = useCallback(
(item: DatasourcePanelDndItem) => {
if (item.type === DndItemType.Column) {
const columnName = (item.value as ColumnMeta).column_name;
return (
columnName in combinedOptionsMap &&
!coercedValue.some(v => v === columnName)
);
}
if (item.type === DndItemType.Metric) {
const metric = item.value as Metric;
const metricName = metric.metric_name;
return (
isMetricSelected(metricName) &&
!coercedValue.some(v => v === metricName)
);
}
return false;
},
[combinedOptionsMap, coercedValue, isMetricSelected],
);
const onClickClose = useCallback(
(index: number) => {
const newValues = [...coercedValue];
newValues.splice(index, 1);
onChange(multi ? newValues : newValues[0]);
},
[onChange, coercedValue, multi],
);
const onShiftOptions = useCallback(
(dragIndex: number, hoverIndex: number) => {
const newValues = [...coercedValue];
[newValues[hoverIndex], newValues[dragIndex]] = [
newValues[dragIndex],
newValues[hoverIndex],
];
onChange(multi ? newValues : newValues[0]);
},
[onChange, coercedValue, multi],
);
const isColumnValue = (val: unknown): val is string =>
isString(val) && !!combinedOptionsMap[val];
const valuesRenderer = useCallback(
() =>
coercedValue.map((item, idx) => {
if (isColumnValue(item)) {
const column = combinedOptionsMap[item];
const datasourceWarningMessage = undefined;
const withCaret = true;
const columnItem = {
item_type: 'column',
column_name: item,
};
const hasMultipleValues =
formData && fieldHasMultipleValues(columnItem, formData);
const multiValueWarningMessage = hasMultipleValues
? MULTI_VALUE_WARNING_MESSAGE
: undefined;
return (
<ColumnSelectPopoverTrigger
key={`column-${idx}`}
columns={columns}
onColumnEdit={newColumn => {
const newValues = [...coercedValue];
if (isColumnMeta(newColumn)) {
newValues[idx] = newColumn.column_name;
} else {
newValues[idx] = newColumn;
}
onChange(multi ? newValues : newValues[0]);
}}
editedColumn={column}
isTemporal={isTemporal}
disabledTabs={disabledTabs}
>
<OptionWrapper
key={`column-${idx}`}
index={idx}
clickClose={onClickClose}
onShiftOptions={onShiftOptions}
type={`${DndItemType.ColumnOption}_${name}_${label}`}
canDelete={canDelete}
column={column}
datasourceWarningMessage={datasourceWarningMessage}
withCaret={withCaret}
multiValueWarningMessage={multiValueWarningMessage}
/>
</ColumnSelectPopoverTrigger>
);
}
const datasourceWarningMessage =
(item instanceof AdhocMetric && item.datasourceWarning) ||
(item &&
typeof item === 'object' &&
'error_text' in item &&
item.error_text)
? t('This metric might be incompatible with current dataset')
: undefined;
return (
<MetricDefinitionValue
key={`metric-${idx}`}
index={idx}
option={item}
onMetricEdit={(changedMetric: Metric | AdhocMetric) => {
const newValues = [...coercedValue];
if (changedMetric instanceof AdhocMetric) {
newValues[idx] = changedMetric;
} else {
newValues[idx] = (changedMetric as Metric).metric_name;
}
onChange(multi ? newValues : newValues[0]);
}}
onRemoveMetric={onClickClose}
columns={columns}
savedMetrics={savedMetrics}
savedMetricsOptions={savedMetrics}
datasource={datasource}
onMoveLabel={onShiftOptions}
onDropLabel={() => {}}
type={`${DndItemType.AdhocMetricOption}_${name}_${label}`}
multi={multi}
datasourceWarningMessage={datasourceWarningMessage}
/>
);
}),
[
coercedValue,
isColumnValue,
combinedOptionsMap,
columns,
onChange,
multi,
isTemporal,
disabledTabs,
onClickClose,
onShiftOptions,
name,
label,
canDelete,
savedMetrics,
datasource,
],
);
const labelGhostButtonText = useMemo(
() =>
ghostButtonText ??
tn(
'Drop a column/metric here or click',
'Drop columns/metrics here or click',
multi ? 2 : 1,
),
[ghostButtonText, multi],
);
const toggleColumnPopover = useCallback((visible: boolean) => {
setNewColumnPopoverVisible(visible);
}, []);
const closeColumnPopover = useCallback(() => {
toggleColumnPopover(false);
}, [toggleColumnPopover]);
const handleClickGhostButton = useCallback(() => {
toggleColumnPopover(true);
}, [toggleColumnPopover]);
const addNewColumn = useCallback(
(newItem: ColumnMeta | AdhocColumn | Metric | AdhocMetric) => {
const newValues = [...coercedValue];
if (isColumnMeta(newItem)) {
newValues.push(newItem.column_name);
} else if (isAdhocColumn(newItem)) {
newValues.push(newItem);
} else if ('metric_name' in newItem && newItem.metric_name) {
newValues.push(newItem.metric_name);
} else if (newItem instanceof AdhocMetric) {
newValues.push(newItem);
}
onChange(multi ? newValues : newValues[0]);
},
[onChange, coercedValue, multi],
);
return (
<div>
<DndSelectLabel
onDrop={onDrop}
canDrop={canDrop}
valuesRenderer={valuesRenderer}
accept={DND_ACCEPTED_TYPES}
displayGhostButton={multi || coercedValue.length === 0}
ghostButtonText={labelGhostButtonText}
onClickGhostButton={handleClickGhostButton}
{...props}
/>
<ColumnSelectPopoverTrigger
columns={columns}
onColumnEdit={addNewColumn}
isControlledComponent
visible={newColumnPopoverVisible}
togglePopover={toggleColumnPopover}
closePopover={closeColumnPopover}
isTemporal={false}
disabledTabs={disabledTabs}
metrics={savedMetrics}
selectedMetrics={selectedMetrics}
>
<div />
</ColumnSelectPopoverTrigger>
</div>
);
}
export { DndColumnMetricSelect };

View File

@@ -39,6 +39,7 @@ export default function Option({
isExtra,
datasourceWarningMessage,
canDelete = true,
multiValueWarningMessage,
}: OptionProps) {
const theme = useTheme();
const onClickClose = useCallback(
@@ -69,6 +70,13 @@ export default function Option({
</CloseContainer>
)}
<Label data-test="control-label">{children}</Label>
{!!multiValueWarningMessage && (
<StyledInfoTooltip
type="warning"
placement="top"
tooltip={multiValueWarningMessage}
/>
)}
{(!!datasourceWarningMessage || isExtra) && (
<StyledInfoTooltip
type="warning"

View File

@@ -60,6 +60,7 @@ export default function OptionWrapper(
datasourceWarningMessage,
canDelete = true,
tooltipOverlay,
multiValueWarningMessage,
...rest
} = props;
const ref = useRef<HTMLDivElement>(null);
@@ -188,6 +189,7 @@ export default function OptionWrapper(
isExtra={isExtra}
datasourceWarningMessage={datasourceWarningMessage}
canDelete={canDelete}
multiValueWarningMessage={multiValueWarningMessage}
>
<Label />
</Option>

View File

@@ -16,7 +16,19 @@
* specific language governing permissions and limitations
* under the License.
*/
/**
* Export DnD (Drag and Drop) control components for building interactive chart controls.
* These components enable users to configure visualizations by dragging and dropping
* available columns, metrics, and filters.
*
* DndSelectLabel: Base label component for DnD controls
* DndColumnSelect: Column selection control for choosing dataset columns
* DndFilterSelect: Filter selection control for data filtering
* DndMetricSelect: Metric selection control for aggregations
* DndColumnMetricSelect: Combined column and metric selection control
*/
export { default } from './DndSelectLabel';
export * from './DndColumnSelect';
export * from './DndFilterSelect';
export * from './DndMetricSelect';
export * from './DndColumnMetricSelect';

View File

@@ -33,6 +33,7 @@ export interface OptionProps {
datasourceWarningMessage?: string;
canDelete?: boolean;
tooltipOverlay?: ReactNode;
multiValueWarningMessage?: string;
}
export interface OptionItemInterface {