/** * 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 { cloneElement, isValidElement, ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react'; import { t } from '@apache-superset/core/translation'; import { ensureIsArray, getChartControlPanelRegistry, QueryFormData, DatasourceType, isDefined, JsonValue, NO_TIME_RANGE, usePrevious, isFeatureEnabled, FeatureFlag, VizType, } from '@superset-ui/core'; import { styled, css, SupersetTheme, useTheme, } from '@apache-superset/core/theme'; 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, Loading, Label, Tooltip, } from '@superset-ui/core/components'; import Tabs from '@superset-ui/core/components/Tabs'; import { PluginContext } from 'src/components'; import { useConfirmModal } from 'src/hooks/useConfirmModal'; 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 TABS_KEYS = { DATA: 'DATA', CUSTOMIZE: 'CUSTOMIZE', MATRIXIFY: 'MATRIXIFY', }; // Table charts don't support matrixify feature const MATRIXIFY_INCOMPATIBLE_CHARTS = new Set([ VizType.Table, VizType.TableAgGrid, VizType.PivotTable, VizType.TimeTable, VizType.TimePivot, ]); export type ControlPanelsContainerProps = { exploreState: ExplorePageState['explore']; // Only setControlValue is used from actions in this component actions: Pick; datasource_type: DatasourceType; chart: ChartState; controls: Record; 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 { showConfirm, ConfirmModal } = useConfirmModal(); const prevState = usePrevious(props.exploreState); const prevDatasource = usePrevious(props.exploreState.datasource); const prevChartStatus = usePrevious(props.chart.chartStatus); const [showDatasourceAlert, setShowDatasourceAlert] = useState(false); const [activeTabKey, setActiveTabKey] = useState(TABS_KEYS.DATA); const containerRef = useRef(null); const controlsTransferred = useSelector< ExplorePageState, string[] | undefined >(state => state.explore.controlsTransferred); const defaultTimeFilter = useSelector( 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) { showConfirm({ title: t('The X-axis is not on the filters list'), body: 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?`, ), onConfirm: () => { setControlValue('adhoc_filters', [ ...(adhoc_filters || []), { clause: Clauses.Where, subject: x_axis, operator: Operators.TemporalRange, comparator: defaultTimeFilter, expressionType: 'SIMPLE', }, ]); }, confirmText: t('Yes'), cancelText: t('No'), }); } } }, [ x_axis, adhoc_filters, setControlValue, defaultTimeFilter, previousXAxis, props.exploreState.datasource, showConfirm, ]); 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, values: Record[], ) => { const isTemporalRange = (filter: Record) => 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 ( ); }; const sectionHasHadNoErrors = useResetOnChangeRef( () => ({}), form_data.viz_type, ); const renderControlPanelSection = ( section: ExpandedControlPanelSectionConfig, ) => { const { controls, chart, exploreState, form_data, actions } = 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 = () => ( css` font-size: ${theme.fontSize}px; line-height: 1.3; `} > {label} {' '} {description && ( )} {hasErrors && ( )} ); const PanelChildren = ( <> 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 const element = controlItem as React.ReactElement< Record >; const controlName = (element.props as { name: string }) .name; if (!controlName) { return element; } const controlState = controls[controlName]; return cloneElement(element, { ...(element.props as Record), actions, controls, chart, exploreState, form_data, ...(controlState && { value: controlState.value, validationErrors: controlState.validationErrors, default: controlState.default, onChange: (value: unknown, errors: unknown[]) => setControlValue(controlName, value, errors), }), }); } 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 ( ); })} )} ); return { key: String(section.label), label: , children: PanelChildren, className: section.label ? '' : 'hidden-collapse-header', style: { display: isVisible ? 'block' : 'none' }, }; }; const hasControlsTransferred = ensureIsArray(props.exploreState.controlsTransferred).length > 0; const DatasourceAlert = useCallback( () => hasControlsTransferred ? ( ) : ( ), [handleClearFormClick, handleContinueClick, hasControlsTransferred], ); const dataTabHasHadNoErrors = useResetOnChangeRef( () => false, form_data.viz_type, ); const dataTabTitle = useMemo(() => { if (!props.errorMessage) { dataTabHasHadNoErrors.current = true; } return ( <> {t('Data')} {props.errorMessage && ( css` margin-left: ${theme.sizeUnit * 2}px; `} > {' '} )} ); }, [ theme.colorErrorText, theme.colorWarningText, dataTabHasHadNoErrors, props.errorMessage, ]); const showCustomizeTab = customizeSections.length > 0; const showMatrixifyTab = isFeatureEnabled(FeatureFlag.Matrixify) && !MATRIXIFY_INCOMPATIBLE_CHARTS.has(form_data.viz_type as VizType); // Check if matrixify is enabled in form_data const matrixifyIsEnabled = form_data.matrixify_enable === true && ((form_data.matrixify_mode_rows !== undefined && form_data.matrixify_mode_rows !== 'disabled') || (form_data.matrixify_mode_columns !== undefined && form_data.matrixify_mode_columns !== 'disabled')); // Auto-switch to Matrixify tab when it's enabled useEffect(() => { if (showMatrixifyTab && matrixifyIsEnabled) { setActiveTabKey(TABS_KEYS.MATRIXIFY); } }, [showMatrixifyTab, matrixifyIsEnabled]); // 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( () => ( <> {t('Matrixify')} {matrixifyHasErrors && ( css` margin-left: ${theme.sizeUnit * 2}px; `} > {' '} )}{' '} ), [ matrixifyHasErrors, theme.colorErrorText, theme.sizeUnit, theme.fontSizeSM, ], ); const controlPanelRegistry = getChartControlPanelRegistry(); if (!controlPanelRegistry.has(form_data.viz_type) && pluginContext.loading) { return ; } return ( <> setActiveTabKey(key)} items={[ { key: TABS_KEYS.DATA, label: dataTabTitle, children: ( <> {showDatasourceAlert && } ), }, ...(showCustomizeTab ? [ { key: TABS_KEYS.CUSTOMIZE, label: t('Customize'), children: ( ), }, ] : []), ...(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) => (
{controlSetRow.map( (control: CustomControlItem, j: number) => { if ( !control || typeof control === 'string' ) { return null; } return (
{renderControl(control)}
); }, )}
), )} ), }, ] : []), ]} />
{ConfirmModal} ); }; export default ControlPanelsContainer;