This is an automated email from the ASF dual-hosted git repository. kgabryje pushed a commit to branch what-if in repository https://gitbox.apache.org/repos/asf/superset.git
commit b4165736d58aa5217d10f2c85b95e3d8236f3948 Author: Kamil Gabryjelski <[email protected]> AuthorDate: Tue Dec 16 18:11:17 2025 +0100 Implement UI --- .../superset-ui-core/src/query/extractExtras.ts | 3 +- .../superset-ui-core/src/query/types/Query.ts | 8 + .../src/dashboard/actions/dashboardState.js | 5 + .../DashboardBuilder/DashboardBuilder.tsx | 22 +- .../src/dashboard/components/Header/index.jsx | 32 +++ .../dashboard/components/WhatIfDrawer/index.tsx | 260 +++++++++++++++++++++ .../src/dashboard/reducers/dashboardState.js | 7 + .../util/charts/getFormDataWithExtraFilters.ts | 18 ++ superset-frontend/src/dashboard/util/whatIf.ts | 13 ++ superset/charts/schemas.py | 10 + 10 files changed, 376 insertions(+), 2 deletions(-) diff --git a/superset-frontend/packages/superset-ui-core/src/query/extractExtras.ts b/superset-frontend/packages/superset-ui-core/src/query/extractExtras.ts index ea7d7de0c4..86f4b1fe01 100644 --- a/superset-frontend/packages/superset-ui-core/src/query/extractExtras.ts +++ b/superset-frontend/packages/superset-ui-core/src/query/extractExtras.ts @@ -43,7 +43,8 @@ type ExtractedExtra = ExtraFilterQueryField & { export default function extractExtras(formData: QueryFormData): ExtractedExtra { const applied_time_extras: AppliedTimeExtras = {}; const filters: QueryObjectFilterClause[] = []; - const extras: QueryObjectExtras = {}; + // Preserve existing extras from formData (e.g., what_if modifications) + const extras: QueryObjectExtras = { ...formData.extras }; const extract: ExtractedExtra = { filters, extras, diff --git a/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts b/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts index 9a8033a9c6..b754cca627 100644 --- a/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts +++ b/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts @@ -77,6 +77,14 @@ export type QueryObjectExtras = Partial<{ /** If true, WHERE/HAVING clauses need transpilation to target dialect */ transpile_to_dialect?: boolean; + + /** What-if analysis: column value modifications */ + what_if?: { + modifications: Array<{ + column: string; + multiplier: number; + }>; + }; }>; export type ResidualQueryObjectData = { diff --git a/superset-frontend/src/dashboard/actions/dashboardState.js b/superset-frontend/src/dashboard/actions/dashboardState.js index 9327b612df..b50e1f4dd0 100644 --- a/superset-frontend/src/dashboard/actions/dashboardState.js +++ b/superset-frontend/src/dashboard/actions/dashboardState.js @@ -785,6 +785,11 @@ export function clearWhatIfModifications() { return { type: CLEAR_WHAT_IF_MODIFICATIONS }; } +export const TOGGLE_WHAT_IF_PANEL = 'TOGGLE_WHAT_IF_PANEL'; +export function toggleWhatIfPanel(isOpen) { + return { type: TOGGLE_WHAT_IF_PANEL, isOpen }; +} + // Undo history --------------------------------------------------------------- export const SET_MAX_UNDO_HISTORY_EXCEEDED = 'SET_MAX_UNDO_HISTORY_EXCEEDED'; export function setMaxUndoHistoryExceeded(maxUndoHistoryExceeded = true) { diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx index 38f0dcac2b..3961b1e63a 100644 --- a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx +++ b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx @@ -42,6 +42,7 @@ import { import { setDirectPathToChild, setEditMode, + toggleWhatIfPanel, } from 'src/dashboard/actions/dashboardState'; import { deleteTopLevelTabs, @@ -55,6 +56,7 @@ import { DashboardStandaloneMode, } from 'src/dashboard/util/constants'; import FilterBar from 'src/dashboard/components/nativeFilters/FilterBar'; +import WhatIfPanel from 'src/dashboard/components/WhatIfDrawer'; import { useUiConfig } from 'src/components/UiConfigContext'; import ResizableSidebar from 'src/components/ResizableSidebar'; import { @@ -272,8 +274,9 @@ const DashboardContentWrapper = styled.div` const StyledDashboardContent = styled.div<{ editMode: boolean; marginLeft: number; + marginRight: number; }>` - ${({ theme, editMode, marginLeft }) => css` + ${({ theme, editMode, marginLeft, marginRight }) => css` background-color: ${theme.colorBgLayout}; display: flex; flex-direction: row; @@ -293,6 +296,7 @@ const StyledDashboardContent = styled.div<{ position: relative; margin: ${theme.sizeUnit * 4}px; margin-left: ${marginLeft}px; + margin-right: ${marginRight}px; ${editMode && ` @@ -385,6 +389,13 @@ const DashboardBuilder = () => { const filterBarOrientation = useSelector<RootState, FilterBarOrientation>( ({ dashboardInfo }) => dashboardInfo.filterBarOrientation, ); + const whatIfPanelOpen = useSelector<RootState, boolean>( + ({ dashboardState }) => dashboardState.whatIfPanelOpen ?? false, + ); + + const handleCloseWhatIfPanel = useCallback(() => { + dispatch(toggleWhatIfPanel(false)); + }, [dispatch]); const handleChangeTab = useCallback( ({ pathToTabIndex }: { pathToTabIndex: string[] }) => { @@ -563,6 +574,8 @@ const DashboardBuilder = () => { ? theme.sizeUnit * 4 : theme.sizeUnit * 8; + const dashboardContentMarginRight = theme.sizeUnit * 4; + const renderChild = useCallback( adjustedWidth => { const filterBarWidth = dashboardFiltersOpen @@ -674,6 +687,7 @@ const DashboardBuilder = () => { className="dashboard-content" editMode={editMode} marginLeft={dashboardContentMarginLeft} + marginRight={dashboardContentMarginRight} > {showDashboard ? ( missingInitialFilters.length > 0 ? ( @@ -705,6 +719,12 @@ const DashboardBuilder = () => { ) : ( <Loading /> )} + {!editMode && whatIfPanelOpen && ( + <WhatIfPanel + onClose={handleCloseWhatIfPanel} + topOffset={barTopOffset} + /> + )} {editMode && <BuilderComponentPane topOffset={barTopOffset} />} </StyledDashboardContent> </DashboardContentWrapper> diff --git a/superset-frontend/src/dashboard/components/Header/index.jsx b/superset-frontend/src/dashboard/components/Header/index.jsx index 16bec548f9..3829a23c31 100644 --- a/superset-frontend/src/dashboard/components/Header/index.jsx +++ b/superset-frontend/src/dashboard/components/Header/index.jsx @@ -88,6 +88,7 @@ import { setMaxUndoHistoryExceeded, setRefreshFrequency, setUnsavedChanges, + toggleWhatIfPanel, } from '../../actions/dashboardState'; import { logEvent } from '../../../logger/actions'; import { dashboardInfoChanged } from '../../actions/dashboardInfo'; @@ -106,6 +107,26 @@ const editButtonStyle = theme => css` color: ${theme.colorPrimary}; `; +const whatIfButtonStyle = theme => css` + display: flex; + align-items: center; + justify-content: center; + width: ${theme.sizeUnit * 8}px; + height: ${theme.sizeUnit * 8}px; + margin-right: ${theme.sizeUnit * 2}px; + border: 1px solid ${theme.colorBorder}; + border-radius: ${theme.borderRadius}px; + background: ${theme.colorBgContainer}; + color: ${theme.colorText}; + cursor: pointer; + transition: all 0.2s; + + &:hover { + border-color: ${theme.colorPrimary}; + color: ${theme.colorPrimary}; + } +`; + const actionButtonsStyle = theme => css` display: flex; align-items: center; @@ -715,6 +736,17 @@ const Header = () => { ) : ( <div css={actionButtonsStyle}> {NavExtension && <NavExtension />} + <Tooltip title={t('What-if playground')}> + <button + type="button" + css={whatIfButtonStyle} + onClick={() => dispatch(toggleWhatIfPanel(true))} + data-test="what-if-button" + aria-label={t('What-if playground')} + > + <Icons.StarFilled iconSize="m" /> + </button> + </Tooltip> {userCanEdit && ( <Button buttonStyle="secondary" diff --git a/superset-frontend/src/dashboard/components/WhatIfDrawer/index.tsx b/superset-frontend/src/dashboard/components/WhatIfDrawer/index.tsx new file mode 100644 index 0000000000..d7eca32cc8 --- /dev/null +++ b/superset-frontend/src/dashboard/components/WhatIfDrawer/index.tsx @@ -0,0 +1,260 @@ +/** + * 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 { useDispatch, useSelector } from 'react-redux'; +import { t } from '@superset-ui/core'; +import { css, styled, Alert, useTheme } from '@apache-superset/core/ui'; +import { Button, Select } from '@superset-ui/core/components'; +import Slider from '@superset-ui/core/components/Slider'; +import { Icons } from '@superset-ui/core/components/Icons'; +import { setWhatIfModifications } from 'src/dashboard/actions/dashboardState'; +import { triggerQuery } from 'src/components/Chart/chartAction'; +import { getNumericColumnsForDashboard } from 'src/dashboard/util/whatIf'; +import { RootState, WhatIfColumn } from 'src/dashboard/types'; + +export const WHAT_IF_PANEL_WIDTH = 300; + +const SLIDER_MIN = -50; +const SLIDER_MAX = 50; +const SLIDER_DEFAULT = 0; + +const PanelContainer = styled.div<{ topOffset: number }>` + width: ${WHAT_IF_PANEL_WIDTH}px; + min-width: ${WHAT_IF_PANEL_WIDTH}px; + background-color: ${({ theme }) => theme.colorBgContainer}; + border-left: 1px solid ${({ theme }) => theme.colorBorderSecondary}; + display: flex; + flex-direction: column; + overflow: hidden; + position: sticky; + top: ${({ topOffset }) => topOffset}px; + height: calc(100vh - ${({ topOffset }) => topOffset}px); + align-self: flex-start; + z-index: 10; +`; + +const PanelHeader = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + padding: ${({ theme }) => theme.sizeUnit * 3}px + ${({ theme }) => theme.sizeUnit * 4}px; + border-bottom: 1px solid ${({ theme }) => theme.colorBorderSecondary}; +`; + +const PanelTitle = styled.div` + display: flex; + align-items: center; + gap: ${({ theme }) => theme.sizeUnit * 2}px; + font-weight: ${({ theme }) => theme.fontWeightStrong}; + font-size: ${({ theme }) => theme.fontSizeLG}px; +`; + +const CloseButton = styled.button` + background: none; + border: none; + cursor: pointer; + padding: ${({ theme }) => theme.sizeUnit}px; + display: flex; + align-items: center; + justify-content: center; + color: ${({ theme }) => theme.colorTextSecondary}; + border-radius: ${({ theme }) => theme.borderRadius}px; + + &:hover { + background-color: ${({ theme }) => theme.colorBgTextHover}; + color: ${({ theme }) => theme.colorText}; + } +`; + +const PanelContent = styled.div` + flex: 1; + overflow-y: auto; + padding: ${({ theme }) => theme.sizeUnit * 4}px; + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.sizeUnit * 4}px; +`; + +const FormSection = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.sizeUnit}px; +`; + +const Label = styled.label` + font-weight: ${({ theme }) => theme.fontWeightStrong}; + color: ${({ theme }) => theme.colorText}; +`; + +const SliderContainer = styled.div` + padding: 0 ${({ theme }) => theme.sizeUnit}px; +`; + +const ApplyButton = styled(Button)` + width: 100%; +`; + +interface WhatIfPanelProps { + onClose: () => void; + topOffset: number; +} + +const WhatIfPanel = ({ onClose, topOffset }: WhatIfPanelProps) => { + const theme = useTheme(); + const dispatch = useDispatch(); + + const [selectedColumn, setSelectedColumn] = useState<string | null>(null); + const [sliderValue, setSliderValue] = useState<number>(SLIDER_DEFAULT); + + const charts = useSelector((state: RootState) => state.charts); + const datasources = useSelector((state: RootState) => state.datasources); + + const numericColumns = useMemo( + () => getNumericColumnsForDashboard(charts, datasources), + [charts, datasources], + ); + + const columnOptions = useMemo( + () => + numericColumns.map(col => ({ + value: col.columnName, + label: col.columnName, + })), + [numericColumns], + ); + + // Create a map from column name to affected chart IDs + const columnToChartIds = useMemo(() => { + const map = new Map<string, number[]>(); + numericColumns.forEach((col: WhatIfColumn) => { + map.set(col.columnName, col.usedByChartIds); + }); + return map; + }, [numericColumns]); + + const handleColumnChange = useCallback((value: string | null) => { + setSelectedColumn(value); + }, []); + + const handleSliderChange = useCallback((value: number) => { + setSliderValue(value); + }, []); + + const handleApply = useCallback(() => { + if (!selectedColumn) return; + + const multiplier = 1 + sliderValue / 100; + + // Set the what-if modifications in Redux state + dispatch( + setWhatIfModifications([ + { + column: selectedColumn, + multiplier, + }, + ]), + ); + + // Trigger queries for all charts that use the selected column + const affectedChartIds = columnToChartIds.get(selectedColumn) || []; + affectedChartIds.forEach(chartId => { + dispatch(triggerQuery(true, chartId)); + }); + }, [dispatch, selectedColumn, sliderValue, columnToChartIds]); + + const isApplyDisabled = !selectedColumn || sliderValue === SLIDER_DEFAULT; + const isSliderDisabled = !selectedColumn; + + const sliderMarks = { + [SLIDER_MIN]: `${SLIDER_MIN}%`, + 0: '0%', + [SLIDER_MAX]: `+${SLIDER_MAX}%`, + }; + + return ( + <PanelContainer data-test="what-if-panel" topOffset={topOffset}> + <PanelHeader> + <PanelTitle> + <Icons.StarFilled + iconSize="m" + css={css` + color: ${theme.colorWarning}; + `} + /> + {t('What-if playground')} + </PanelTitle> + <CloseButton onClick={onClose} aria-label={t('Close')}> + <Icons.CloseOutlined iconSize="m" /> + </CloseButton> + </PanelHeader> + <PanelContent> + <FormSection> + <Label>{t('Select column to adjust')}</Label> + <Select + value={selectedColumn} + onChange={handleColumnChange} + options={columnOptions} + placeholder={t('Choose a column...')} + allowClear + showSearch + ariaLabel={t('Select column to adjust')} + /> + </FormSection> + + <FormSection> + <Label>{t('Adjust value')}</Label> + <SliderContainer> + <Slider + min={SLIDER_MIN} + max={SLIDER_MAX} + value={sliderValue} + onChange={handleSliderChange} + disabled={isSliderDisabled} + marks={sliderMarks} + tooltip={{ + formatter: (value?: number) => + value !== undefined ? `${value > 0 ? '+' : ''}${value}%` : '', + }} + /> + </SliderContainer> + </FormSection> + + <ApplyButton + buttonStyle="primary" + onClick={handleApply} + disabled={isApplyDisabled} + > + <Icons.StarFilled iconSize="s" /> + {t('See what if')} + </ApplyButton> + + <Alert + type="info" + message={t( + 'Select a column above to simulate changes and preview how it would impact your dashboard in real-time.', + )} + showIcon + /> + </PanelContent> + </PanelContainer> + ); +}; + +export default WhatIfPanel; diff --git a/superset-frontend/src/dashboard/reducers/dashboardState.js b/superset-frontend/src/dashboard/reducers/dashboardState.js index abdb6ccbee..3f8cdeba0c 100644 --- a/superset-frontend/src/dashboard/reducers/dashboardState.js +++ b/superset-frontend/src/dashboard/reducers/dashboardState.js @@ -56,6 +56,7 @@ import { CLEAR_ALL_CHART_STATES, SET_WHAT_IF_MODIFICATIONS, CLEAR_WHAT_IF_MODIFICATIONS, + TOGGLE_WHAT_IF_PANEL, } from '../actions/dashboardState'; import { HYDRATE_DASHBOARD } from '../actions/hydrate'; @@ -347,6 +348,12 @@ export default function dashboardStateReducer(state = {}, action) { whatIfModifications: [], }; }, + [TOGGLE_WHAT_IF_PANEL]() { + return { + ...state, + whatIfPanelOpen: action.isOpen, + }; + }, }; if (action.type in actionHandlers) { diff --git a/superset-frontend/src/dashboard/util/charts/getFormDataWithExtraFilters.ts b/superset-frontend/src/dashboard/util/charts/getFormDataWithExtraFilters.ts index cd12d0ceee..9300eb2f9d 100644 --- a/superset-frontend/src/dashboard/util/charts/getFormDataWithExtraFilters.ts +++ b/superset-frontend/src/dashboard/util/charts/getFormDataWithExtraFilters.ts @@ -162,6 +162,24 @@ function buildExistingColumnsSet(chart: ChartQueryPayload): Set<string> { } }); + // Handle metric (singular) - used by pie charts and other single-metric charts + const singleMetric = chart.form_data?.metric; + if (singleMetric && typeof singleMetric === 'object') { + const metric = singleMetric as any; + if ('column' in metric) { + const metricColumn = metric.column; + if (typeof metricColumn === 'string') { + existingColumns.add(metricColumn); + } else if ( + metricColumn && + typeof metricColumn === 'object' && + 'column_name' in metricColumn + ) { + existingColumns.add(metricColumn.column_name); + } + } + } + const seriesColumn = chart.form_data?.series; if (seriesColumn) existingColumns.add(seriesColumn); diff --git a/superset-frontend/src/dashboard/util/whatIf.ts b/superset-frontend/src/dashboard/util/whatIf.ts index 53b83da462..d170f35eb7 100644 --- a/superset-frontend/src/dashboard/util/whatIf.ts +++ b/superset-frontend/src/dashboard/util/whatIf.ts @@ -74,6 +74,19 @@ export function extractColumnsFromChart(chart: ChartQueryPayload): Set<string> { } }); + // Extract metric (singular) - used by pie charts and other single-metric charts + if (formData.metric && typeof formData.metric === 'object') { + const metric = formData.metric as any; + if ('column' in metric) { + const metricColumn = metric.column; + if (typeof metricColumn === 'string') { + columns.add(metricColumn); + } else if (metricColumn?.column_name) { + columns.add(metricColumn.column_name); + } + } + } + // Extract series column (can be physical or adhoc) if (formData.series) { addColumn(formData.series); diff --git a/superset/charts/schemas.py b/superset/charts/schemas.py index a767be42b0..1fe5cf6ad1 100644 --- a/superset/charts/schemas.py +++ b/superset/charts/schemas.py @@ -1029,6 +1029,16 @@ class ChartDataExtrasSchema(Schema): }, allow_none=True, ) + what_if = fields.Dict( + metadata={ + "description": ( + "What-if analysis configuration. Contains modifications to apply " + "to column values for simulation purposes." + ), + "example": {"modifications": [{"column": "revenue", "multiplier": 1.1}]}, + }, + allow_none=True, + ) class AnnotationLayerSchema(Schema):
