/** * 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 { ComponentType, memo, useCallback, useEffect, useMemo, useState, } from 'react'; import { bindActionCreators, Dispatch } from 'redux'; import { connect } from 'react-redux'; import { useChangeEffect, useComponentDidMount, usePrevious, isMatrixifyEnabled, QueryFormData, JsonObject, MatrixifyFormData, DatasourceType, } from '@superset-ui/core'; import { ControlStateMapping, ControlPanelState, } from '@superset-ui/chart-controls'; import { t, styled, css, useTheme } from '@apache-superset/core/ui'; import { logging } from '@apache-superset/core'; import { debounce, isEqual, isObjectLike, omit, pick } from 'lodash'; import { Resizable } from 're-resizable'; import { Tooltip } from '@superset-ui/core/components'; import { usePluginContext } from 'src/components'; import { Global } from '@emotion/react'; import { Icons } from '@superset-ui/core/components/Icons'; import { getItem, setItem, LocalStorageKeys, } from 'src/utils/localStorageHelpers'; import { RESERVED_CHART_URL_PARAMS, URL_PARAMS } from 'src/constants'; import { QUERY_MODE_REQUISITES } from 'src/explore/constants'; import { areObjectsEqual } from 'src/reduxUtils'; import * as logActions from 'src/logger/actions'; import { LOG_ACTIONS_MOUNT_EXPLORER, LOG_ACTIONS_CHANGE_EXPLORE_CONTROLS, } from 'src/logger/LogUtils'; import { ensureAppRoot } from 'src/utils/pathUtils'; import { getUrlParam } from 'src/utils/urlUtils'; import cx from 'classnames'; import * as chartActions from 'src/components/Chart/chartAction'; import { fetchDatasourceMetadata } from 'src/dashboard/actions/datasources'; import { mergeExtraFormData } from 'src/dashboard/components/nativeFilters/utils'; import { postFormData, putFormData } from 'src/explore/exploreUtils/formData'; import { datasourcesActions } from 'src/explore/actions/datasourcesActions'; import { mountExploreUrl } from 'src/explore/exploreUtils'; import { getFormDataFromControls } from 'src/explore/controlUtils'; import * as exploreActions from 'src/explore/actions/exploreActions'; import * as saveModalActions from 'src/explore/actions/saveModalActions'; import { useTabId } from 'src/hooks/useTabId'; import withToasts from 'src/components/MessageToasts/withToasts'; import { ChartState, Datasource, ExplorePageInitialData, ExplorePageState, SaveActionType, } from 'src/explore/types'; import { Slice } from 'src/types/Chart'; import { User } from 'src/types/bootstrapTypes'; import ExploreChartPanel from '../ExploreChartPanel'; import ConnectedControlPanelsContainer from '../ControlPanelsContainer'; import SaveModal from '../SaveModal'; import DataSourcePanel from '../DatasourcePanel'; import ConnectedExploreChartHeader from '../ExploreChartHeader'; import ExploreContainer from '../ExploreContainer'; const ExplorePanelContainer = styled.div` ${({ theme }) => css` text-align: left; position: relative; width: 100%; max-height: 100%; background-color: ${theme.colorBgContainer}; min-height: 0; display: flex; flex: 1; flex-wrap: nowrap; border-top: 1px solid ${theme.colorSplit}; .explore-column { display: flex; flex-direction: column; padding: ${theme.sizeUnit * 2}px 0; max-height: 100%; } .data-source-selection { padding: ${theme.sizeUnit * 2}px 0; border-right: 1px solid ${theme.colorSplit}; } .main-explore-content { flex: 1; min-width: ${theme.sizeUnit * 128}px; border-left: 1px solid ${theme.colorSplit}; padding: 0 ${theme.sizeUnit * 4}px; .panel { margin-bottom: 0; } } .controls-column { align-self: flex-start; padding: 0; } .title-container { position: relative; display: flex; flex-direction: row; padding: 0 ${theme.sizeUnit * 2}px 0 ${theme.sizeUnit * 4}px; justify-content: space-between; .horizontal-text { font-size: ${theme.fontSize}px; line-height: 1.5; display: inline-block; height: auto; overflow: visible; } } .no-show { display: none; } .vertical-text { writing-mode: vertical-rl; text-orientation: mixed; } .sidebar { height: 100%; padding: ${theme.sizeUnit * 2}px; width: ${theme.sizeUnit * 8}px; } .collapse-icon > svg { color: ${theme.colorPrimary}; } `}; `; const updateHistory = debounce( async ( formData, datasourceId, datasourceType, isReplace, standalone, force, title, tabId, ) => { const payload = { ...formData }; const chartId = formData.slice_id; const params = new URLSearchParams(window.location.search); const additionalParam = Object.fromEntries(params); if (chartId) { additionalParam[URL_PARAMS.sliceId.name] = chartId; } else { additionalParam[URL_PARAMS.datasourceId.name] = datasourceId; additionalParam[URL_PARAMS.datasourceType.name] = datasourceType; } const urlParams = payload?.url_params || {}; Object.entries(urlParams).forEach(([key, value]) => { if (!RESERVED_CHART_URL_PARAMS.includes(key)) { additionalParam[key] = value as string; } }); try { let key: string | null | undefined; let stateModifier: 'replaceState' | 'pushState'; if (isReplace) { key = await postFormData( datasourceId, datasourceType, formData, chartId, tabId, ); stateModifier = 'replaceState'; } else { key = getUrlParam(URL_PARAMS.formDataKey); if (key) { await putFormData( datasourceId, datasourceType, key, formData, chartId, tabId, ); } stateModifier = 'pushState'; } // avoid race condition in case user changes route before explore updates the url if (window.location.pathname.startsWith(ensureAppRoot('/explore'))) { const url = mountExploreUrl( standalone ? URL_PARAMS.standalone.name : 'base', { [URL_PARAMS.formDataKey.name]: key ?? '', ...additionalParam, }, force, ); window.history[stateModifier](payload, title, url); } } catch (e) { logging.warn('Failed at altering browser history', e); } }, 1000, ); type DefaultSidebarWidthKey = 'controls_width' | 'datasource_width'; const defaultSidebarsWidth: Record = { controls_width: 320, datasource_width: 300, }; function getSidebarWidths( key: LocalStorageKeys.ControlsWidth | LocalStorageKeys.DatasourceWidth, ): number { const defaultKey = key === LocalStorageKeys.ControlsWidth ? 'controls_width' : 'datasource_width'; return getItem(key, defaultSidebarsWidth[defaultKey]); } function setSidebarWidths( key: LocalStorageKeys.ControlsWidth | LocalStorageKeys.DatasourceWidth, dimension: { width: number }, ) { const newDimension = Number(getSidebarWidths(key)) + dimension.width; setItem(key, newDimension); } // Chart types that use aggregation and can have multiple values in tooltips const AGGREGATED_CHART_TYPES = [ // Deck.gl aggregated charts 'deck_screengrid', 'deck_heatmap', 'deck_contour', 'deck_hex', 'deck_grid', // Other aggregated chart types can be added here 'heatmap', 'treemap', 'sunburst', 'pie', 'donut', 'histogram', 'table', ]; function isAggregatedChartType(vizType: string | undefined): boolean { return vizType ? AGGREGATED_CHART_TYPES.includes(vizType) : false; } interface ExploreRootState { explore: { controls: ControlStateMapping; slice: Slice | null; datasource: Datasource; metadata?: ExplorePageInitialData['metadata']; hiddenFormData?: Partial; isDatasourceMetaLoading: boolean; isStarred: boolean; can_add: boolean; can_download: boolean; can_overwrite: boolean; sliceName?: string; triggerRender: boolean; standalone: boolean; force: boolean; form_data?: QueryFormData; saveAction?: SaveActionType | null; }; charts: Record; common: { conf: { SUPERSET_WEBSERVER_TIMEOUT: number; }; }; impressionId: string; dataMask: Record; reports: JsonObject; user: User; saveModal: { isVisible: boolean; }; } interface OwnProps { addDangerToast: (msg: string) => void; addSuccessToast?: (msg: string) => void; } interface StateProps { isDatasourceMetaLoading: boolean; datasource: Datasource; datasource_type: DatasourceType; datasourceId: number; dashboardId?: number; colorScheme?: string; ownColorScheme?: string; dashboardColorScheme?: string; controls: ControlStateMapping; can_add: boolean; can_download: boolean; can_overwrite: boolean; column_formats: JsonObject | null; containerId: string; isStarred: boolean; slice: Slice | null; sliceName: string | null; triggerRender: boolean; form_data: QueryFormData; table_name?: string; vizType?: string; standalone: boolean; force: boolean; chart: ChartState; timeout: number; ownState?: JsonObject; impressionId: string; user: User; exploreState: ExplorePageState['explore']; reports: JsonObject; metadata?: ExplorePageInitialData['metadata']; saveAction?: SaveActionType | null; isSaveModalVisible: boolean; } // Combined actions from all action modules used in Explore // Note: These modules export both action creators AND action type constants, // Using a callable signature to allow TypeScript to understand these are functions interface DispatchProps { // eslint-disable-next-line @typescript-eslint/no-explicit-any actions: Record any>; } type ExploreViewContainerProps = StateProps & DispatchProps & OwnProps; function ExploreViewContainer(props: ExploreViewContainerProps) { const dynamicPluginContext = usePluginContext(); const dynamicPlugin = props.vizType ? dynamicPluginContext.dynamicPlugins[props.vizType] : undefined; const isDynamicPluginLoading = dynamicPlugin && dynamicPlugin.mounting; const wasDynamicPluginLoading = usePrevious(isDynamicPluginLoading); /** the state of controls in the previous render */ const previousControls = usePrevious(props.controls); /** the state of controls last time a query was triggered */ const [lastQueriedControls, setLastQueriedControls] = useState( props.controls, ); const [isCollapsed, setIsCollapsed] = useState(false); const [width, setWidth] = useState( getSidebarWidths(LocalStorageKeys.DatasourceWidth), ); const tabId = useTabId(); const theme = useTheme(); // Capture original title before any effects run const originalTitle = useMemo(() => document.title, []); // Update document title when slice name changes useEffect(() => { if (props.sliceName) { document.title = props.sliceName; } }, [props.sliceName]); // Restore original title on unmount useEffect( () => () => { document.title = originalTitle || theme?.brandAppName || theme?.brandLogoAlt || 'Superset'; }, [originalTitle, theme?.brandAppName, theme?.brandLogoAlt], ); const addHistory = useCallback( async ({ isReplace = false, title } = {}) => { const formData = props.dashboardId ? { ...props.form_data, dashboardId: props.dashboardId, } : props.form_data; const { id: datasourceId, type: datasourceType } = props.datasource; updateHistory( formData, datasourceId, datasourceType, isReplace, props.standalone, props.force, title, tabId, ); }, [ props.dashboardId, props.form_data, props.datasource.id, props.datasource.type, props.standalone, props.force, tabId, ], ); const handlePopstate = useCallback(() => { const formData = window.history.state; if (formData && Object.keys(formData).length) { props.actions.setExploreControls(formData); props.actions.postChartFormData( formData, props.force, props.timeout, props.chart.id, ); } }, [props.actions, props.chart.id, props.timeout]); const onQuery = useCallback(() => { props.actions.setForceQuery(false); // Skip main query if Matrixify is enabled if (isMatrixifyEnabled(props.form_data as MatrixifyFormData)) { // Set chart to success state since Matrixify will handle its own queries props.actions.chartUpdateSucceeded([], props.chart.id); props.actions.chartRenderingSucceeded(props.chart.id); // Update history and controls addHistory(); setLastQueriedControls(props.controls); return; } // Normal behavior for non-Matrixify props.actions.triggerQuery(true, props.chart.id); addHistory(); setLastQueriedControls(props.controls); }, [ props.controls, addHistory, props.actions, props.chart.id, props.form_data, ]); const handleKeydown = useCallback( (event: KeyboardEvent) => { const controlOrCommand = event.ctrlKey || event.metaKey; if (controlOrCommand) { const isEnter = event.key === 'Enter' || event.keyCode === 13; if (isEnter) { onQuery(); } // Note: Ctrl+S save functionality removed due to type incompatibilities // between Slice types. Use the save button instead. } }, [onQuery], ); function onStop() { if (props.chart && props.chart.queryController) { props.chart.queryController.abort(); } } function toggleCollapse() { setIsCollapsed(!isCollapsed); } useComponentDidMount(() => { props.actions.logEvent( LOG_ACTIONS_MOUNT_EXPLORER, props.slice?.slice_id ? { slice_id: props.slice.slice_id, } : {}, ); }); useChangeEffect(tabId, (previous, current) => { if (current) { addHistory({ isReplace: true }); } }); const previousHandlePopstate = usePrevious(handlePopstate); useEffect(() => { if (previousHandlePopstate) { window.removeEventListener('popstate', previousHandlePopstate); } window.addEventListener('popstate', handlePopstate); return () => { window.removeEventListener('popstate', handlePopstate); }; }, [handlePopstate, previousHandlePopstate]); const previousHandleKeyDown = usePrevious(handleKeydown); useEffect(() => { if (previousHandleKeyDown) { window.removeEventListener('keydown', previousHandleKeyDown); } document.addEventListener('keydown', handleKeydown); return () => { document.removeEventListener('keydown', handleKeydown); }; }, [handleKeydown, previousHandleKeyDown]); useEffect(() => { if (wasDynamicPluginLoading && !isDynamicPluginLoading) { // reload the controls now that we actually have the control config props.actions.dynamicPluginControlsReady(); } }, [isDynamicPluginLoading]); useEffect(() => { const hasError = Object.values(props.controls).some( control => control.validationErrors && control.validationErrors.length > 0, ); if (!hasError) { props.actions.triggerQuery(true, props.chart.id); } }, []); const reRenderChart = useCallback( (controlsChanged?: string[]) => { const newQueryFormData = controlsChanged ? { ...props.chart.latestQueryFormData, ...getFormDataFromControls(pick(props.controls, controlsChanged)), } : getFormDataFromControls(props.controls); props.actions.updateQueryFormData(newQueryFormData, props.chart.id); props.actions.renderTriggered(new Date().getTime(), props.chart.id); addHistory(); }, [ addHistory, props.actions, props.chart.id, props.chart.latestQueryFormData, props.controls, ], ); // effect to run when controls change useEffect(() => { if ( previousControls && props.chart.latestQueryFormData.viz_type === props.controls.viz_type.value ) { if ( props.controls.datasource && (previousControls.datasource == null || props.controls.datasource.value !== previousControls.datasource.value) ) { // this should really be handled by actions fetchDatasourceMetadata(props.form_data.datasource); } const changedControlKeys = Object.keys(props.controls).filter( key => typeof previousControls[key] !== 'undefined' && !areObjectsEqual( props.controls[key].value, previousControls[key].value, ), ); if (changedControlKeys.includes('tooltip_contents')) { const tooltipContentsValue = props.controls.tooltip_contents?.value; const tooltipContents = Array.isArray(tooltipContentsValue) ? tooltipContentsValue : []; const currentTemplateValue = props.controls.tooltip_template?.value; const currentTemplate = typeof currentTemplateValue === 'string' ? currentTemplateValue : ''; if (tooltipContents.length > 0) { const getFieldName = ( item: | string | { item_type?: string; column_name?: string; metric_name?: string; label?: string; }, ): string | null => { if (typeof item === 'string') return item; if (item?.item_type === 'column') return item.column_name ?? null; if (item?.item_type === 'metric') { return item.metric_name || item.label || null; } return null; }; const vizType = props.form_data?.viz_type || ''; const isAggregatedChart = isAggregatedChartType(vizType); const DEFAULT_TOOLTIP_LIMIT = 10; // Maximum number of values to show in aggregated tooltips const fieldNames = tooltipContents .map(getFieldName) .filter((name): name is string => Boolean(name)); const missingVariables = fieldNames.filter( (fieldName: string) => !currentTemplate.includes(`{{ ${fieldName} }}`) && !currentTemplate.includes(`{{ limit ${fieldName}`), ); if (missingVariables.length > 0) { const newVariables = missingVariables.map((fieldName: string) => { const item = tooltipContents[fieldNames.indexOf(fieldName)]; const isColumn = (typeof item === 'object' && item?.item_type === 'column') || typeof item === 'string'; if (isAggregatedChart && isColumn) { return `{{ limit ${fieldName} ${DEFAULT_TOOLTIP_LIMIT} }}`; } return `{{ ${fieldName} }}`; }); const updatedTemplate = currentTemplate + (currentTemplate ? ' ' : '') + newVariables.join(' '); props.actions.setControlValue('tooltip_template', updatedTemplate); } } } // this should also be handled by the actions that are actually changing the controls const displayControlsChanged = changedControlKeys.filter( key => props.controls[key].renderTrigger, ); if (displayControlsChanged.length > 0) { reRenderChart(displayControlsChanged); } } }, [props.controls, props.ownState]); const chartIsStale = useMemo(() => { if (lastQueriedControls) { const { controls } = props; const changedControlKeys = Object.keys(controls).filter(key => { const lastControl = lastQueriedControls[key]; if (typeof lastControl === 'undefined') { return false; } const { value: value1 } = controls[key]; const { value: value2 } = lastControl; if (isObjectLike(value1) && isObjectLike(value2)) { return !areObjectsEqual(value1, value2, { ignoreFields: ['datasourceWarning'], }); } return !isEqual(value1, value2); }); return changedControlKeys.some( key => !props.controls[key].renderTrigger && !props.controls[key].dontRefreshOnChange, ); } return false; }, [lastQueriedControls, props.controls]); useChangeEffect(props.saveAction, () => { if ( props.saveAction && ['saveas', 'overwrite'].includes(props.saveAction) ) { onQuery(); addHistory({ isReplace: true }); props.actions.setSaveAction(null); } }); const previousOwnState = usePrevious(props.ownState); useEffect(() => { const strip = (s: JsonObject | undefined) => s && typeof s === 'object' ? omit(s, ['clientView']) : s; if (!isEqual(strip(previousOwnState), strip(props.ownState))) { onQuery(); reRenderChart(); } }, [props.ownState]); if (chartIsStale) { props.actions.logEvent(LOG_ACTIONS_CHANGE_EXPLORE_CONTROLS, {}); } const errorMessage = useMemo(() => { // Include all controls with validation errors (for button disabling) const controlsWithErrors = Object.values(props.controls).filter( control => control.validationErrors && control.validationErrors.length > 0, ); if (controlsWithErrors.length === 0) { return null; } const errorMessages = controlsWithErrors.map( control => control.validationErrors, ); const uniqueErrorMessages = [...new Set(errorMessages.flat())]; const errors = uniqueErrorMessages .map(message => { const matchingLabels = controlsWithErrors .filter(control => control.validationErrors?.includes(message)) .map(control => typeof control.label === 'function' ? control.label( props.exploreState as unknown as ControlPanelState, control, ) : control.label, ); return [matchingLabels, message]; }) .map(([labels, message]) => (
{labels.length > 1 ? t('Controls labeled ') : t('Control labeled ')} {` ${labels.join(', ')}`} : {message}
)); let errorMessage; if (errors.length > 0) { errorMessage =
{errors}
; } return errorMessage; }, [props.controls]); // Error message for Data tab only (excludes matrixify controls) const dataTabErrorMessage = useMemo(() => { const controlsWithErrors = Object.values(props.controls).filter( control => control.validationErrors && control.validationErrors.length > 0 && control.tabOverride !== 'matrixify', // Exclude matrixify controls from Data tab ); if (controlsWithErrors.length === 0) { return null; } const errorMessages = controlsWithErrors.map( control => control.validationErrors, ); const uniqueErrorMessages = [...new Set(errorMessages.flat())]; const errors = uniqueErrorMessages .map(message => { const matchingLabels = controlsWithErrors .filter(control => control.validationErrors?.includes(message)) .map(control => typeof control.label === 'function' ? control.label( props.exploreState as unknown as ControlPanelState, control, ) : control.label, ); return [matchingLabels, message]; }) .map(([labels, message]) => (
{labels.length > 1 ? t('Controls labeled ') : t('Control labeled ')} {` ${labels.join(', ')}`} : {message}
)); let dataTabErrorMessage; if (errors.length > 0) { dataTabErrorMessage = (
{errors}
); } return dataTabErrorMessage; }, [props.controls]); function renderChartContainer() { return ( props.actions.setControlValue(controlName, value), }} can_overwrite={props.can_overwrite} can_download={props.can_download} datasource={props.datasource} dashboardId={props.dashboardId} column_formats={props.column_formats ?? undefined} containerId={props.containerId} isStarred={props.isStarred} slice={props.slice ?? undefined} sliceName={props.sliceName ?? undefined} table_name={props.table_name} vizType={props.vizType ?? ''} form_data={props.form_data} ownState={props.ownState} standalone={props.standalone} force={props.force} timeout={props.timeout} chart={props.chart} triggerRender={props.triggerRender} errorMessage={dataTabErrorMessage} chartIsStale={chartIsStale} onQuery={onQuery} exploreState={props.exploreState} /> ); } if (props.standalone) { return renderChartContainer(); } return ( { setWidth(ref.getBoundingClientRect().width); setSidebarWidths(LocalStorageKeys.DatasourceWidth, d); }} defaultSize={{ width: getSidebarWidths(LocalStorageKeys.DatasourceWidth), height: '100%', }} minWidth={defaultSidebarsWidth[LocalStorageKeys.DatasourceWidth]} maxWidth="33%" enable={{ right: true }} className={ isCollapsed ? 'no-show' : 'explore-column data-source-selection' } >
{t('Chart Source')}
{/* eslint-disable @typescript-eslint/no-explicit-any -- DataSourcePanel uses narrower types that are compatible at runtime */} {/* eslint-enable @typescript-eslint/no-explicit-any */}
{isCollapsed ? (
) : null} setSidebarWidths(LocalStorageKeys.ControlsWidth, d) } defaultSize={{ width: getSidebarWidths(LocalStorageKeys.ControlsWidth), height: '100%', }} minWidth={defaultSidebarsWidth[LocalStorageKeys.ControlsWidth]} maxWidth="33%" enable={{ right: true }} className="col-sm-3 explore-column controls-column" >
{renderChartContainer()}
{props.isSaveModalVisible && ( )}
); } const retainQueryModeRequirements = ( hiddenFormData: Partial | undefined, ): string[] => Object.keys(hiddenFormData ?? {}).filter( key => !QUERY_MODE_REQUISITES.has(key), ); interface SliceWithSubheader extends Slice { form_data?: QueryFormData & { subheader?: string; subheader_font_size?: number; }; } function patchBigNumberTotalFormData( form_data: QueryFormData, slice: SliceWithSubheader | null | undefined, ): QueryFormData { if ( form_data.viz_type === 'big_number_total' && !form_data.subtitle && slice?.form_data?.subheader ) { return { ...form_data, subtitle: slice.form_data.subheader }; } return form_data; } function mapStateToProps(state: ExploreRootState) { const { explore, charts, common, impressionId, dataMask, reports, user, saveModal, } = state; const { controls, slice, datasource, metadata, hiddenFormData } = explore; const hasQueryMode = !!controls?.query_mode?.value; const fieldsToOmit = hasQueryMode ? retainQueryModeRequirements(hiddenFormData) : Object.keys(hiddenFormData ?? {}); const controlsBasedFormData = omit( getFormDataFromControls(controls), fieldsToOmit, ) as QueryFormData; const isDeckGLChart = explore.form_data?.viz_type === 'deck_multi'; const getDeckGLFormData = (): QueryFormData => { const formData = { ...controlsBasedFormData } as QueryFormData & { layer_filter_scope?: JsonObject; filter_data_mapping?: JsonObject; }; if (explore.form_data?.layer_filter_scope) { formData.layer_filter_scope = explore.form_data .layer_filter_scope as JsonObject; } if (explore.form_data?.filter_data_mapping) { formData.filter_data_mapping = explore.form_data .filter_data_mapping as JsonObject; } return formData; }; const form_data: QueryFormData = isDeckGLChart ? getDeckGLFormData() : controlsBasedFormData; const slice_id = form_data.slice_id ?? slice?.slice_id ?? 0; // 0 - unsaved chart // exclude clientView from extra_form_data; keep other ownState pieces const ownStateForQuery = omit(dataMask[slice_id]?.ownState, ['clientView']); form_data.extra_form_data = mergeExtraFormData( { ...form_data.extra_form_data }, { ...ownStateForQuery, }, ); const chart = charts[slice_id]; const colorScheme = explore.form_data?.color_scheme; const ownColorScheme = explore.form_data?.own_color_scheme; const dashboardColorScheme = explore.form_data?.dashboard_color_scheme; let dashboardId: number | undefined = Number(explore.form_data?.dashboardId); if (Number.isNaN(dashboardId)) { dashboardId = undefined; } if ( controls && form_data.viz_type === 'big_number_total' && slice?.form_data?.subheader && (!controls.subtitle?.value || controls.subtitle.value === '') ) { controls.subtitle = { ...controls.subtitle, value: slice.form_data.subheader, }; if (slice?.form_data?.subheader_font_size) { controls.subtitle_font_size = { ...controls.subtitle_font_size, value: slice.form_data.subheader_font_size, }; } } const patchedFormData = patchBigNumberTotalFormData(form_data, slice); return { isDatasourceMetaLoading: explore.isDatasourceMetaLoading, datasource, datasource_type: datasource.type, datasourceId: datasource.id, dashboardId, colorScheme, ownColorScheme, dashboardColorScheme, controls: explore.controls, can_add: !!explore.can_add, can_download: !!explore.can_download, can_overwrite: !!explore.can_overwrite, column_formats: datasource?.column_formats ?? null, containerId: slice ? `slice-container-${slice.slice_id}` : 'slice-container', isStarred: explore.isStarred, slice, sliceName: explore.sliceName ?? slice?.slice_name ?? null, triggerRender: explore.triggerRender, form_data: patchedFormData, table_name: datasource.table_name, vizType: form_data.viz_type, standalone: !!explore.standalone, force: !!explore.force, chart, timeout: common.conf.SUPERSET_WEBSERVER_TIMEOUT, ownState: dataMask[slice_id]?.ownState, impressionId, user, // ExploreRootState['explore'] is compatible with ExplorePageState['explore'] // but has additional optional fields; casting is safe here exploreState: explore as unknown as ExplorePageState['explore'], reports, metadata, saveAction: explore.saveAction, isSaveModalVisible: saveModal.isVisible, }; } function mapDispatchToProps(dispatch: Dispatch): DispatchProps { const actions = { ...exploreActions, ...datasourcesActions, ...saveModalActions, ...chartActions, ...logActions, }; return { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Action modules export mixed types (creators + constants) actions: bindActionCreators(actions as any, dispatch), }; } // withToasts HOC expects ComponentType, requiring type assertion // The connected component properly handles StateProps & DispatchProps & OwnProps export default connect( mapStateToProps, mapDispatchToProps, )(withToasts(memo(ExploreViewContainer)) as ComponentType);