You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by kg...@apache.org on 2025/12/18 23:55:17 UTC
(superset) 03/23: Implement UI
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 <ka...@gmail.com>
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):