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):

Reply via email to