blob: 2d8b19812e026afe5e8da324a6881e6b55bc42cc [file] [log] [blame]
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/* eslint-disable camelcase */
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useSelector } from 'react-redux';
import {
AdhocColumn,
isAdhocColumn,
t,
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';
import {
Button,
Form,
FormItem,
Select,
EmptyState,
} from '@superset-ui/core/components';
import sqlKeywords from 'src/SqlLab/utils/sqlKeywords';
import { getColumnKeywords } from 'src/explore/controlUtils/getColumnKeywords';
import { StyledColumnOption } from 'src/explore/components/optionRenderers';
import SQLEditorWithValidation from 'src/components/SQLEditorWithValidation';
import {
POPOVER_INITIAL_HEIGHT,
POPOVER_INITIAL_WIDTH,
} from 'src/explore/constants';
import { ExplorePageState } from 'src/explore/types';
import useResizeButton from './useResizeButton';
const TABS_KEYS = {
SAVED: 'saved',
SIMPLE: 'simple',
SQL_EXPRESSION: 'sqlExpression',
};
const StyledSelect = styled(Select)`
.metric-option {
& > svg {
min-width: ${({ theme }) => `${theme.sizeUnit * 4}px`};
}
& > .option-label {
overflow: hidden;
text-overflow: ellipsis;
}
}
`;
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 | Metric) => void;
onClose: () => void;
hasCustomLabel: boolean;
setLabel: (title: string) => void;
getCurrentTab: (tab: string) => void;
label: string;
isTemporal?: boolean;
setDatasetModal?: Dispatch<SetStateAction<boolean>>;
disabledTabs?: Set<string>;
metrics?: Metric[];
selectedMetrics?: QueryFormMetric[];
datasource?: any;
}
const getInitialColumnValues = (
editedColumn?: ColumnMeta | AdhocColumn,
): [AdhocColumn?, ColumnMeta?, ColumnMeta?] => {
if (!editedColumn) {
return [undefined, undefined, undefined];
}
if (isAdhocColumn(editedColumn)) {
return [editedColumn, undefined, undefined];
}
if (isSavedExpression(editedColumn)) {
return [undefined, editedColumn, undefined];
}
return [undefined, undefined, editedColumn];
};
const ColumnSelectPopover = ({
columns,
editedColumn,
getCurrentTab,
hasCustomLabel,
isTemporal,
label,
onChange,
onClose,
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,
);
const [initialLabel] = useState(label);
const [initialAdhocColumn, initialCalculatedColumn, initialSimpleColumn] =
getInitialColumnValues(editedColumn);
const [adhocColumn, setAdhocColumn] = useState<AdhocColumn | undefined>(
initialAdhocColumn,
);
const [selectedCalculatedColumn, setSelectedCalculatedColumn] = useState<
ColumnMeta | undefined
>(initialCalculatedColumn);
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(
POPOVER_INITIAL_WIDTH,
POPOVER_INITIAL_HEIGHT,
);
const sqlEditorRef = useRef(null);
const [calculatedColumns, simpleColumns] = useMemo(
() =>
columns?.reduce(
(acc: [ColumnMeta[], ColumnMeta[]], column: ColumnMeta) => {
if (column.expression) {
acc[0].push(column);
} else {
acc[1].push(column);
}
return acc;
},
[[], []],
),
[columns],
);
// 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],
);
const onCalculatedColumnChange = useCallback(
selectedColumnName => {
const selectedColumn = calculatedColumns.find(
col => col.column_name === selectedColumnName,
);
setSelectedCalculatedColumn(selectedColumn);
setSelectedSimpleColumn(undefined);
setSelectedMetric(undefined);
setAdhocColumn(undefined);
setLabel(
selectedColumn?.verbose_name || selectedColumn?.column_name || '',
);
},
[calculatedColumns, setLabel],
);
const onSimpleColumnChange = useCallback(
selectedColumnName => {
const selectedColumn = simpleColumns.find(
col => col.column_name === selectedColumnName,
);
setSelectedCalculatedColumn(undefined);
setSelectedSimpleColumn(selectedColumn);
setSelectedMetric(undefined);
setAdhocColumn(undefined);
setLabel(
selectedColumn?.verbose_name || selectedColumn?.column_name || '',
);
},
[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
? 'saved'
: 'simple';
useEffect(() => {
getCurrentTab(defaultActiveTabKey);
setSelectedTab(defaultActiveTabKey);
}, [defaultActiveTabKey, getCurrentTab, setSelectedTab]);
useEffect(() => {
/* if the adhoc column is not set (because it was never edited) but the
* tab is selected and the label has changed, then we need to set the
* adhoc column manually */
if (
adhocColumn === undefined &&
selectedTab === 'sqlExpression' &&
hasCustomLabel
) {
const sqlExpression =
selectedSimpleColumn?.column_name ||
selectedCalculatedColumn?.expression ||
'';
setAdhocColumn({ label, sqlExpression, expressionType: 'SQL' });
}
}, [
adhocColumn,
defaultActiveTabKey,
hasCustomLabel,
getCurrentTab,
label,
selectedCalculatedColumn,
selectedSimpleColumn,
selectedTab,
]);
const onSave = useCallback(() => {
if (adhocColumn && adhocColumn.label !== label) {
adhocColumn.label = label;
}
const selectedColumn =
adhocColumn || selectedCalculatedColumn || selectedSimpleColumn;
const selectedItem = selectedColumn || selectedMetric;
if (!selectedItem) {
return;
}
onChange(selectedItem);
onClose();
}, [
adhocColumn,
label,
onChange,
onClose,
selectedCalculatedColumn,
selectedSimpleColumn,
selectedMetric,
]);
const onResetStateAndClose = useCallback(() => {
setSelectedCalculatedColumn(initialCalculatedColumn);
setSelectedSimpleColumn(initialSimpleColumn);
setSelectedMetric(undefined);
setAdhocColumn(initialAdhocColumn);
onClose();
}, [
initialAdhocColumn,
initialCalculatedColumn,
initialSimpleColumn,
onClose,
]);
const onTabChange = useCallback(
tab => {
getCurrentTab(tab);
setSelectedTab(tab);
// @ts-ignore
sqlEditorRef.current?.editor.focus();
},
[getCurrentTab],
);
const setDatasetAndClose = () => {
if (setDatasetModal) {
setDatasetModal(true);
}
onClose();
};
const stateIsValid =
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('Columns and metrics');
const keywords = useMemo(
() => sqlKeywords.concat(getColumnKeywords(columns)),
[columns],
);
return (
<Form layout="vertical" id="metrics-edit-popover">
<Tabs
id="adhoc-metric-edit-tabs"
defaultActiveKey={defaultActiveTabKey}
onChange={onTabChange}
className="adhoc-metric-edit-tabs"
allowOverflow
css={css`
height: ${height}px;
width: ${width}px;
`}
items={[
// 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,
}),
)}
/>
</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')}
</>
) : (
<>
<span
role="button"
tabIndex={0}
onClick={setDatasetAndClose}
>
{t('Create a dataset')}
</span>{' '}
{t(' to add calculated columns')}
</>
)
}
/>
)}
</>
),
},
]),
{
key: TABS_KEYS.SIMPLE,
label: t('Simple'),
children: (
<>
{isTemporal && simpleColumns.length === 0 ? (
<EmptyState
image="empty.svg"
size="small"
title={t('No temporal columns found')}
description={
datasourceType === DatasourceType.Table ? (
t(
'Mark a column as temporal in "Edit datasource" modal',
)
) : (
<>
<span
role="button"
tabIndex={0}
onClick={setDatasetAndClose}
>
{t('Create a dataset')}
</span>{' '}
{t(' to mark a column as a time column')}
</>
)
}
/>
) : (
<FormItem label={simpleColumnsLabel}>
<Select
ariaLabel={simpleColumnsLabel}
value={
selectedSimpleColumn?.column_name ||
selectedMetric?.metric_name
}
onChange={onSimpleItemChange}
allowClear
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>
)}
</>
),
},
{
key: TABS_KEYS.SQL_EXPRESSION,
label: t('Custom SQL'),
disabled: disabledTabs.has('sqlExpression'),
children: (
<>
<SQLEditorWithValidation
value={
adhocColumn?.sqlExpression ||
selectedSimpleColumn?.column_name ||
selectedCalculatedColumn?.expression ||
''
}
ref={sqlEditorRef}
showLoadingForImport
onChange={onSqlExpressionChange}
width="100%"
height={`${height - 120}px`}
showGutter={false}
editorProps={{ $blockScrolling: true }}
enableLiveAutocompletion
className="filter-sql-editor"
wrapEnabled
keywords={keywords.map((k: any) =>
typeof k === 'string' ? k : k.value || k.name || k,
)}
showValidation
expressionType="column"
datasourceId={datasource?.id}
datasourceType={datasourceType}
/>
</>
),
},
]}
/>
<div>
<Button
buttonSize="small"
buttonStyle="secondary"
onClick={onResetStateAndClose}
cta
>
{t('Close')}
</Button>
<Button
disabled={!stateIsValid || !hasUnsavedChanges}
buttonStyle="primary"
buttonSize="small"
onClick={onSave}
data-test="ColumnEdit#save"
cta
>
{t('Save')}
</Button>
{resizeButton}
</div>
</Form>
);
};
export default ColumnSelectPopover;