blob: 9e30cef49046ea2b24eb390b35cedbd3f2173841 [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 camelcase: 0 */
import {
isValidElement,
ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
ensureIsArray,
t,
styled,
getChartControlPanelRegistry,
QueryFormData,
DatasourceType,
css,
SupersetTheme,
useTheme,
isDefined,
JsonValue,
NO_TIME_RANGE,
usePrevious,
isFeatureEnabled,
FeatureFlag,
} from '@superset-ui/core';
import {
ControlPanelSectionConfig,
ControlState,
CustomControlItem,
Dataset,
ExpandedControlItem,
isCustomControlItem,
isTemporalColumn,
sections,
} from '@superset-ui/chart-controls';
import { useSelector } from 'react-redux';
import { kebabCase, isEqual } from 'lodash';
import {
Collapse,
Modal,
Loading,
Label,
Tooltip,
} from '@superset-ui/core/components';
import Tabs from '@superset-ui/core/components/Tabs';
import { PluginContext } from 'src/components';
import { getSectionsToRender } from 'src/explore/controlUtils';
import { ExploreActions } from 'src/explore/actions/exploreActions';
import { ChartState, ExplorePageState } from 'src/explore/types';
import { Icons } from '@superset-ui/core/components/Icons';
import ControlRow from './ControlRow';
import Control from './Control';
import { ExploreAlert } from './ExploreAlert';
import { RunQueryButton } from './RunQueryButton';
import { Operators } from '../constants';
import { Clauses } from './controls/FilterControl/types';
import StashFormDataContainer from './StashFormDataContainer';
const { confirm } = Modal;
const TABS_KEYS = {
DATA: 'DATA',
CUSTOMIZE: 'CUSTOMIZE',
MATRIXIFY: 'MATRIXIFY',
};
export type ControlPanelsContainerProps = {
exploreState: ExplorePageState['explore'];
actions: ExploreActions;
datasource_type: DatasourceType;
chart: ChartState;
controls: Record<string, ControlState>;
form_data: QueryFormData;
isDatasourceMetaLoading: boolean;
errorMessage: ReactNode;
buttonErrorMessage?: ReactNode; // Error message for RunQueryButton (includes all errors)
onQuery: () => void;
onStop: () => void;
canStopQuery: boolean;
chartIsStale: boolean;
};
export type ExpandedControlPanelSectionConfig = Omit<
ControlPanelSectionConfig,
'controlSetRows'
> & {
controlSetRows: ExpandedControlItem[][];
};
const iconStyles = css`
&.anticon {
font-size: unset;
.anticon {
line-height: unset;
vertical-align: unset;
}
}
`;
const actionButtonsContainerStyles = (theme: SupersetTheme) => css`
display: flex;
flex-direction: column;
align-items: center;
padding: ${theme.sizeUnit * 4}px;
background: ${theme.colorBgContainer};
flex-shrink: 0;
& > button {
min-width: 156px;
}
`;
const Styles = styled.div`
position: relative;
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
// Resizable add overflow-y: auto as a style to this div
// To override it, we need to use !important
overflow: visible !important;
#controlSections {
flex: 1;
overflow: auto;
}
.tab-content {
overflow: visible;
flex: 1 1 100%;
}
// Ensure Ant Design tabs allow content to expand
.ant-tabs-content {
overflow: visible;
height: auto;
}
.ant-tabs-content-holder {
overflow: visible;
height: auto;
}
.ant-tabs-tabpane {
overflow: visible;
height: auto;
}
// Ensure collapse components can expand
.ant-collapse-content {
overflow: visible;
}
.ant-collapse-content-box {
overflow: visible;
}
.Select__menu {
max-width: 100%;
}
.type-label {
margin-right: ${({ theme }) => theme.sizeUnit * 3}px;
width: ${({ theme }) => theme.sizeUnit * 7}px;
display: inline-block;
text-align: center;
font-weight: ${({ theme }) => theme.fontWeightStrong};
}
`;
const isTimeSection = (section: ControlPanelSectionConfig): boolean =>
!!section.label && sections.legacyTimeseriesTime.label === section.label;
const hasTimeColumn = (datasource: Dataset): boolean =>
datasource?.columns?.some(c => c.is_dttm);
const sectionsToExpand = (
sections: ControlPanelSectionConfig[],
datasource: Dataset,
): string[] =>
// avoid expanding time section if datasource doesn't include time column
sections.reduce(
(acc, section) =>
(section.expanded || !section.label) &&
(!isTimeSection(section) || hasTimeColumn(datasource))
? [...acc, String(section.label)]
: acc,
[] as string[],
);
function getState(
vizType: string,
datasource: Dataset,
datasourceType: DatasourceType,
) {
const querySections: ControlPanelSectionConfig[] = [];
const customizeSections: ControlPanelSectionConfig[] = [];
const matrixifySections: ControlPanelSectionConfig[] = [];
let matrixifyEnableControl: ControlPanelSectionConfig | null = null;
getSectionsToRender(vizType, datasourceType).forEach(section => {
if (!section) return;
if (section.tabOverride === 'matrixify') {
// Separate the enable control from other sections
if (section.label === t('Enable Matrixify')) {
matrixifyEnableControl = section;
} else {
matrixifySections.push(section);
}
} else if (
section.tabOverride === 'data' ||
section.controlSetRows.some(rows =>
rows.some(
control =>
control &&
typeof control === 'object' &&
'config' in control &&
control.config &&
(!control.config.renderTrigger ||
control.config.tabOverride === 'data'),
),
)
) {
querySections.push(section);
} else if (section.controlSetRows && section.controlSetRows.length > 0) {
customizeSections.push(section);
}
});
const expandedQuerySections: string[] = sectionsToExpand(
querySections,
datasource,
);
const expandedCustomizeSections: string[] = sectionsToExpand(
customizeSections,
datasource,
);
const expandedMatrixifySections: string[] = sectionsToExpand(
matrixifySections,
datasource,
);
return {
expandedQuerySections,
expandedCustomizeSections,
expandedMatrixifySections,
querySections,
customizeSections,
matrixifySections,
matrixifyEnableControl,
};
}
function useResetOnChangeRef(initialValue: () => any, resetOnChangeValue: any) {
const value = useRef(initialValue());
const prevResetOnChangeValue = useRef(resetOnChangeValue);
if (prevResetOnChangeValue.current !== resetOnChangeValue) {
value.current = initialValue();
prevResetOnChangeValue.current = resetOnChangeValue;
}
return value;
}
export const ControlPanelsContainer = (props: ControlPanelsContainerProps) => {
const theme = useTheme();
const pluginContext = useContext(PluginContext);
const prevState = usePrevious(props.exploreState);
const prevDatasource = usePrevious(props.exploreState.datasource);
const prevChartStatus = usePrevious(props.chart.chartStatus);
const [showDatasourceAlert, setShowDatasourceAlert] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const controlsTransferred = useSelector<
ExplorePageState,
string[] | undefined
>(state => state.explore.controlsTransferred);
const defaultTimeFilter = useSelector<ExplorePageState>(
state => state.common?.conf?.DEFAULT_TIME_FILTER || NO_TIME_RANGE,
);
const { form_data, actions } = props;
const { setControlValue } = actions;
const { x_axis, adhoc_filters } = form_data;
const previousXAxis = usePrevious(x_axis);
useEffect(() => {
if (
x_axis &&
x_axis !== previousXAxis &&
isTemporalColumn(x_axis, props.exploreState.datasource)
) {
const noFilter = !adhoc_filters?.find(
filter =>
filter.expressionType === 'SIMPLE' &&
filter.operator === Operators.TemporalRange &&
filter.subject === x_axis,
);
if (noFilter) {
confirm({
title: t('The X-axis is not on the filters list'),
content:
t(`The X-axis is not on the filters list which will prevent it from being used in
time range filters in dashboards. Would you like to add it to the filters list?`),
onOk: () => {
setControlValue('adhoc_filters', [
...(adhoc_filters || []),
{
clause: Clauses.Where,
subject: x_axis,
operator: Operators.TemporalRange,
comparator: defaultTimeFilter,
expressionType: 'SIMPLE',
},
]);
},
});
}
}
}, [
x_axis,
adhoc_filters,
setControlValue,
defaultTimeFilter,
previousXAxis,
props.exploreState.datasource,
]);
useEffect(() => {
let shouldUpdateControls = false;
const removeDatasourceWarningFromControl = (
value: JsonValue | undefined,
) => {
if (
typeof value === 'object' &&
isDefined(value) &&
'datasourceWarning' in value &&
value.datasourceWarning === true
) {
shouldUpdateControls = true;
return { ...value, datasourceWarning: false };
}
return value;
};
if (
props.chart.chartStatus === 'success' &&
prevChartStatus !== 'success'
) {
controlsTransferred?.forEach(controlName => {
shouldUpdateControls = false;
if (!isDefined(props.controls[controlName])) {
return;
}
const alteredControls = Array.isArray(props.controls[controlName].value)
? ensureIsArray(props.controls[controlName].value)?.map(
removeDatasourceWarningFromControl,
)
: removeDatasourceWarningFromControl(
props.controls[controlName].value,
);
if (shouldUpdateControls) {
props.actions.setControlValue(controlName, alteredControls);
}
});
}
}, [
controlsTransferred,
prevChartStatus,
props.actions,
props.chart.chartStatus,
props.controls,
]);
useEffect(() => {
if (
prevDatasource &&
prevDatasource.type !== DatasourceType.Query &&
(props.exploreState.datasource?.id !== prevDatasource.id ||
props.exploreState.datasource?.type !== prevDatasource.type)
) {
setShowDatasourceAlert(true);
containerRef.current?.scrollTo(0, 0);
}
}, [
props.exploreState.datasource?.id,
props.exploreState.datasource?.type,
prevDatasource,
]);
const {
expandedQuerySections,
expandedCustomizeSections,
expandedMatrixifySections,
querySections,
customizeSections,
matrixifySections,
matrixifyEnableControl,
} = useMemo(
() =>
getState(
form_data.viz_type,
props.exploreState.datasource,
props.datasource_type,
),
[props.exploreState.datasource, form_data.viz_type, props.datasource_type],
);
const resetTransferredControls = useCallback(() => {
ensureIsArray(props.exploreState.controlsTransferred).forEach(controlName =>
props.actions.setControlValue(
controlName,
props.controls[controlName].default,
),
);
}, [props.actions, props.exploreState.controlsTransferred, props.controls]);
const handleClearFormClick = useCallback(() => {
resetTransferredControls();
setShowDatasourceAlert(false);
}, [resetTransferredControls]);
const handleContinueClick = useCallback(() => {
setShowDatasourceAlert(false);
}, []);
const shouldRecalculateControlState = ({
name,
config,
}: CustomControlItem): boolean => {
const { controls, chart, exploreState } = props;
return Boolean(
config.shouldMapStateToProps?.(
prevState || exploreState,
exploreState,
controls[name],
chart,
),
);
};
const renderControl = ({ name, config }: CustomControlItem) => {
const { controls, chart, exploreState } = props;
const { visibility, hidden, disableStash, ...restConfig } = config;
// If the control item is not an object, we have to look up the control data from
// the centralized controls file.
// When it is an object we read control data straight from `config` instead
const controlData = {
...restConfig,
...controls[name],
...(shouldRecalculateControlState({ name, config })
? config?.mapStateToProps?.(exploreState, controls[name], chart)
: // for other controls, `mapStateToProps` is already run in
// controlUtils/getControlState.ts
undefined),
name,
};
const {
validationErrors,
label: baseLabel,
description: baseDescription,
...restProps
} = controlData as ControlState & {
validationErrors?: any[];
};
const isVisible = visibility
? visibility.call(config, props, controlData)
: undefined;
const isHidden =
typeof hidden === 'function'
? hidden.call(config, props, controlData)
: hidden;
const label =
typeof baseLabel === 'function'
? baseLabel(exploreState, controls[name], chart)
: baseLabel;
const description =
typeof baseDescription === 'function'
? baseDescription(exploreState, controls[name], chart)
: baseDescription;
if (name.includes('adhoc_filters')) {
restProps.canDelete = (
valueToBeDeleted: Record<string, any>,
values: Record<string, any>[],
) => {
const isTemporalRange = (filter: Record<string, any>) =>
filter.operator === Operators.TemporalRange;
if (!controls?.time_range?.value && isTemporalRange(valueToBeDeleted)) {
const count = values.filter(isTemporalRange).length;
if (count === 1) {
// if temporal filter's value is "No filter", prevent deletion
// otherwise reset the value to "No filter"
if (valueToBeDeleted.comparator === defaultTimeFilter) {
return t(
`You cannot delete the last temporal filter as it's used for time range filters in dashboards.`,
);
}
props.actions.setControlValue(
name,
values.map(val => {
if (isEqual(val, valueToBeDeleted)) {
return {
...val,
comparator: defaultTimeFilter,
};
}
return val;
}),
);
return false;
}
}
return true;
};
}
return (
<StashFormDataContainer
shouldStash={isVisible === false && disableStash !== true}
fieldNames={[name]}
key={`control-container-${name}`}
>
<Control
key={`control-${name}`}
name={name}
label={label}
description={description}
validationErrors={validationErrors}
actions={props.actions}
isVisible={isVisible}
hidden={isHidden}
{...restProps}
/>
</StashFormDataContainer>
);
};
const sectionHasHadNoErrors = useResetOnChangeRef(
() => ({}),
form_data.viz_type,
);
const renderControlPanelSection = (
section: ExpandedControlPanelSectionConfig,
) => {
const { controls } = props;
const { label, description, visibility } = section;
// Section label can be a ReactNode but in some places we want to
// have a string ID. Using forced type conversion for now,
// should probably add a `id` field to sections in the future.
const sectionId = String(label);
const isVisible = visibility?.call(this, props, controls) !== false;
const hasErrors = section.controlSetRows.some(rows =>
rows.some(item => {
const controlName =
typeof item === 'string'
? item
: item && 'name' in item
? item.name
: null;
return (
controlName &&
controlName in controls &&
controls[controlName].validationErrors &&
controls[controlName].validationErrors.length > 0
);
}),
);
if (!hasErrors) {
sectionHasHadNoErrors.current[sectionId] = true;
}
const PanelHeader = () => (
<span data-test="collapsible-control-panel-header">
<span
css={(theme: SupersetTheme) => css`
font-size: ${theme.fontSize}px;
line-height: 1.3;
`}
>
{label}
</span>{' '}
{description && (
<Tooltip id={sectionId} title={description}>
<Icons.InfoCircleOutlined css={iconStyles} />
</Tooltip>
)}
{hasErrors && (
<Tooltip
id={`${kebabCase('validation-errors')}-tooltip`}
title={t('This section contains validation errors')}
>
<Icons.InfoCircleOutlined iconColor={theme.colorErrorText} />
</Tooltip>
)}
</span>
);
const PanelChildren = (
<>
<StashFormDataContainer
key={`sectionId-${sectionId}`}
shouldStash={!isVisible}
fieldNames={section.controlSetRows
.flat()
.map(item =>
item && typeof item === 'object'
? 'name' in item
? item.name
: ''
: String(item || ''),
)
.filter(Boolean)}
/>
{isVisible && (
<>
{section.controlSetRows.map((controlSets, i) => {
const renderedControls = controlSets
.map(controlItem => {
if (!controlItem) {
// When the item is invalid
return null;
}
if (isValidElement(controlItem)) {
// When the item is a React element
return controlItem;
}
if (
isCustomControlItem(controlItem) &&
controlItem.name !== 'datasource'
) {
return renderControl(controlItem);
}
return null;
})
.filter(x => x !== null);
// don't show the row if it is empty
if (renderedControls.length === 0) {
return null;
}
return (
<ControlRow
key={`controlsetrow-${i}`}
controls={renderedControls}
/>
);
})}
</>
)}
</>
);
return {
key: String(section.label),
label: <PanelHeader />,
children: PanelChildren,
className: section.label ? '' : 'hidden-collapse-header',
style: { visibility: isVisible ? 'visible' : 'hidden' },
};
};
const hasControlsTransferred =
ensureIsArray(props.exploreState.controlsTransferred).length > 0;
const DatasourceAlert = useCallback(
() =>
hasControlsTransferred ? (
<ExploreAlert
title={t('Keep control settings?')}
bodyText={t(
"You've changed datasets. Any controls with data (columns, metrics) that match this new dataset have been retained.",
)}
primaryButtonAction={handleContinueClick}
secondaryButtonAction={handleClearFormClick}
primaryButtonText={t('Continue')}
secondaryButtonText={t('Clear form')}
type="info"
/>
) : (
<ExploreAlert
title={t('No form settings were maintained')}
bodyText={t(
'We were unable to carry over any controls when switching to this new dataset.',
)}
primaryButtonAction={handleContinueClick}
primaryButtonText={t('Continue')}
type="warning"
/>
),
[handleClearFormClick, handleContinueClick, hasControlsTransferred],
);
const dataTabHasHadNoErrors = useResetOnChangeRef(
() => false,
form_data.viz_type,
);
const dataTabTitle = useMemo(() => {
if (!props.errorMessage) {
dataTabHasHadNoErrors.current = true;
}
return (
<>
<span>{t('Data')}</span>
{props.errorMessage && (
<span
css={(theme: SupersetTheme) => css`
margin-left: ${theme.sizeUnit * 2}px;
`}
>
{' '}
<Tooltip
id="query-error-tooltip"
placement="right"
title={props.errorMessage}
>
<Icons.InfoCircleOutlined
data-test="query-error-tooltip-trigger"
iconColor={theme.colorErrorText}
iconSize="s"
/>
</Tooltip>
</span>
)}
</>
);
}, [
theme.colorErrorText,
theme.colorWarningText,
dataTabHasHadNoErrors,
props.errorMessage,
]);
const showCustomizeTab = customizeSections.length > 0;
const showMatrixifyTab = isFeatureEnabled(FeatureFlag.Matrixify);
// Check if matrixify sections have validation errors
const matrixifyHasErrors = useMemo(() => {
if (!showMatrixifyTab) return false;
return matrixifySections.some(section =>
section.controlSetRows.some(rows =>
rows.some(item => {
const controlName =
typeof item === 'string'
? item
: item && 'name' in item
? item.name
: null;
return (
controlName &&
controlName in props.controls &&
props.controls[controlName].validationErrors &&
props.controls[controlName].validationErrors.length > 0
);
}),
),
);
}, [showMatrixifyTab, matrixifySections, props.controls]);
// Create Matrixify tab label with Beta tag and validation errors
const matrixifyTabLabel = useMemo(
() => (
<>
<span>{t('Matrixify')}</span>
{matrixifyHasErrors && (
<span
css={(theme: SupersetTheme) => css`
margin-left: ${theme.sizeUnit * 2}px;
`}
>
{' '}
<Tooltip
id="matrixify-validation-error-tooltip"
placement="right"
title={t('This section contains validation errors')}
>
<Icons.InfoCircleOutlined
data-test="matrixify-validation-error-tooltip-trigger"
iconColor={theme.colorErrorText}
iconSize="s"
/>
</Tooltip>
</span>
)}{' '}
<Tooltip
title={t(
'This feature is experimental and may change or have limitations',
)}
placement="top"
>
<Label
type="info"
css={css`
margin-left: ${theme.sizeUnit}px;
font-size: ${theme.fontSizeSM}px;
`}
>
{t('beta')}
</Label>
</Tooltip>
</>
),
[
matrixifyHasErrors,
theme.colorErrorText,
theme.sizeUnit,
theme.fontSizeSM,
],
);
const controlPanelRegistry = getChartControlPanelRegistry();
if (!controlPanelRegistry.has(form_data.viz_type) && pluginContext.loading) {
return <Loading />;
}
return (
<Styles ref={containerRef}>
<Tabs
id="controlSections"
data-test="control-tabs"
allowOverflow={false}
items={[
{
key: TABS_KEYS.DATA,
label: dataTabTitle,
children: (
<>
{showDatasourceAlert && <DatasourceAlert />}
<Collapse
defaultActiveKey={expandedQuerySections}
expandIconPosition="end"
ghost
bordered
items={[...querySections.map(renderControlPanelSection)]}
/>
</>
),
},
...(showCustomizeTab
? [
{
key: TABS_KEYS.CUSTOMIZE,
label: t('Customize'),
children: (
<Collapse
defaultActiveKey={expandedCustomizeSections}
expandIconPosition="end"
ghost
bordered
items={[
...customizeSections.map(renderControlPanelSection),
]}
/>
),
},
]
: []),
...(showMatrixifyTab
? [
{
key: TABS_KEYS.MATRIXIFY,
label: matrixifyTabLabel,
children: (
<>
{/* Render Enable Matrixify control outside collapsible sections */}
{matrixifyEnableControl &&
(
matrixifyEnableControl as ControlPanelSectionConfig
).controlSetRows.map(
(controlSetRow: CustomControlItem[], i: number) => (
<div
key={`matrixify-enable-${i}`}
css={css`
padding: ${theme.sizeUnit * 4}px;
border-bottom: 1px solid ${theme.colorBorder};
`}
>
{controlSetRow.map(
(control: CustomControlItem, j: number) => {
if (!control || typeof control === 'string') {
return null;
}
return (
<div key={`control-${i}-${j}`}>
{renderControl(control)}
</div>
);
},
)}
</div>
),
)}
<Collapse
defaultActiveKey={expandedMatrixifySections}
expandIconPosition="right"
ghost
bordered
items={[
...matrixifySections.map(renderControlPanelSection),
]}
/>
</>
),
},
]
: []),
]}
/>
<div css={actionButtonsContainerStyles}>
<RunQueryButton
onQuery={props.onQuery}
onStop={props.onStop}
errorMessage={props.buttonErrorMessage || props.errorMessage}
loading={props.chart.chartStatus === 'loading'}
isNewChart={!props.chart.queriesResponse}
canStopQuery={props.canStopQuery}
chartIsStale={props.chartIsStale}
/>
</div>
</Styles>
);
};
export default ControlPanelsContainer;